Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
srcstringCurrent image, shown until a file is picked or removed.
namestringUsed for the initials and the image alt text.
onChange(file: File | null) => voidCalled with the picked file, or null when the image is removed.
size"sm" | "default" | "lg""default"Preview size: 40px, 64px or 96px.
acceptstring"image/*"Accepted file types for the picker.
changeLabelstring"Change"Label on the pick button.
removeLabelstring"Remove"Label on the remove button.
disabledbooleanfalseDisables both buttons.
childrenReactNodeOptional 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