Web InnoventixFreeCode

Upload Avatar

Original · free

avatar with upload/edit overlay

byWeb InnoventixReact + Tailwind
avuploadavatars
Open

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
av-upload.tsx
"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

motion

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 quote

Similar components

Browse all →