Upload Avatar
Original · freeavatar with upload/edit overlay
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/av-upload.json"use client";
import {
useState,
useRef,
useEffect,
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
type KeyboardEvent as ReactKeyboardEvent,
type DragEvent as ReactDragEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type IconProps = { className?: string };
type Committed = {
url: string;
zoom: number;
offsetX: number;
offsetY: number;
} | null;
type Mode = "view" | "edit" | "uploading";
const FRAME = 176; // px, matches w-44 / h-44
const MAX_MB = 5;
const MIN_ZOOM = 1;
const MAX_ZOOM = 3;
const ACCEPTED = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const clampOffset = (v: number, z: number) => {
const max = (FRAME * (z - 1)) / 2;
return Math.min(max, Math.max(-max, v));
};
function CameraIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M4.5 8.5a2 2 0 0 1 2-2h1.2l.7-1.4a1.5 1.5 0 0 1 1.34-.83h4.52a1.5 1.5 0 0 1 1.34.83l.7 1.4h1.2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="12" cy="12.5" r="3.2" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
function UploadIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M12 15.5V4.5m0 0L8 8.5m4-4 4 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 15.5v2a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
);
}
function TrashIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7m3 0-.6 11a2 2 0 0 1-2 1.9H9.6a2 2 0 0 1-2-1.9L7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CheckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M5 12.5l4 4 10-10" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function CloseIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function MinusIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M6 12h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
);
}
function PlusIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path d="M12 6v12M6 12h12" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
);
}
export default function AvatarUpload() {
const reduce = useReducedMotion();
const [mode, setMode] = useState<Mode>("view");
const [committed, setCommitted] = useState<Committed>(null);
const [draftUrl, setDraftUrl] = useState<string | null>(null);
const [zoom, setZoom] = useState(1);
const [offsetX, setOffsetX] = useState(0);
const [offsetY, setOffsetY] = useState(0);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<number | null>(null);
const urlsRef = useRef<Set<string>>(new Set());
const dragRef = useRef({ active: false, sx: 0, sy: 0, bx: 0, by: 0 });
useEffect(() => {
const timers = timerRef;
const urls = urlsRef;
return () => {
if (timers.current) window.clearInterval(timers.current);
urls.current.forEach((u) => URL.revokeObjectURL(u));
urls.current.clear();
};
}, []);
const revokeUrl = (u: string) => {
URL.revokeObjectURL(u);
urlsRef.current.delete(u);
};
const openPicker = () => inputRef.current?.click();
const processFile = (file: File) => {
if (!ACCEPTED.includes(file.type)) {
setError("That doesn't look like a supported image. Choose a JPG, PNG, WebP, or GIF.");
setStatus("Upload failed: unsupported file type.");
return;
}
const mb = file.size / (1024 * 1024);
if (mb > MAX_MB) {
setError(`That image is ${mb.toFixed(1)} MB. Please pick one under ${MAX_MB} MB.`);
setStatus("Upload failed: file too large.");
return;
}
setError(null);
if (draftUrl) revokeUrl(draftUrl);
const url = URL.createObjectURL(file);
urlsRef.current.add(url);
setDraftUrl(url);
setZoom(1);
setOffsetX(0);
setOffsetY(0);
setMode("edit");
setStatus("Photo loaded. Adjust the framing, then save.");
};
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) processFile(file);
e.target.value = "";
};
const onDrop = (e: ReactDragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer?.files?.[0];
if (file) processFile(file);
};
const startUpload = () => {
setMode("uploading");
setError(null);
setStatus("Uploading your photo…");
setProgress(0);
const snapUrl = draftUrl;
const snapZoom = zoom;
const snapX = offsetX;
const snapY = offsetY;
const prev = committed;
let p = 0;
if (timerRef.current) window.clearInterval(timerRef.current);
timerRef.current = window.setInterval(() => {
p = Math.min(100, p + Math.random() * 14 + 7);
setProgress(p);
if (p >= 100) {
if (timerRef.current) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
window.setTimeout(() => {
if (prev && prev.url !== snapUrl) revokeUrl(prev.url);
if (snapUrl) setCommitted({ url: snapUrl, zoom: snapZoom, offsetX: snapX, offsetY: snapY });
setDraftUrl(null);
setMode("view");
setStatus("Profile photo updated.");
}, 280);
}
}, 110);
};
const cancelEdit = () => {
if (draftUrl) revokeUrl(draftUrl);
setDraftUrl(null);
setMode("view");
setStatus("Edit canceled. Your photo is unchanged.");
};
const removePhoto = () => {
if (committed) revokeUrl(committed.url);
setCommitted(null);
setError(null);
setStatus("Profile photo removed.");
};
const onZoom = (e: ChangeEvent<HTMLInputElement>) => {
const z = Number(e.target.value);
setZoom(z);
setOffsetX((x) => clampOffset(x, z));
setOffsetY((y) => clampOffset(y, z));
};
const nudgeZoom = (d: number) => {
setZoom((z) => {
const nz = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number((z + d).toFixed(2))));
setOffsetX((x) => clampOffset(x, nz));
setOffsetY((y) => clampOffset(y, nz));
return nz;
});
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (zoom <= 1) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { active: true, sx: e.clientX, sy: e.clientY, bx: offsetX, by: offsetY };
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragRef.current.active) return;
const dx = e.clientX - dragRef.current.sx;
const dy = e.clientY - dragRef.current.sy;
setOffsetX(clampOffset(dragRef.current.bx + dx, zoom));
setOffsetY(clampOffset(dragRef.current.by + dy, zoom));
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
dragRef.current.active = false;
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
};
const onFrameKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const step = 8;
if (e.key === "ArrowLeft") {
e.preventDefault();
setOffsetX((x) => clampOffset(x - step, zoom));
} else if (e.key === "ArrowRight") {
e.preventDefault();
setOffsetX((x) => clampOffset(x + step, zoom));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setOffsetY((y) => clampOffset(y - step, zoom));
} else if (e.key === "ArrowDown") {
e.preventDefault();
setOffsetY((y) => clampOffset(y + step, zoom));
} else if (e.key === "+" || e.key === "=") {
e.preventDefault();
nudgeZoom(0.2);
} else if (e.key === "-" || e.key === "_") {
e.preventDefault();
nudgeZoom(-0.2);
}
};
const imgTransform = (z: number, x: number, y: number) => `translate(${x}px, ${y}px) scale(${z})`;
const RING = 502.6548; // 2 * PI * 80
const dashOffset = RING * (1 - progress / 100);
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
return (
<section className="relative w-full bg-slate-50 py-16 sm:py-24 dark:bg-slate-950">
<style>{`
@keyframes avup-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes avup-pop { 0% { transform: scale(.85); opacity: 0 } 100% { transform: scale(1); opacity: 1 } }
@keyframes avup-shake { 0%,100% { transform: translateX(0) } 20%,60% { transform: translateX(-4px) } 40%,80% { transform: translateX(4px) } }
@keyframes avup-spin { to { transform: rotate(360deg) } }
.avup-fade { animation: avup-fade .32s ease both }
.avup-pop { animation: avup-pop .3s cubic-bezier(.34,1.56,.64,1) both }
.avup-shake { animation: avup-shake .42s ease both }
.avup-spin { transform-origin: 50% 50%; animation: avup-spin 1.1s linear infinite }
@media (prefers-reduced-motion: reduce) {
.avup-fade, .avup-pop, .avup-shake, .avup-spin { animation: none !important }
}
`}</style>
<div className="mx-auto max-w-xl px-5">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
<div className="border-b border-slate-100 px-6 py-5 sm:px-8 dark:border-slate-800">
<h2 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
Profile photo
</h2>
<p className="mt-1 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Upload a clear headshot so teammates recognize you across comments, mentions, and the member directory.
</p>
</div>
<div className="flex flex-col items-center gap-6 px-6 py-8 sm:px-8">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="sr-only"
onChange={onInputChange}
aria-hidden="true"
tabIndex={-1}
/>
{/* ---- VIEW MODE ---- */}
{mode === "view" && (
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className="relative"
>
<button
type="button"
onClick={openPicker}
aria-label={committed ? "Change your profile photo" : "Upload a profile photo"}
aria-describedby="avup-help"
className={`group relative block h-44 w-44 overflow-hidden rounded-full ring-1 ring-slate-900/5 transition dark:ring-white/10 ${focusRing}`}
>
{committed ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={committed.url}
alt="Your current profile photo"
loading="lazy"
draggable={false}
className="h-full w-full select-none object-cover"
style={{ transform: imgTransform(committed.zoom, committed.offsetX, committed.offsetY) }}
/>
</>
) : (
<span className="flex h-full w-full items-center justify-center bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 text-5xl font-semibold tracking-tight text-white">
AW
</span>
)}
<span className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 bg-slate-900/55 text-white opacity-0 backdrop-blur-[1px] transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
<CameraIcon className="h-7 w-7" />
<span className="text-xs font-medium">{committed ? "Change" : "Upload"}</span>
</span>
</button>
<span className="pointer-events-none absolute bottom-1.5 right-1.5 flex h-9 w-9 items-center justify-center rounded-full bg-white text-slate-700 shadow-md ring-2 ring-white dark:bg-slate-800 dark:text-slate-100 dark:ring-slate-900">
<CameraIcon className="h-5 w-5" />
</span>
{dragging && (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full border-2 border-dashed border-indigo-500 bg-indigo-500/15 text-xs font-semibold text-indigo-700 dark:text-indigo-200">
Drop to upload
</span>
)}
</div>
)}
{/* ---- EDIT MODE ---- */}
{mode === "edit" && draftUrl && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.28 }}
className="flex w-full flex-col items-center gap-5"
>
<div
role="group"
aria-label="Reposition photo. Drag, or use arrow keys to move and + / − to zoom."
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onFrameKey}
className={`relative h-44 w-44 touch-none overflow-hidden rounded-full ring-1 ring-slate-900/10 dark:ring-white/10 ${zoom > 1 ? "cursor-grab active:cursor-grabbing" : "cursor-default"} ${focusRing}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={draftUrl}
alt="Preview of the photo you're adjusting"
loading="lazy"
draggable={false}
className="pointer-events-none h-full w-full select-none object-cover"
style={{ transform: imgTransform(zoom, offsetX, offsetY) }}
/>
<span className="pointer-events-none absolute inset-0 rounded-full ring-1 ring-inset ring-white/40" />
</div>
<div className="flex w-full max-w-xs items-center gap-3">
<button
type="button"
onClick={() => nudgeZoom(-0.2)}
aria-label="Zoom out"
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-300 text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 ${focusRing}`}
>
<MinusIcon className="h-4 w-4" />
</button>
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
onChange={onZoom}
aria-label="Zoom level"
aria-valuetext={`${zoom.toFixed(1)} times`}
className={`h-1.5 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 dark:bg-slate-700 ${focusRing}`}
/>
<button
type="button"
onClick={() => nudgeZoom(0.2)}
aria-label="Zoom in"
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-300 text-slate-600 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 ${focusRing}`}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={startUpload}
className={`inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 ${focusRing}`}
>
<CheckIcon className="h-4 w-4" />
Save photo
</button>
<button
type="button"
onClick={cancelEdit}
className={`inline-flex items-center gap-2 rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 ${focusRing}`}
>
<CloseIcon className="h-4 w-4" />
Cancel
</button>
</div>
<p className="text-center text-xs text-slate-400 dark:text-slate-500">
Drag the image to reposition · arrow keys nudge · + / − to zoom
</p>
</motion.div>
)}
{/* ---- UPLOADING MODE ---- */}
{mode === "uploading" && draftUrl && (
<div className="relative h-44 w-44">
<div className="absolute inset-0 overflow-hidden rounded-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={draftUrl}
alt="Photo being uploaded"
loading="lazy"
draggable={false}
className="h-full w-full select-none object-cover opacity-40"
style={{ transform: imgTransform(zoom, offsetX, offsetY) }}
/>
</div>
<svg viewBox="0 0 176 176" className="absolute inset-0 h-full w-full -rotate-90" aria-hidden="true">
<circle cx="88" cy="88" r="80" fill="none" strokeWidth="8" className="stroke-slate-200 dark:stroke-slate-700" />
<circle
cx="88"
cy="88"
r="80"
fill="none"
strokeWidth="8"
strokeLinecap="round"
className="stroke-indigo-500"
style={{
strokeDasharray: RING,
strokeDashoffset: dashOffset,
transition: "stroke-dashoffset 120ms linear",
}}
/>
<circle
cx="88"
cy="88"
r="80"
fill="none"
strokeWidth="8"
strokeLinecap="round"
className="avup-spin stroke-indigo-300/70"
style={{ strokeDasharray: "18 484" }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-semibold tabular-nums text-slate-900 dark:text-white">
{Math.round(progress)}%
</span>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Uploading</span>
</div>
</div>
)}
{/* ---- IDENTITY + ACTIONS ---- */}
<div className="flex flex-col items-center gap-0.5">
<p className="text-base font-semibold text-slate-900 dark:text-white">Amara Whitfield</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Design Systems Lead · Northwind</p>
</div>
{mode !== "uploading" && (
<div className="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={openPicker}
className={`inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 ${focusRing}`}
>
<UploadIcon className="h-4 w-4" />
{committed ? "Replace photo" : "Upload photo"}
</button>
<button
type="button"
onClick={removePhoto}
disabled={!committed}
className={`inline-flex items-center gap-2 rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-rose-600 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent dark:border-slate-700 dark:text-rose-400 dark:hover:bg-rose-950/40 dark:disabled:border-slate-800 dark:disabled:text-slate-600 ${focusRing}`}
>
<TrashIcon className="h-4 w-4" />
Remove
</button>
</div>
)}
<p id="avup-help" className="max-w-sm text-center text-xs leading-relaxed text-slate-400 dark:text-slate-500">
JPG, PNG, WebP, or GIF · up to {MAX_MB} MB. Square images look best — you can zoom and reposition after uploading.
</p>
<AnimatePresence>
{error && (
<motion.p
key="avup-error"
role="alert"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
className="avup-shake flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/50 dark:text-rose-300"
>
<span aria-hidden="true" className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-rose-600 text-white dark:bg-rose-500">
<CloseIcon className="h-3.5 w-3.5" />
</span>
{error}
</motion.p>
)}
</AnimatePresence>
{status.startsWith("Profile photo updated") && (
<span className="avup-pop inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
<CheckIcon className="h-3.5 w-3.5" />
Saved
</span>
)}
</div>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
{status}
</p>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Avatar
Originalavatars in sizes with fallback

Group Avatar
Originaloverlapping avatar group

Status Avatar
Originalavatars with online status dot

Ring Avatar
Originalavatars with gradient ring

Initials Avatar
Originalinitials avatars with colours

Shapes Avatar
Originalavatars in circle/square/squircle

Stacked Count Avatar
Originalavatar stack with +N count

With Name Avatar
Originalavatar with name and role

Squircle Avatar
Originalsquircle avatars with badge

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

