Goal Ring Close
An activity ring runs out the last of its gap, the caps meet, and the total settles once.
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 { useEffect, useRef, useState } from "react";
import {
animate,
motion,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useTransform,
} from "motion/react";
/**
* Vibary · Goal Ring Close
*
* The last stretch of a daily goal. The arc runs to the top of the
* circle, the leading cap meets the trailing one, and the ring gives a
* single soft acknowledgement at the instant the gap disappears.
*
* One value drives the arc and the figure inside it, so the ring can
* never be closed while the number still reads short.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The empty track is mixed from the inherited text color; the ring
* colour is semantic and stays literal.
* Works with zero props; tune via `variant`, `from`, `value`, `goal`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type GoalRingCloseProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Where the ring starts the sequence. */
from?: number;
/** Where it finishes. Reaching `goal` closes the ring. */
value?: number;
/** The target. */
goal?: number;
/** Ring diameter in px. */
size?: number;
/** What is being measured. */
label?: string;
/** Unit shown under the figure. */
unit?: string;
/** Line shown once the ring closes. */
metNote?: string;
/** Ring color. Semantic, so it stays literal. */
accent?: string;
/** Fires the moment the ring closes. */
onClose?: () => void;
};
type VariantConfig = {
/** Beat before the arc moves, so the remaining gap registers. */
delay: number;
/** How long the last stretch takes. */
duration: number;
/** Peak of the one-time acknowledgement, as a scale factor. */
settle: number;
};
// A tween, not a spring: an activity ring that overshoots its target
// paints minutes the wearer did not earn. The curve decelerates hard so
// the closing centimetre of arc is slow enough to watch.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For a ring in a summary row, seen in passing.
subtle: { delay: 0.06, duration: 0.7, settle: 1.01 },
// The all-purpose setting.
default: { delay: 0.14, duration: 1.05, settle: 1.03 },
// A slower close for a screen where the ring is the subject.
playful: { delay: 0.2, duration: 1.4, settle: 1.04 },
};
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function GoalRingClose({
variant = "default",
from = 0.62,
value = 500,
goal = 500,
size = 132,
label = "Focus",
unit = "of 500 min",
metNote = "Goal closed",
accent = "#22B07D",
onClose,
}: GoalRingCloseProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const safeGoal = Math.max(1, goal);
const target = Math.min(1, Math.max(0, value / safeGoal));
const start = Math.min(target, Math.max(0, from));
const progress = useMotionValue(reduceMotion ? target : start);
const digits = useTransform(progress, (v) =>
Math.round(v * safeGoal).toLocaleString("en-US")
);
// A new total — or a new variant — is a new run of the arc, so the
// acknowledgement has to start over. That reset is a render-time fact:
// the run key lives in state and is compared during render, which keeps
// the effect below owning the animation and nothing else.
const runKey = `${reduceMotion}:${start}:${target}:${cfg.duration}:${cfg.delay}`;
const fresh = { key: runKey, closed: !!reduceMotion && target >= 1 };
const [run, setRun] = useState(fresh);
if (run.key !== runKey) setRun(fresh);
const closed = run.key === runKey ? run.closed : fresh.closed;
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
// The acknowledgement is tied to the arc, not to a timer: it fires at
// the frame the gap actually disappears, which is the only frame it
// means anything.
useMotionValueEvent(progress, "change", (v) => {
if (v >= 0.999) setRun({ key: runKey, closed: true });
});
useEffect(() => {
if (closed) onCloseRef.current?.();
}, [closed]);
useEffect(() => {
// Reduced motion: the ring is simply closed. The travel was only ever
// the presentation of a total that is already true.
if (reduceMotion) {
progress.set(target);
return;
}
progress.set(start);
const controls = animate(progress, target, {
duration: cfg.duration,
delay: cfg.delay,
ease: [0.16, 1, 0.3, 1],
});
return () => controls.stop();
}, [progress, start, target, reduceMotion, cfg.duration, cfg.delay]);
const stroke = size * 0.115;
const radius = 50 - stroke / 2 - 1;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
<div style={{ position: "relative", width: size, height: size }}>
{/* One soft settle at the close: a shade over three percent, over
in half a second. Enough to be felt, too small to be a bounce.
It is applied to the dial alone — the readout is a sibling, not
a child, so the figure never rides the scale. */}
<motion.div
initial={false}
animate={
closed && !reduceMotion ? { scale: [1, cfg.settle, 1] } : { scale: 1 }
}
transition={{ duration: 0.52, times: [0, 0.34, 1], ease: "easeOut" }}
style={{ width: "100%", height: "100%", lineHeight: 0 }}
>
<svg
viewBox="0 0 100 100"
width="100%"
height="100%"
fill="none"
role="img"
aria-label={`${label}: ${value} ${unit}`}
>
<circle
cx="50"
cy="50"
r={radius}
stroke={tone(11)}
strokeWidth={stroke}
/>
{/* Rotated so zero sits at twelve o'clock and the round caps
meet exactly there when the ring closes. */}
<motion.circle
cx="50"
cy="50"
r={radius}
stroke={accent}
strokeWidth={stroke}
strokeLinecap="round"
transform="rotate(-90 50 50)"
style={{ pathLength: progress }}
/>
</svg>
</motion.div>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
lineHeight: 1.1,
pointerEvents: "none",
}}
>
<motion.span
style={{
fontSize: size * 0.2,
fontWeight: 660,
letterSpacing: "-0.02em",
fontVariantNumeric: "tabular-nums",
}}
>
{digits}
</motion.span>
<span style={{ fontSize: size * 0.085, color: tone(52) }}>{unit}</span>
</div>
</div>
<div style={{ minHeight: 18, textAlign: "center" }}>
<motion.div
initial={false}
animate={{ opacity: closed ? 1 : 0, y: closed ? 0 : reduceMotion ? 0 : 5 }}
transition={{ duration: reduceMotion ? 0 : 0.3, ease: "easeOut" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12.5,
fontWeight: 600,
color: accent,
}}
>
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
<circle cx="7" cy="7" r="6.2" stroke="currentColor" strokeWidth="1.4" />
<path
d="M4.4 7.2 6.2 9 9.7 5.3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{metNote}
</motion.div>
</div>
</div>
);
}About this pattern
The last stretch of a daily target. The arc travels to twelve o'clock where its round leading cap meets the trailing one, and the whole dial gives a single three-percent settle at the exact frame the gap disappears. Two things make this read as instrument rather than toy. The acknowledgement is bound to the arc's own value rather than to a timer, so it can never fire while the dial still shows a gap. And the fill is a decelerating tween instead of a spring — a goal dial that overshoots paints minutes the person did not earn, which is a reporting error dressed up as a flourish. The figure in the middle is driven by the same value as the arc and keeps a constant size throughout.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
Activity dial travelling its last stretch, with the caps meeting at the top.
Related patterns
- Team GoalEach person's contribution grows into one shared track in turn until the stack passes the target mark.
- Badge UnlockAn earned badge lands and one band of light crosses its face — a single pass, then still.
- Challenge CompleteA seal presses onto the finished challenge card and settles a couple of degrees off square.