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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/avatar-upload.jsonPrompt
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.
Video Playlist
A queue that sits beside your player — thumbnail rows with duration and watched progress, a now-playing marker, cancellable autoplay-next, shuffle and repeat, and a keyboard-walkable listbox.
File Icon
An extension-to-icon mark — nine file families, a four-step size scale, tints drawn from your own tokens, and the extension printed whenever the type is unknown.