Story Ring Progress
A segmented ring traces around the avatar, one arc per clip, then goes quiet.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useCallback, useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Story Ring Progress
*
* One arc per clip, tracing around the avatar. The rate is linear on
* purpose: an eased timer lies about how much time is left. Completed
* arcs stay filled so the position in the set stays readable, and when
* the last one lands the ring drops to a neutral tone — the difference
* between an avatar you have watched and one you have not.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The track and the watched state are mixed from the inherited text
* color, so they read correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `clips`, `initials`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StoryRingProgressProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many clips the set holds — one arc each. */
clips?: number;
/** Two letters shown on the avatar disc. */
initials?: string;
/** Disc color behind the initials — stands in for a photo. */
tint?: string;
name?: string;
/** Fires once the last clip finishes. */
onComplete?: () => void;
};
type VariantConfig = {
/** Seconds each clip runs for. */
clipSeconds: number;
/** Ring thickness in user units of the 96-wide viewBox. */
weight: number;
/** Degrees of empty space between arcs. */
gap: number;
};
// No spring anywhere in this pattern: a timer that overshoots its own
// end is a timer nobody can trust. Variants change pace and weight only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Thin ring, unhurried. For a row of many avatars.
subtle: { clipSeconds: 1.9, weight: 2.4, gap: 5 },
// The all-purpose setting.
default: { clipSeconds: 1.5, weight: 3.2, gap: 7 },
// Heavier ring, quicker clips, for a full-screen viewer.
playful: { clipSeconds: 1.1, weight: 4, gap: 9 },
};
const ACCENT = "#E0559B";
const R = 42;
/** Point on the ring, measured in degrees clockwise from twelve. */
function pointOn(angle: number) {
const radians = ((angle - 90) * Math.PI) / 180;
return [48 + R * Math.cos(radians), 48 + R * Math.sin(radians)];
}
function arcPath(from: number, to: number) {
const [x1, y1] = pointOn(from);
const [x2, y2] = pointOn(to);
const large = to - from > 180 ? 1 : 0;
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${R} ${R} 0 ${large} 1 ${x2.toFixed(
2
)} ${y2.toFixed(2)}`;
}
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function StoryRingProgress({
variant = "default",
clips = 3,
initials = "AD",
tint = "#4AA3B8",
name = "Ana Duarte",
onComplete,
}: StoryRingProgressProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [active, setActive] = useState(0);
const [done, setDone] = useState(false);
const advance = useCallback(() => {
if (active >= clips - 1) {
setDone(true);
onComplete?.();
return;
}
setActive(active + 1);
}, [active, clips, onComplete]);
// Reduced motion: the arcs still land one per clip, at the same pace,
// they simply appear filled instead of sweeping. The position in the
// set survives; the continuous movement does not.
useEffect(() => {
if (!reduceMotion || done) return;
const timer = window.setTimeout(advance, cfg.clipSeconds * 1000);
return () => window.clearTimeout(timer);
}, [reduceMotion, done, advance, cfg.clipSeconds]);
const step = 360 / clips;
return (
<div
style={{
display: "inline-flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
width: 104,
}}
>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={clips}
aria-valuenow={done ? clips : active}
aria-label={`${name}, clip ${Math.min(active + 1, clips)} of ${clips}`}
style={{ position: "relative", width: 88, height: 88 }}
>
<svg
width="88"
height="88"
viewBox="0 0 96 96"
fill="none"
aria-hidden
style={{ display: "block" }}
>
{Array.from({ length: clips }, (_, index) => {
const from = index * step + cfg.gap / 2;
const to = (index + 1) * step - cfg.gap / 2;
const d = arcPath(from, to);
const filled = index < active || done;
return (
<g key={index}>
<path
d={d}
strokeWidth={cfg.weight}
strokeLinecap="round"
style={{ stroke: tone(13) }}
/>
{filled && (
<motion.path
d={d}
stroke={ACCENT}
strokeWidth={cfg.weight}
strokeLinecap="round"
initial={false}
animate={{ opacity: done ? 0 : 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
/>
)}
{index === active && !done && (
// Re-keyed per clip so each arc mounts fresh and
// traces exactly once, and the completion of the
// tween is what advances the set.
<motion.path
key={`trace-${index}`}
d={d}
stroke={ACCENT}
strokeWidth={cfg.weight}
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: reduceMotion ? 0 : cfg.clipSeconds,
ease: "linear",
}}
onAnimationComplete={reduceMotion ? undefined : advance}
/>
)}
{done && (
<motion.path
d={d}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
strokeWidth={cfg.weight}
strokeLinecap="round"
style={{ stroke: tone(30) }}
/>
)}
</g>
);
})}
</svg>
<span
aria-hidden
style={{
position: "absolute",
inset: 9,
display: "grid",
placeItems: "center",
borderRadius: "50%",
background: tint,
color: "#ffffff",
fontSize: 21,
fontWeight: 650,
letterSpacing: 0.5,
}}
>
{initials}
</span>
</div>
<div style={{ fontSize: 12, textAlign: "center", lineHeight: 1.3 }}>
<div style={{ fontWeight: 600 }}>{name}</div>
<div style={{ fontSize: 11, opacity: 0.5 }}>
{done ? "Seen" : `${Math.min(active + 1, clips)} of ${clips}`}
</div>
</div>
</div>
);
}About this pattern
The ring does two jobs at once: it says how much of this clip is left, and how many clips are in the set. Each arc traces at a constant rate — linear, never eased, because an eased timer lies about how much time remains — and the arcs behind it stay filled so the position in the set is always readable. When the last one lands the whole ring drops to a neutral tone, which is the difference between an avatar you have watched and one you have not.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Social feed
Segmented ring around an avatar, one arc per clip in the set.
Related patterns
- Goal Ring CloseAn activity ring runs out the last of its gap, the caps meet, and the total settles once.
- Group JoinA joining member's avatar drops into the stack, the others make room, and the count rolls by one.
- Profile Header ParallaxThe cover drifts at a fraction of the scroll while the avatar rides up and docks into the title bar.