Avatar upload
A round avatar preview with Change and Remove buttons over a hidden file input.
npx shadcn@latest add https://ui.goodtabs.co/r/avatar-upload.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/avatar-upload
A 64px preview with initials until a file is picked.
Large, with helper text
PNG or JPG, up to 2 MB
The lg size with a hint below the buttons that shows the picked file.
In a settings row
Profile photo
Shown next to your comments and in the team list.
The sm size at the end of a settings row, next to a title and description.
Usage
import { AvatarUpload } from "@/components/ui/avatar-upload"
<AvatarUpload src={user.avatarUrl} name={user.name} onChange={(file) => upload(file)} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | Current image, shown until a file is picked or removed. | |
| name | string | Used for the initials and the image alt text. | |
| onChange | (file: File | null) => void | Called with the picked file, or null when the image is removed. | |
| size | "sm" | "default" | "lg" | "default" | Preview size: 40px, 64px or 96px. |
| accept | string | "image/*" | Accepted file types for the picker. |
| changeLabel | string | "Change" | Label on the pick button. |
| removeLabel | string | "Remove" | Label on the remove button. |
| disabled | boolean | false | Disables both buttons. |
| children | ReactNode | Optional helper text shown under the buttons. |
Source
avatar-upload/avatar-upload.tsx
"use client";
import * as React from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Size = "sm" | "default" | "lg";
type AvatarUploadProps = Omit<React.ComponentProps<"div">, "onChange"> & {
/** Current image. Shown until a new file is picked or the image is removed. */
src?: string;
/** Used for the initials and the image alt text. */
name: string;
/** Called with the picked file, or null when the image is removed. */
onChange?: (file: File | null) => void;
/** Preview size: 40px, 64px or 96px. */
size?: Size;
/** Accepted file types for the picker. */
accept?: string;
/** Label on the pick button. */
changeLabel?: string;
/** Label on the remove button. */
removeLabel?: string;
/** Disables both buttons. */
disabled?: boolean;
};
const sizes: Record<Size, { avatar: string; text: string }> = {
sm: { avatar: "size-10", text: "text-sm" },
default: { avatar: "size-16", text: "text-base" },
lg: { avatar: "size-24", text: "text-xl" },
};
/** First letters of the first two words, upper case. */
export function initialsOf(name: string) {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("");
}
/**
* A round avatar preview with a hidden file input behind a Change button.
* The picked file is shown through an object URL, revoked when it changes.
*/
export function AvatarUpload({
src,
name,
onChange,
size = "default",
accept = "image/*",
changeLabel = "Change",
removeLabel = "Remove",
disabled,
className,
children,
...props
}: AvatarUploadProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const [preview, setPreview] = React.useState<string | null>(null);
const [removed, setRemoved] = React.useState(false);
const [announce, setAnnounce] = React.useState("");
React.useEffect(() => {
if (!preview) return;
return () => URL.revokeObjectURL(preview);
}, [preview]);
const shown = removed ? undefined : (preview ?? src);
const initials = initialsOf(name);
const pick = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
if (!file) return;
setPreview(URL.createObjectURL(file));
setRemoved(false);
setAnnounce(`Photo changed to ${file.name}`);
onChange?.(file);
};
const remove = () => {
setPreview(null);
setRemoved(true);
setAnnounce("Photo removed");
if (inputRef.current) inputRef.current.value = "";
onChange?.(null);
};
return (
<div data-slot="avatar-upload" className={cn("flex items-center gap-4", className)} {...props}>
<Avatar className={cn(sizes[size].avatar, "shrink-0")}>
{shown ? <AvatarImage src={shown} alt={name} /> : null}
<AvatarFallback delayMs={shown ? 400 : 0} className={cn("font-medium text-foreground", sizes[size].text)}>
{initials || "?"}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col items-start gap-1.5">
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="file"
accept={accept}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={pick}
disabled={disabled}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => inputRef.current?.click()}
aria-label={`${changeLabel} photo for ${name}`}
>
{changeLabel}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || !shown}
onClick={remove}
aria-label={`${removeLabel} photo for ${name}`}
>
{removeLabel}
</Button>
</div>
{children}
</div>
<span className="sr-only" aria-live="polite">
{announce}
</span>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/avatar-upload/prompt.md, or the Copy AI prompt button above.
When to use
- Profile and workspace settings where one image is picked, previewed and removed
- Onboarding steps that ask for a photo but can continue without one
- Anywhere a 40 to 96px round preview is enough to confirm the pick
When not to use
- Cover images, galleries or multiple files, use a drop zone
- When the image needs cropping before upload
- Icons and logos that are not round
Agent notes
- It takes a function (onChange), so the page or demo that renders it must be a client component
- The preview is local only; onChange gives you the File and you upload it yourself
- Object URLs are revoked when a new file is picked or the component unmounts
- Remove is disabled while there is no image, and it clears src as well as a picked file