# Avatar Upload

> A profile photo uploader with round cropping and a progress ring.

A shadcn/ui block from the [@uploadcn](https://uploadcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uploadcn/avatar-upload).

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/avatar-upload
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/avatar-upload-light-b163c35c36e45dcd.png
- Registry item: https://uploadcn.dev/r/new-york-v4/avatar-upload.json
- Categories: upload, image, forms
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/avatar-upload.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@uploadcn/core`, `@uploadcn/react`, `cn`
- Registry items: `@uploadcn/image-cropper`, `@uploadcn/upload`, `@uploadcn/use-crop-queue`

## Files

### `registry/default/blocks/avatar-upload.tsx`

```tsx
"use client"

import * as React from "react"
import { type UploadAdapter, type UploadItem } from "@uploadcn/core"
import {
  Upload as UploadPrimitive,
  useUploadContext,
  useUploadSelector,
} from "@uploadcn/react"
import { cn } from "cn"

import { IconPlaceholder } from "@/components/icon-placeholder"
import { useCropQueue } from "@/registry/default/hooks/use-crop-queue"
import { ImageCropper } from "@/registry/default/ui/image-cropper"
import {
  NativeButton,
  Upload,
  UploadTrigger,
} from "@/registry/default/ui/upload"

interface AvatarUploadProps<TResult> {
  adapter?: UploadAdapter<TResult>
  /** Current avatar URL, shown until a new one is uploaded. */
  src?: string | null
  /** Output size in pixels (square). Default 512. */
  size?: number
  /** Called with the upload result, or `null` when the avatar is removed. */
  onChange?: (item: UploadItem<TResult> | null) => void
  name?: string
  className?: string
}

function AvatarUpload<TResult = unknown>({
  adapter,
  src,
  size = 512,
  onChange,
  name,
  className,
}: AvatarUploadProps<TResult>) {
  const crops = useCropQueue()
  const { request } = crops
  const transform = React.useCallback((file: File) => request(file), [request])

  return (
    <Upload
      adapter={adapter}
      accept="image/*"
      maxSize={10 * 1000 * 1000}
      multiple={false}
      transform={transform}
      onSuccess={onChange}
      name={name}
      className={cn("w-auto flex-row items-center gap-4", className)}
    >
      <AvatarPreview fallbackSrc={src ?? null} />
      <div className="flex flex-col gap-2">
        <div className="flex gap-2">
          <UploadTrigger>
            <IconPlaceholder
              lucide="CameraIcon"
              tabler="IconCamera"
              hugeicons="Camera01Icon"
              phosphor="CameraIcon"
              remixicon="RiCameraLine"
              data-icon="inline-start"
            />
            {src ? "Change photo" : "Upload photo"}
          </UploadTrigger>
          <AvatarRemove onRemove={() => onChange?.(null)} />
        </div>
        <AvatarHint />
      </div>
      <ImageCropper
        file={crops.current}
        cropShape="round"
        aspect={1}
        output={{
          maxWidth: size,
          maxHeight: size,
          type: "image/webp",
          quality: 0.9,
        }}
        onCrop={crops.complete}
        onCancel={crops.skip}
        title="Crop your photo"
      />
    </Upload>
  )
}

function useAvatarItem() {
  const { uploader } = useUploadContext()
  return useUploadSelector(uploader, (state) => state.items.at(-1) ?? null)
}

function AvatarPreview({ fallbackSrc }: { fallbackSrc: string | null }) {
  const item = useAvatarItem()
  const { openFileDialog } = useUploadContext()
  const radius = 46
  const circumference = 2 * Math.PI * radius
  const uploading =
    item &&
    (item.status === "uploading" ||
      item.status === "queued" ||
      item.status === "validating")

  return (
    <button
      type="button"
      onClick={openFileDialog}
      aria-label="Change profile photo"
      className="group/avatar relative size-20 shrink-0 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
    >
      <span className="absolute inset-1 flex items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground">
        {item && item.status !== "rejected" && item.status !== "cancelled" ? (
          <UploadPrimitive.Item
            item={item}
            render={<span className="contents" />}
          >
            <UploadPrimitive.Preview
              thumbnailSize={256}
              className="size-full animate-in object-cover duration-300 fade-in-0 motion-reduce:animate-none"
            />
          </UploadPrimitive.Item>
        ) : fallbackSrc ? (
          // eslint-disable-next-line @next/next/no-img-element -- remote avatar URL of unknown origin
          <img src={fallbackSrc} alt="" className="size-full object-cover" />
        ) : (
          <IconPlaceholder
            lucide="UserIcon"
            tabler="IconUser"
            hugeicons="UserIcon"
            phosphor="UserIcon"
            remixicon="RiUserLine"
            className="size-8"
            aria-hidden
          />
        )}
        <span className="absolute inset-0 flex items-center justify-center bg-background/60 opacity-0 transition-opacity group-hover/avatar:opacity-100 group-focus-visible/avatar:opacity-100">
          <IconPlaceholder
            lucide="CameraIcon"
            tabler="IconCamera"
            hugeicons="Camera01Icon"
            phosphor="CameraIcon"
            remixicon="RiCameraLine"
            className="size-5"
            aria-hidden
          />
        </span>
      </span>
      {item ? (
        <UploadPrimitive.Item
          item={item}
          render={<span className="contents" />}
        >
          <UploadPrimitive.Progress
            className={cn(
              "pointer-events-none absolute inset-0 transition-opacity",
              !uploading && "opacity-0"
            )}
            render={(props, state) => (
              <svg
                {...(props as React.ComponentProps<"svg">)}
                viewBox="0 0 100 100"
                aria-hidden={!uploading}
              >
                <circle
                  cx="50"
                  cy="50"
                  r={radius}
                  fill="none"
                  strokeWidth="4"
                  className="stroke-muted"
                />
                <circle
                  cx="50"
                  cy="50"
                  r={radius}
                  fill="none"
                  strokeWidth="4"
                  strokeLinecap="round"
                  strokeDasharray={circumference}
                  strokeDashoffset={circumference * (1 - state.percent / 100)}
                  className="origin-center -rotate-90 stroke-primary transition-[stroke-dashoffset] duration-300 motion-reduce:transition-none"
                />
              </svg>
            )}
          />
        </UploadPrimitive.Item>
      ) : null}
    </button>
  )
}

function AvatarRemove({ onRemove }: { onRemove: () => void }) {
  const item = useAvatarItem()
  const { uploader } = useUploadContext()
  if (!item) return null
  return (
    <NativeButton
      variant="ghost"
      size="sm"
      onClick={() => {
        uploader.remove()
        onRemove()
      }}
    >
      Remove
    </NativeButton>
  )
}

function AvatarHint() {
  const item = useAvatarItem()
  if (item?.status === "error" || item?.status === "rejected") {
    return (
      <p className="inline-flex items-center gap-1 text-xs text-destructive">
        <IconPlaceholder
          lucide="CircleAlertIcon"
          tabler="IconExclamationCircle"
          hugeicons="AlertCircleIcon"
          phosphor="WarningCircleIcon"
          remixicon="RiErrorWarningLine"
          className="size-3"
          aria-hidden
        />
        {item.issues[0]?.message ?? item.error?.message}
      </p>
    )
  }
  return (
    <p className="text-xs text-muted-foreground">
      JPG, PNG or WebP. Cropped to a square.
    </p>
  )
}

export { AvatarUpload, type AvatarUploadProps }
```
