Media

Avatar Upload

The whole avatar flow in one control — pick or drop, refuse with a spoken reason, crop square, upload with a progress ring and cancel, and keep the crop when the transfer fails.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Crop, ImageUp, RefreshCw, Trash2, Upload, X, ZoomIn, ZoomOut } from "lucide-react"
import { cn } from "@/lib/utils"

type Phase = "idle" | "checking" | "cropping" | "uploading" | "error"
type FocusTarget = "trigger" | "crop" | "save" | "cancelUpload" | "retry"
type Vec2 = { x: number; y: number }
type Message = { tone: "info" | "error"; text: string }
/** 三格 object URL:选中的原图 / 待上传的裁切图 / 上传成功后的本地头像。 */
type FlowUrls = { source: string | null; crop: string | null; avatar: string | null }

export interface AvatarUploadTransfer {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/avatar-upload.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AvatarUpload" component: one control that
carries a profile photo from "pick a file" all the way to "stored on your
server". lucide-react for icons; canvas for the export; no cropping library, no
upload library.

Contract
- forwardRef<HTMLDivElement>, props extend React.HTMLAttributes<HTMLDivElement>,
  className merged through cn(), the rest spread on the root card.
- src?: string | null (current avatar, owned by the consumer),
  name?: string (initials fallback + the accessible name of the empty disc),
  size?: number = 88 (disc diameter, px), cropSize?: number = 224 (crop stage
  width, px; it shrinks with a narrow container), outputSize?: number = 512
  (exported square edge, px), accept?: string = "image/png,image/jpeg,image/webp"
  (native accept syntax: extension, wildcard or exact MIME), maxSize?: number =
  5 MB, minDimension?: number = 128, outputType?: string = "image/jpeg",
  quality?: number = 0.92, maxZoom?: number = 4, disabled?: boolean,
  labels?: Partial<AvatarUploadLabels> (every user-visible string).
- onUpload: (blob: Blob, transfer: { signal: AbortSignal; onProgress:
  (fraction: number) => void }) => Promise<void> — required, because transport
  is the consumer's business: resolve means stored, reject means failed and the
  rejection's Error.message becomes the visible reason. Never calling onProgress
  is legal and yields an indeterminate ring.
- onUploaded?: (blob: Blob) => void fires once the transfer resolved, with the
  exact square blob that was sent. onRemove?: () => void, omitted = no Remove
  control.
- Internally the whole thing is one phase machine: "idle" | "checking" |
  "cropping" | "uploading" | "error". Every branch is a first-class render path,
  not an && bolted onto the idle layout.

Behavior
- Two doors in: a hidden <input type="file"> driven by the trigger button, and a
  drop target on the idle row (dragenter/dragleave fire on every child, so count
  drag depth and only clear the highlight at zero; dragover must preventDefault
  or the browser refuses the drop). Reset input.value after every change, or
  picking the same file twice fires nothing. A multi-file drop takes the first
  and says so.
- Three refusals, each a sentence rather than a shrug, and none of them touches
  the photo already on screen:
  · wrong type — "PNG, JPEG or WebP only — <name> is application/pdf."
  · too large — "<name> is 8.4 MB — the limit is 5 MB."
  · too small — "That image is 64x64px — at least 128x128px is needed."
  The first two are cheap header checks; the third can only be answered after a
  real decode, so the accepted file goes through an object URL into an
  Image() probe, and that probe element is kept as the canvas source later —
  it is guaranteed loaded, unlike the <img> in the DOM.
- Crop stage: a square frame, the image cover-scaled so zoom = 1 exactly covers
  it and zooming out past that is impossible. Drag with pointer capture,
  arrow keys nudge 8px (24px with Shift), + / - zoom, 0 resets, and a role=slider
  thumb takes Arrow/Home/End. Pan is clamped so no background can ever show
  through. Measure the frame with a ResizeObserver: the same number drives the
  CSS scale and the exported source rectangle, so what is inside the circle is
  what gets encoded.
- Export inverts the preview transform: sourceSide = frame / totalScale, and the
  top-left corner is naturalCenter + (-frame/2 - position) / totalScale, clamped
  into the image. drawImage that square into an outputSize x outputSize canvas,
  toBlob(outputType, quality). The mask is round but the blob is square — the
  circle is a display convention, not a file format.
- One live transfer, ever. Each run takes a monotonic id written synchronously in
  the handler plus its own AbortController; a resolution whose id is stale, whose
  signal is aborted or whose component unmounted is dropped on the floor. That is
  what makes Cancel silent: the uploader rejects with AbortError, and the run it
  belongs to is already invalid, so it never becomes a visible failure.
- A failure keeps the crop. The error phase holds the very blob that failed:
  Retry re-sends those exact bytes (no re-crop, no re-encode), Adjust crop walks
  back to the stage with zoom and position intact, Discard ends the flow. Cancel
  during a transfer also returns to the stage, not to zero.
- Progress: a ring around the disc, role=progressbar with aria-valuenow +
  aria-valuetext when the uploader reports, and no aria-valuenow at all when it
  does not (absent valuenow is the ARIA way to say indeterminate).
- Object URL discipline. Exactly three slots exist — source, crop, avatar — and
  one function is the only way to write them: it revokes the outgoing URL unless
  another slot has taken it over. On success the crop URL is *adopted* as the
  avatar URL rather than a fourth being created. Unmount aborts the transfer and
  revokes all three. Async callbacks read those URLs from a ref, never from
  state, because they run inside the closure of the render that created them.
- Focus never lands on <body>. Every transition that unmounts the control the
  user is standing on names a successor: checking → cropping focuses the crop
  area, Save focuses Cancel upload, success focuses the trigger, failure focuses
  Retry, Cancel focuses Save, Remove focuses the trigger. Implement it as a ref
  written in the handler plus a tick of state, and move focus in an effect after
  the commit — but only when document.activeElement is still inside the root or
  has already fallen to <body>, so an upload that finishes two minutes later
  cannot yank focus back from wherever the user went.
- Nothing native-disabled: the trigger while checking, and everything while
  `disabled`, uses aria-disabled plus an early return in the handler, so the
  control keeps its place in the tab order.
- A permanently mounted role="status" line carries every verdict (refusal,
  canceled, updated, removed, failure). Mounted from the start, because a live
  region that appears together with its text is not announced. Progress percent
  deliberately stays out of it — that is what aria-valuetext is for.
- A src prop that changes means the consumer took over: drop and revoke the local
  preview in an effect keyed on src.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground on the shell, bg-muted for
  the disc and the crop backdrop, ring-border, bg-primary /
  text-primary-foreground for the primary action and the progress arc
  (stroke-primary over stroke-muted), accent for hovers, text-muted-foreground
  for hints, text-destructive for refusals and failures, border-primary +
  bg-accent/40 for the drag-over state. The dimming outside the crop circle is a
  huge box-shadow spread in color-mix(in oklab, var(--background) 66%,
  transparent) — no hex anywhere.
- cn() merges every className; focus-visible:ring-2 ring-ring ring-offset-2 on
  every button, the crop area and the zoom thumb.
- Motion is decoration: the arc has a 200ms stroke-dashoffset transition and the
  indeterminate ring spins, both behind motion-reduce. With motion off the ring
  is a static arc and the text still reads "Uploading…".

Customization levers
- Density and shape: size (disc), cropSize (stage) and outputSize (file) are
  independent; a rectangular cover-photo variant only needs an aspect prop fed
  into the frame's aspect-ratio and into the export rectangle.
- Sub-blocks are optional: drop the zoom row for a fixed cover crop, drop the
  drop target for a pure button flow, drop onRemove to hide removal, or move the
  crop stage into a Dialog — nothing in the logic assumes it is inline.
- Rules: accept / maxSize / minDimension drive both the gate and the hint line,
  so tightening them is a one-prop change and the copy follows. All user-visible
  strings live in labels; the three refusal sentences are composed from the file
  itself.
- Transport: onUpload is the whole integration surface — fetch, XHR with real
  upload progress, presigned S3 PUT, tus. Report 0..1 for a filling ring, report
  nothing for a spinning one, throw an Error whose message is worth reading.
- Tokens: recolour the arc to var(--chart-2) to match a dashboard, or swap the
  ring for a linear bar under the disc; both read the same progress value.

Concepts

