Componentpickers1.31 KB gzipZero dependencies
Avatar Upload
Avatar upload circle with hover camera overlay and preview.
Preview
Live Preview
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add avatar-uploadOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { AvatarUpload } from "@/components/ui/avatar-upload";
<AvatarUpload />Usage guidance
Use Avatar Upload when a ticket stub surface needs Avatar upload circle with hover camera overlay and preview. Keep props minimal and prefer composition with sibling stubs.
Avoid Avatar Upload for long form flows or dense data grids where a dedicated layout block fits better.
Source code
avatar-upload.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export interface AvatarUploadProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
initials?: string;
src?: string;
onChange?: (file: File) => void;
}
/** Circular avatar uploader: hover camera overlay + loading spinner. */
export function AvatarUpload({ initials = "BB", src, onChange, className, ...props }: AvatarUploadProps) {
const [preview, setPreview] = React.useState<string | null>(src ?? null);
const [uploading, setUploading] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
onChange?.(file);
const reader = new FileReader();
reader.onload = () => {
setPreview(reader.result as string);
setUploading(false);
};
reader.readAsDataURL(file);
};
return (
<div className={cn("relative inline-block", className)} {...props}>
<style>{`@keyframes avSpin { to { transform: rotate(360deg); } }`}</style>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="group relative block size-24 overflow-hidden rounded-full border-2 border-dashed border-border bg-card transition-all duration-200 hover:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
aria-label="Upload avatar"
>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Avatar" className="size-full object-cover" />
) : (
<span className="flex size-full items-center justify-center font-mono text-xl font-bold text-muted-foreground">{initials}</span>
)}
{/* Camera overlay */}
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100 motion-reduce:transition-none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-white" aria-hidden="true"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" /><circle cx="12" cy="13" r="4" /></svg>
</span>
{uploading && (
<span className="absolute inset-0 flex items-center justify-center bg-background/70">
<span className="size-6 rounded-full border-2 border-accent border-t-transparent" style={{ animation: "avSpin 0.8s linear infinite" }} aria-label="Loading" />
</span>
)}
</button>
<input ref={inputRef} type="file" accept="image/*" className="sr-only" onChange={handleChange} tabIndex={-1} aria-hidden="true" />
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| initials | string | |
| src | string | |
| onChange | (file: File) => void |