Avatar Upload React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
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.
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.
Avatar Upload is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add avatar-upload, together with Image Cropper, Avatar, Button, Dialog, Dropdown Menu and 12 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add avatar-uploadAvatar Upload is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import AvatarUpload from @/components/ui/avatar-upload.
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
valuestring | nullThe controlled picture url; null shows the initials. It becomes the url your onUpload resolves, or a local object url when it resolves nothing.
defaultValuedefault"null"string | nullThe uncontrolled starting picture (a url); null shows the initials. The playground is controlled through value instead.
onValueChange(value: string | null) => voidFires with the new url after an upload succeeds, with null on remove, and with the old url when a remove is rolled back.
namedefault""stringThe 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) => voidA wrong file type, a file over maxFileSize, a failed export, upload or remove.
variantdefault"avatar""avatar" | "row" | "dropzone"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.
sizedefault"lg""md" | "lg" | "xl"The avatar: 64 / 96 / 128px. The badge and the check scale with it (24 / 32 / 36px, a 44px hit area on touch).
shapedefault"circle""circle" | "rounded"The avatar and the crop frame. circle exports with transparent corners (unless JPEG); rounded suits logos and crops a plain square.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The camera badge, the progress ring, the drag-over tint and the Save button. A failed upload always turns the ring red.
colordefault""stringAny CSS color; replaces the tone.
ringColordefault""stringThe 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).
labeldefault""ReactNodeThe field label above the control (W2 field anatomy).
descriptiondefault""ReactNodeHelper text under the control.
errordefault""ReactNodeReplaces the description, sets aria-invalid and marks the dropzone red.
showHintdefaulttruebooleanThe hint (JPG, PNG or WebP, up to 10 MB) under the buttons in the row look and inside the dropzone.
requireddefaultfalsebooleanMarks the label with an asterisk and sets aria-required.
invaliddefaultfalsebooleanInvalid without a message.
disableddefaultfalsebooleanDims the control; no menu, no drops.
readOnlydefaultfalsebooleanShows the picture without a badge, a menu or drops.
progressnumber | nullExternal 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.
cropdefaulttruebooleanThe crop step. false uploads the largest centered square at once, without the dialog.
cameradefaulttruebooleanThe 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.
acceptdefault"image/jpeg,image/png,image/webp"stringAllowed 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.
maxFileSizedefault10485760numberBytes of the chosen file (10 MB); bigger files get labels.tooLarge with the limit formatted. 0 means no limit. The export itself is small.
outputSizedefault512numberWidth and height of the exported square, in px.
outputTypedefault"image/webp""image/webp" | "image/jpeg" | "image/png"The exported format. JPEG has no transparency, so a round crop fills the corners white.
outputQualitydefault0.9number0 to 1, for WebP and JPEG.
responsivedefaulttruebooleanThe source menu, the crop step and the camera become bottom sheets below 768px.
localedefault""stringFormats the size limit in the error (10 MB). Empty uses UIProvider's locale, then the browser's.
inputNamedefault""stringRenders a hidden input with this name and the current url, for plain form posts. (name is the person's name here.)
idstringThe trigger's id; the label and the messages point at it.
cropperPropsPartial<Omit<ImageCropperProps, "src">>Extra props for the crop step's ImageCropper, e.g. { labels } to translate it, { height: 360 } or { flippable: true }.
fieldPropsPartial<FieldProps>Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel.
labelsPartial<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.
classNamesPartial<Record<"root" | "trigger" | "avatar" | "badge" | "progress" | "check" | "actions" | "hint" | "message" | "dialog" | "camera" | "field" | "label" | "description" | "error" | "counter", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root (the Field when a label, description or error is set).
Parts
DEFAULT_AVATAR_UPLOAD_LABELS
constantThe English copy ("Change photo", "Crop your photo", "Take photo"...). Spread it into labels when you translate only a few keys.
AvatarUploadHandler
typeThe 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.
Dependencies
Files
- components/ui/avatar-upload.tsx
FAQ
Is the React Avatar Upload component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Avatar Upload?
Run npx wingo-ui@latest add avatar-upload. It also adds the Wingo UI items it builds on (Image Cropper, Avatar, Button, Dialog, Dropdown Menu and 12 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/avatar-upload.
How does Avatar Upload behave on phones?
The source menu, the crop step and the camera become bottom sheets below 768px. The responsive prop controls this and is on by default.
See also
Used in
More React input components
Articles using this component
- Shadcn Profile Page in React: Follow, Tabs and Edit Sheet
Build it: screens and apps9 min read
- React Image Cropper: Crop Before Upload, Zoom, Rotate
Component guides9 min read