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 (<AvatarUploadhandler={handler}value={avatarUrl}onChange={setAvatarUrl}fallback="JD" // Shown when no avatarsize="lg"/>)}
Props
| Prop | Type | Description |
|---|---|---|
| handler | UploadHandler | Upload handler (required) |
| value | string | null | Current avatar URL |
| onChange | (url: string | null) => void | Called when avatar changes |
| fallback | string | Initials to show when no avatar (uses first 2 chars) |
| size | "sm" | "default" | "lg" | "xl" | Avatar size (default: "default") |
| maxSize | number | Max file size in bytes (default: 5MB) |
| accept | string[] | Accepted MIME types |
| allowRemove | boolean | Show remove button (default: true) |
| disabled | boolean | Disable 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)}><AvatarUploadhandler={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)}><divrole="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")}><inputref={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 && (<buttontype="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>);}