Swish UI
All componentsFeedback and forms Free

Upload ring

Change photo fills a ring around the avatar with a live percentage, crossfades the new photo and pops a check badge.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const UPLOAD_MS = 2600;
const WAIT = { idle: 1800, done: 3200 }; // auto play pauses between takes
type Phase = "idle" | "uploading" | "done";

const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
// Uploads rarely move evenly: a quick start, a stall near two thirds, then a rush to the end.
const curve = (t: number) =>
  t < 0.5 ? easeOut(t / 0.5) * 0.64 : t < 0.72 ? 0.64 + ((t - 0.5) / 0.22) * 0.06 : 0.7 + easeOut((t - 0.72) / 0.28) * 0.3;

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const ring = useRef<SVGCircleElement>(null);
  const pct = useRef<HTMLSpanElement>(null);
  const elapsed = useRef(0);
  const auto = useRef(true);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [phase, setPhase] = useState<Phase>("idle");
  const [photo, setPhoto] = useState(0);

  const start = (byUser: boolean) => {
    if (byUser) auto.current = false;
    elapsed.current = 0;
    if (reduced) { setPhoto((p) => 1 - p); setPhase("done"); return; }
    setPhase("uploading");
  };

  // Reduced motion opens on the finished upload.
  useEffect(() => {
    if (reduced) setPhase("done");
  }, [reduced]);

  // Auto play: idle starts an upload, done settles back to idle. Stops for good after the first click.
  useEffect(() => {
    if (reduced || !inView || !auto.current || phase === "uploading") return;
    const t = setTimeout(() => (phase === "idle" ? start(false) : setPhase("idle")), WAIT[phase]);
    return () => clearTimeout(t);
  }, [phase, inView, reduced]);

  // The ring and the percentage are written through refs every frame.
  useEffect(() => {
    const arc = ring.current, label = pct.current;
    if (phase !== "uploading" || !inView || !arc || !label) return;
    const draw = () => {
      const p = curve(Math.min(1, elapsed.current / UPLOAD_MS));
      arc.style.strokeDashoffset = String(100 - p * 100);
      label.textContent = `${Math.round(p * 100)}%`;
    };
    draw();
    let raf = 0, last = performance.now();
    const frame = (now: number) => {
      elapsed.current += Math.min(64, now - last);
      last = now;
      draw();
      if (elapsed.current >= UPLOAD_MS) {
        setPhoto((x) => 1 - x);
        setPhase("done");
        return;
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [phase, inView]);

  const status = phase === "uploading" ? "Uploading photo" : phase === "done" ? "Photo updated" : "JPG or PNG, up to 5 MB";

  return (
    <div ref={root} className={styles.root}>
      <section className={styles.card} data-phase={phase} aria-label="Profile photo">
        <div className={styles.avatar} role="img" aria-label="Ana Torres, profile photo">
          <span className={styles.photo} data-photo="0" data-on={photo === 0 || undefined} />
          <span className={styles.photo} data-photo="1" data-on={photo === 1 || undefined} />
          <span className={styles.scrim} aria-hidden="true"><span ref={pct}>0%</span></span>
          <svg className={styles.ring} viewBox="0 0 100 100" aria-hidden="true">
            <circle className={styles.track} cx="50" cy="50" r="47" pathLength={100} />
            <circle ref={ring} className={styles.arc} cx="50" cy="50" r="47" pathLength={100} />
          </svg>
          <span className={styles.badge} aria-hidden="true">
            <svg viewBox="0 0 16 16"><path d="m4 8.5 2.6 2.6L12 5.5" /></svg>
          </span>
        </div>
        <div className={styles.meta}>
          <p className={styles.name}>Ana Torres</p>
          <p className={styles.role}>Product designer, Lisbon</p>
          <p className={styles.status} role="status">
            <span key={status} className={styles.label}>{status}</span>
          </p>
          <button type="button" className={styles.change} onClick={() => start(true)} disabled={phase === "uploading"}>
            <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 11V3M4.5 6.5 8 3l3.5 3.5M3 13h10" /></svg>
            {phase === "uploading" ? "Uploading" : "Change photo"}
          </button>
        </div>
      </section>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
  box-sizing: border-box; background: var(--sky);
}
.card {
  width: min(420px, 100%); display: flex; align-items: center; gap: 22px; padding: 22px; box-sizing: border-box;
  border-radius: 18px; border: 1px solid var(--line); background: var(--white);
  box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 45%, transparent);
}

/* Avatar: two stacked portraits crossfade; the ring sits just outside the photo. */
.avatar { position: relative; flex: none; width: 96px; height: 96px; }
.photo {
  position: absolute; inset: 0; border-radius: 50%; opacity: 0; scale: 1.06;
  transition: opacity .6s var(--out), scale .8s var(--out);
}
.photo[data-on] { opacity: 1; scale: 1; }
.photo[data-photo="0"] {
  background:
    radial-gradient(19% 19% at 50% 44%, var(--sand) 96%, transparent 100%),
    radial-gradient(24% 23% at 50% 38%, var(--ink) 96%, transparent 100%),
    radial-gradient(40% 30% at 50% 100%, var(--accent-text) 97%, transparent 100%),
    linear-gradient(160deg, var(--accent-pale), var(--accent-soft));
}
.photo[data-photo="1"] {
  background:
    radial-gradient(19% 19% at 50% 44%, var(--sand) 96%, transparent 100%),
    radial-gradient(22% 27% at 50% 46%, var(--muted) 96%, transparent 100%),
    radial-gradient(40% 30% at 50% 100%, var(--ink) 97%, transparent 100%),
    linear-gradient(200deg, var(--sky), var(--accent));
}
.scrim {
  position: absolute; inset: 0; display: grid; place-items: center; border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 30%, transparent); color: var(--white); text-shadow: 0 1px 6px color-mix(in srgb, var(--ink) 50%, transparent);
  font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; opacity: 0; transition: opacity .35s var(--out);
}
.card[data-phase="uploading"] .scrim { opacity: 1; }

.ring { position: absolute; inset: -8px; width: calc(100% + 16px); height: calc(100% + 16px); rotate: -90deg; opacity: 0; transition: opacity .4s var(--out), scale .5s var(--out); }
.ring circle { fill: none; stroke-width: 3; }
.track { stroke: var(--line); }
.arc { stroke: var(--accent-text); stroke-linecap: round; stroke-dasharray: 100; stroke-dashoffset: 100; }
.card[data-phase="uploading"] .ring { opacity: 1; }
.card[data-phase="done"] .ring { scale: .9; }

.badge {
  position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 30px; height: 30px;
  border-radius: 50%; background: var(--accent-text); box-shadow: 0 0 0 3px var(--white); scale: 0; opacity: 0;
  transition: scale .3s var(--out), opacity .2s;
}
.badge svg { width: 16px; height: 16px; fill: none; stroke: var(--white); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 14; stroke-dashoffset: 14; }
.card[data-phase="done"] .badge { scale: 1; opacity: 1; transition: scale .45s var(--out) .15s, opacity .2s .15s; }
.card[data-phase="done"] .badge svg { animation: draw .4s var(--out) .4s forwards; }
@keyframes draw { to { stroke-dashoffset: 0; } }

.meta { min-width: 0; display: grid; gap: 2px; justify-items: start; }
.meta p { margin: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.name { font-size: 16px; font-weight: 700; letter-spacing: -0.01em; }
.role { font-size: 13px; color: var(--muted); }
.status { margin-top: 8px !important; font-size: 12px; font-weight: 600; color: var(--muted); }
.card[data-phase="done"] .status { color: var(--accent-text); }
.label { display: inline-block; animation: label-in .35s var(--out); }
@keyframes label-in { from { opacity: 0; translate: 0 5px; } }

.change {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; min-height: 36px; padding: 0 14px 0 12px;
  border-radius: 999px; border: 0; background: var(--ink); color: var(--white); font: inherit; font-size: 13px; font-weight: 650;
  cursor: pointer; transition: transform .18s var(--out), opacity .3s;
}
.change svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.change:active:not(:disabled) { transform: scale(.95); }
.change:disabled { opacity: .5; cursor: default; }
.change:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }

@container (max-width: 360px) {
  .card { gap: 18px; padding: 18px 16px; }
  .avatar { width: 76px; height: 76px; }
  .scrim { font-size: 15px; }
  .badge { width: 26px; height: 26px; }
}
@container (max-height: 250px) {
  .card { padding-block: 16px; }
  .status { margin-top: 4px !important; }
  .change { margin-top: 6px; }
}
@media (prefers-reduced-motion: reduce) {
  .photo, .scrim, .ring, .badge, .card[data-phase="done"] .badge { transition: none; }
  .label { animation: none; }
  .card[data-phase="done"] .badge svg { animation: none; stroke-dashoffset: 0; }
}

Use it for

  • Profile and team settings with an avatar upload
  • Any single file upload tied to one visible thumbnail
  • Onboarding steps that ask for a photo or logo

Avoid

  • Multi file uploads, where a list with bars scales better
  • Uploads that finish instantly, where the ring only flashes

How it works

The ring is an SVG circle with pathLength 100, so progress maps straight to stroke-dashoffset. A requestAnimationFrame loop advances an eased curve with a stall near two thirds, like a real upload, and writes the offset and the percentage through refs. At 100 the two stacked gradient portraits swap opacity, the ring fades and shrinks and a check badge scales in and draws its stroke.

Accessibility

Change photo is a real button that disables while uploading, the avatar is a labelled image and the status line announces Uploading and Photo updated. Auto play stops after the first click and pauses off screen. Reduced motion opens on the finished state and swaps the photo instantly with no ring.

Found on