  • One control, five phases — idle, checking, cropping, uploading, error are branches of one machine rather than three components glued together, which is what lets a failure walk backwards into the crop instead of dumping the user at the file picker.
  • Refusal is a sentence — type, size and pixel dimensions each produce a specific spoken reason ("that image is 64x64px…"), and a refusal never disturbs the avatar already on screen; the first two checks read the file header, the third needs a real decode, so validation is split across a probe.
  • The crop outlives the transfer — the exported blob is held in a ref, so Retry re-sends identical bytes and Cancel returns to the stage with zoom and position intact; nothing about a network failure invalidates the work of framing a face.
  • One live run — a monotonic run id written synchronously in the handler plus a per-run AbortController means a stale resolution, a canceled transfer and a double-clicked Retry are all impossible to observe; a cancel-induced AbortError is silence, not a failure.
  • Object URL ownership — three named slots (source, crop, avatar) with a single writer that revokes the outgoing URL unless another slot adopted it; success transfers the crop URL to the avatar slot instead of minting a fourth, and unmount revokes whatever is left.
  • A named successor for focus — every control here can vanish under the user (the crop area on Save, Cancel upload on success, Remove when the last photo goes), so each transition names the element that takes focus next instead of letting it fall to <body>; the hand-off is skipped when the user has meanwhile moved focus somewhere else entirely, because taking focus back is as rude as losing it.

On This Page