# Avatar Upload

> A React avatar upload for profile pictures: pick or shoot a photo, crop it in a round frame and watch a ring fill while it uploads.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add avatar-upload`
- Docs: https://wingo-ui.com/components/avatar-upload

## Interaction

Tap or click the avatar (it dips on a quick spring; with a mouse or on keyboard focus a dark scrim says Change photo) to open the source menu: Choose a photo, Take photo and Remove photo, a bottom sheet on phones. With only one entry it goes straight to the file picker. After picking, the crop step opens in a dialog (a sheet on phones) with a round frame: drag to move, pinch or scroll to zoom, the zoom and rotation sliders and a 90° turn below, then Save exports a square WebP at 512px. The new picture crossfades in at once while a ring around the avatar fills with the upload progress (it spins when the server reports none); when it finishes a green check pops over the camera badge for a moment and Photo updated is announced. If the upload fails the old picture comes back, the ring turns red, the badge becomes a retry arrow, the error shows under the avatar and Try again sits at the top of the menu. Remove runs at once (a returned promise shows the spinning ring and rolls back on rejection). Drag a photo onto the avatar or the dropzone (it grows a little and turns the tone color), or paste an image while it is focused. Take photo uses the front camera on phones; on desktops it opens a mirrored live camera with a round shutter, and a refused permission shows a message with Try again and Choose a photo. Keyboard: Tab to the avatar, Enter or Space opens the menu (arrows move, Escape closes and focus returns), Delete or Backspace removes the picture, and inside the crop step arrows nudge, + and - zoom and r rotates. The row look puts Change photo and Remove buttons with the hint beside the avatar; the dropzone look is a dashed tile with the avatar inside. Read-only shows the picture without a badge or menu.

## Installation

```bash
npx wingo-ui@latest add avatar-upload
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/avatar-upload` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/avatar-upload.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Image Cropper](https://wingo-ui.com/components/image-cropper) ([Markdown](https://wingo-ui.com/components/image-cropper.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dialog](https://wingo-ui.com/components/dialog) ([Markdown](https://wingo-ui.com/components/dialog.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [File types](https://wingo-ui.com/components/file-types) ([Markdown](https://wingo-ui.com/components/file-types.md))
- [File export](https://wingo-ui.com/components/file-export) ([Markdown](https://wingo-ui.com/components/file-export.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [Haptics](https://wingo-ui.com/components/haptics) ([Markdown](https://wingo-ui.com/components/haptics.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { AvatarUpload } from "@/components/ui/avatar-upload"

export function ProfilePhoto() {
  const [avatar, setAvatar] = useState<string | null>("/demo/ana-pop.jpg")

  return (
    <AvatarUpload
      name="Anna Price"
      value={avatar}
      onValueChange={setAvatar}
      onUpload={async (blob, { signal, onProgress }) => {
        const body = new FormData()
        body.append("avatar", blob, "avatar.webp")
        onProgress(0.3)
        const response = await fetch("/api/profile/avatar", { method: "POST", body, signal })
        if (!response.ok) throw new Error("The upload failed")
        onProgress(1)
        const { url } = (await response.json()) as { url: string }
        return url
      }}
      onRemove={() => fetch("/api/profile/avatar", { method: "DELETE" })}
      labels={{
        change: "Update photo",
        cropTitle: "Adjust your photo",
        uploaded: "Looking good! Photo updated",
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value?` | `string \| null` |  | The controlled picture url; null shows the initials. It becomes the url your onUpload resolves, or a local object url when it resolves nothing. |
| `defaultValue?` | `string \| null` | `"null"` | The uncontrolled starting picture (a url); null shows the initials. The playground is controlled through value instead. |
| `onValueChange?` | `(value: string \| null) => void` |  | Fires with the new url after an upload succeeds, with null on remove, and with the old url when a remove is rolled back. |
| `name?` | `string` | `""` | The person's name: initials and a stable color while there is no picture, and part of the button's accessible name (Change photo, Anna Price). |
| `onUpload?` | `(blob: Blob, context: { file: File \| null; signal: AbortSignal; onProgress: (fraction: number) => void }) => Promise<string \| void>` |  | Gets the cropped square. Call onProgress(0 to 1) to fill the ring (without calls it spins) and resolve the new url. A rejection rolls the picture back and offers Try again. The signal aborts when the component unmounts or a newer upload starts; a result that arrives after the value changed from outside is ignored. Without onUpload the blob becomes a local object url. |
| `onRemove?` | `() => (void \| Promise<unknown>)` |  | The Remove action, shown only when set and there is a picture. It runs at once without a confirm (offer undo in a toast); a returned promise shows the spinning ring and rolls back on rejection. |
| `onError?` | `(error: Error) => void` |  | A wrong file type, a file over maxFileSize, a failed export, upload or remove. |
| `variant?` | `"avatar" \| "row" \| "dropzone"` | `"avatar"` | avatar: the picture with a camera badge, the whole avatar is the button. row: the picture with Change photo and Remove buttons and the hint beside it, for settings lists. dropzone: a dashed tile with the avatar inside, for logos and onboarding. |
| `size?` | `"md" \| "lg" \| "xl"` | `"lg"` | The avatar: 64 / 96 / 128px. The badge and the check scale with it (24 / 32 / 36px, a 44px hit area on touch). |
| `shape?` | `"circle" \| "rounded"` | `"circle"` | The avatar and the crop frame. circle exports with transparent corners (unless JPEG); rounded suits logos and crops a plain square. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The camera badge, the progress ring, the drag-over tint and the Save button. A failed upload always turns the ring red. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. |
| `ringColor?` | `string` | `""` | The thin ring that separates the badge and the check from the avatar. Set it to the surface behind the avatar (the page background by default). |
| `label?` | `ReactNode` | `""` | The field label above the control (W2 field anatomy). |
| `description?` | `ReactNode` | `""` | Helper text under the control. |
| `error?` | `ReactNode` | `""` | Replaces the description, sets aria-invalid and marks the dropzone red. |
| `showHint?` | `boolean` | `true` | The hint (JPG, PNG or WebP, up to 10 MB) under the buttons in the row look and inside the dropzone. |
| `required?` | `boolean` | `false` | Marks the label with an asterisk and sets aria-required. |
| `invalid?` | `boolean` | `false` | Invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the control; no menu, no drops. |
| `readOnly?` | `boolean` | `false` | Shows the picture without a badge, a menu or drops. |
| `progress?` | `number \| null` |  | External progress (0 to 1) for uploads you run yourself: the ring shows at that share, null spins it, undefined hides it. The playground's fake upload drives the ring itself. |
| `crop?` | `boolean` | `true` | The crop step. false uploads the largest centered square at once, without the dialog. |
| `camera?` | `boolean` | `true` | The Take photo entry: the front camera through the file input on phones (capture="user"), a live camera dialog on desktops with getUserMedia, hidden where neither works. |
| `accept?` | `string` | `"image/jpeg,image/png,image/webp"` | Allowed types for the picker, drops and pastes; other files get labels.wrongType. Safari converts iPhone HEIC photos to JPEG on pick when JPEG is listed; other browsers reject HEIC. |
| `maxFileSize?` | `number` | `10485760` | Bytes of the chosen file (10 MB); bigger files get labels.tooLarge with the limit formatted. 0 means no limit. The export itself is small. |
| `outputSize?` | `number` | `512` | Width and height of the exported square, in px. |
| `outputType?` | `"image/webp" \| "image/jpeg" \| "image/png"` | `"image/webp"` | The exported format. JPEG has no transparency, so a round crop fills the corners white. |
| `outputQuality?` | `number` | `0.9` | 0 to 1, for WebP and JPEG. |
| `responsive?` | `boolean` | `true` | The source menu, the crop step and the camera become bottom sheets below 768px. |
| `locale?` | `string` | `""` | Formats the size limit in the error (10 MB). Empty uses UIProvider's locale, then the browser's. |
| `inputName?` | `string` | `""` | Renders a hidden input with this name and the current url, for plain form posts. (name is the person's name here.) |
| `id?` | `string` |  | The trigger's id; the label and the messages point at it. |
| `cropperProps?` | `Partial<Omit<ImageCropperProps, "src">>` |  | Extra props for the crop step's ImageCropper, e.g. { labels } to translate it, { height: 360 } or { flippable: true }. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel. |
| `labels?` | `Partial<AvatarUploadLabels>` |  | Every visible and announced string: change, choose, takePhoto, remove, cropTitle, cameraTitle, save, cancel, uploading, uploaded, removed, failed, removeFailed, tooLarge(max), wrongType, cameraDenied, cameraStarting, shutter, hint, retry, dropTitle, dropTitleTouch, dropHere. Pass only the keys you translate. |
| `classNames?` | `Partial<Record<"root" \| "trigger" \| "avatar" \| "badge" \| "progress" \| "check" \| "actions" \| "hint" \| "message" \| "dialog" \| "camera" \| "field" \| "label" \| "description" \| "error" \| "counter", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root (the Field when a label, description or error is set). |

## Parts

### DEFAULT_AVATAR_UPLOAD_LABELS (constant)

The English copy ("Change photo", "Crop your photo", "Take photo"...). Spread it into labels when you translate only a few keys.

### AvatarUploadHandler (type)

The onUpload signature: (blob, { file, signal, onProgress }) => Promise<string | void>. Resolve with the new url; call onProgress(0 to 1) to fill the ring (without calls it spins); signal aborts on unmount or a newer upload.

## Examples

- **Row in a settings list**: Change and Remove beside the avatar, with the hint under them.
- **Company logo dropzone**: A rounded square exported as PNG; drag a file onto the tile.
- **Upload fails, then retry**: The old picture comes back, the ring turns red and Try again tops the menu.
- **Quick upload without cropping**: crop={false}: the centered square goes straight up.
- **Sizes**: 64, 96 and 128px; the badge and the check scale along.
- **Initials only**: No picture and no onRemove: the menu has only the sources.
- **Required in a form**: Field label, description and error, a brand tone and a hidden input.

## See also

- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Image Cropper](https://wingo-ui.com/components/image-cropper) ([Markdown](https://wingo-ui.com/components/image-cropper.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))
- [File export](https://wingo-ui.com/components/file-export) ([Markdown](https://wingo-ui.com/components/file-export.md))

---

Canonical: https://wingo-ui.com/components/avatar-upload
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
