Avatar Upload

A circular avatar upload component with preview, fallback initials, loading states, and remove functionality.

Demo

Small
JD

Click to upload

Default
JD

Click to upload

Large
JD

Click to upload

Extra Large
JD

Click to upload

Basic Usage

profile-avatar.tsx
import { AvatarUpload, createStack0Handler } from '@stack0/elements'
const handler = createStack0Handler({
apiKey: process.env.NEXT_PUBLIC_STACK0_API_KEY!,
folder: 'avatars',
})
export function ProfileAvatar() {
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
return (
<AvatarUpload
handler={handler}
value={avatarUrl}
onChange={setAvatarUrl}
fallback="JD" // Shown when no avatar
size="lg"
/>
)
}

Props

PropTypeDescription
handlerUploadHandlerUpload handler (required)
valuestring | nullCurrent avatar URL
onChange(url: string | null) => voidCalled when avatar changes
fallbackstringInitials to show when no avatar (uses first 2 chars)
size"sm" | "default" | "lg" | "xl"Avatar size (default: "default")
maxSizenumberMax file size in bytes (default: 5MB)
acceptstring[]Accepted MIME types
allowRemovebooleanShow remove button (default: true)
disabledbooleanDisable interactions

Size Variants

sizes.tsx
// Small (64x64)
<AvatarUpload handler={handler} size="sm" fallback="JD" />
// Default (96x96)
<AvatarUpload handler={handler} size="default" fallback="JD" />
// Large (128x128)
<AvatarUpload handler={handler} size="lg" fallback="JD" />
// Extra Large (160x160)
<AvatarUpload handler={handler} size="xl" fallback="JD" />

With Form Integration

form-integration.tsx
import { useForm } from 'react-hook-form'
import { AvatarUpload, createStack0Handler } from '@stack0/elements'
const handler = createStack0Handler({ apiKey: '...' })
function ProfileForm() {
const form = useForm({
defaultValues: {
name: '',
avatar: null as string | null,
},
})
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<AvatarUpload
handler={handler}
value={form.watch('avatar')}
onChange={(url) => form.setValue('avatar', url)}
fallback={form.watch('name')?.slice(0, 2) || 'U'}
size="lg"
/>
<input {...form.register('name')} placeholder="Name" />
<button type="submit">Save Profile</button>
</form>
)
}

Full Component Source

Copy this into your project at components/ui/avatar-upload.tsx:

components/ui/avatar-upload.tsx
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { Camera, Loader2, User, X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
interface UploadHandler {
getUploadUrl: (file: File) => Promise<{ uploadUrl: string; assetId?: string }>;
onUploadComplete?: (assetId: string, file: File) => Promise<{ url?: string }>;
onError?: (error: Error, file: File) => void;
}
const avatarVariants = cva(
"relative rounded-full overflow-hidden bg-muted flex items-center justify-center cursor-pointer transition-all group",
{
variants: {
size: {
sm: "h-16 w-16",
default: "h-24 w-24",
lg: "h-32 w-32",
xl: "h-40 w-40",
},
},
defaultVariants: { size: "default" },
}
);
interface AvatarUploadProps extends VariantProps<typeof avatarVariants> {
handler: UploadHandler;
value?: string | null;
onChange?: (url: string | null) => void;
onUploadComplete?: (url: string) => void;
maxSize?: number;
accept?: string[];
allowRemove?: boolean;
disabled?: boolean;
fallback?: string;
className?: string;
}
type UploadState = "idle" | "uploading" | "processing" | "error";
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
export function AvatarUpload({
handler,
value,
onChange,
onUploadComplete,
maxSize = 5 * 1024 * 1024,
accept = ["image/jpeg", "image/png", "image/gif", "image/webp"],
allowRemove = true,
disabled = false,
fallback,
size,
className,
}: AvatarUploadProps) {
const [uploadState, setUploadState] = React.useState<UploadState>("idle");
const [error, setError] = React.useState<string | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
return () => { previewUrl && URL.revokeObjectURL(previewUrl); };
}, [previewUrl]);
const handleClick = () => !disabled && uploadState === "idle" && inputRef.current?.click();
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
previewUrl && URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
onChange?.(null);
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/") || !accept.includes(file.type)) {
setError("Please select a valid image");
return;
}
if (file.size > maxSize) {
setError(`Image must be less than ${formatBytes(maxSize)}`);
return;
}
setError(null);
const objectUrl = URL.createObjectURL(file);
previewUrl && URL.revokeObjectURL(previewUrl);
setPreviewUrl(objectUrl);
try {
setUploadState("uploading");
const { uploadUrl, assetId } = await handler.getUploadUrl(file);
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
setUploadState("processing");
let finalUrl = objectUrl;
if (handler.onUploadComplete && assetId) {
const result = await handler.onUploadComplete(assetId, file);
if (result.url) {
finalUrl = result.url;
URL.revokeObjectURL(objectUrl);
setPreviewUrl(null);
}
}
setUploadState("idle");
onChange?.(finalUrl);
onUploadComplete?.(finalUrl);
} catch (err) {
setUploadState("error");
setError(err instanceof Error ? err.message : "Upload failed");
handler.onError?.(err instanceof Error ? err : new Error("Upload failed"), file);
}
};
const displayUrl = previewUrl || value;
const isLoading = uploadState === "uploading" || uploadState === "processing";
return (
<div className={cn("flex flex-col items-center gap-2", className)}>
<div
role="button"
tabIndex={disabled ? -1 : 0}
onClick={handleClick}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && handleClick()}
className={cn(
avatarVariants({ size }),
disabled && "opacity-50 cursor-not-allowed",
"focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
)}
>
<input
ref={inputRef}
type="file"
accept={accept.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={disabled || isLoading}
/>
{displayUrl ? (
<img src={displayUrl} alt="Avatar" className="h-full w-full object-cover" />
) : fallback ? (
<span className={cn(
"font-medium text-muted-foreground uppercase",
size === "sm" && "text-lg",
size === "default" && "text-xl",
size === "lg" && "text-2xl",
size === "xl" && "text-3xl"
)}>
{fallback.slice(0, 2)}
</span>
) : (
<User className={cn(
"text-muted-foreground",
size === "sm" && "h-6 w-6",
size === "default" && "h-8 w-8",
size === "lg" && "h-10 w-10",
size === "xl" && "h-12 w-12"
)} />
)}
{!disabled && !isLoading && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="h-6 w-6 text-white" />
</div>
)}
{isLoading && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-white animate-spin" />
</div>
)}
{allowRemove && displayUrl && !disabled && !isLoading && (
<button
type="button"
onClick={handleRemove}
className="absolute -top-1 -right-1 h-6 w-6 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="h-3 w-3" />
</button>
)}
</div>
<div className="text-center">
{error ? (
<p className="text-xs text-destructive">{error}</p>
) : isLoading ? (
<p className="text-xs text-muted-foreground">
{uploadState === "uploading" ? "Uploading..." : "Processing..."}
</p>
) : (
<p className="text-xs text-muted-foreground">
Click to {displayUrl ? "change" : "upload"}
</p>
)}
</div>
</div>
);
}