A circular adherence gauge whose arc sweeps and figure counts up on view, with a single gentle milestone moment when it reaches 100 percent.
npx shadcn@latest add @paragon/adherence-ring"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AdherenceRingProps extends React.ComponentProps<"div"> {
/** Percentage adherence, 0–100. */
value?: number;
/** Diameter in px. */
size?: number;
/** Ring thickness in px. */
thickness?: number;
/** Caption under the figure, e.g. "14-day streak". */
caption?: string;
/** Renders the ring filled in place with no sweep. */
static?: boolean;
}
function toneFor(value: number): string {
if (value >= 90) return "var(--color-success)";
if (value >= 60) return "var(--color-warning)";
return "var(--color-destructive)";
}
/**
* A circular adherence gauge. On first view the arc sweeps from empty to its
* value via stroke-dashoffset (transition, so a changed value retargets), and
* the percentage counts up in step. At 100% a single, gentle milestone moment
* fires — the track flashes to success and a check blur-swaps in — then rests.
* Reduced motion draws the final arc and figure with no sweep or count.
*/
export function AdherenceRing({
value = 100,
size = 132,
thickness = 10,
caption = "14-day streak",
static: isStatic = false,
className,
...props
}: AdherenceRingProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const animate = !isStatic && !reducedMotion;
const settled = !animate || inView;
const clamped = Math.max(0, Math.min(100, value));
const complete = clamped >= 100;
const r = (size - thickness) / 2;
const c = 2 * Math.PI * r;
const shown = settled ? clamped : 0;
const offset = c * (1 - shown / 100);
// Count-up figure, driven off the same in-view gate.
const [display, setDisplay] = React.useState(animate ? 0 : clamped);
React.useEffect(() => {
if (!animate) {
setDisplay(clamped);
return;
}
if (!inView) return;
const duration = 900;
const start = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
setDisplay(Math.round(eased * clamped));
if (t < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [animate, inView, clamped]);
// Hold the figure through the count-up; swap to the milestone check only
// once it lands on 100 (or immediately when motion is off).
const showCheck = complete && settled && (!animate || display >= 100);
return (
<div
ref={ref}
data-slot="adherence-ring"
className={cn("flex flex-col items-center gap-2.5", className)}
{...props}
>
<div
className="relative grid place-items-center"
style={{ width: size, height: size }}
>
<svg
role="img"
aria-label={`Adherence ${clamped}%`}
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke="var(--color-secondary)"
strokeWidth={thickness}
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={toneFor(clamped)}
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
style={{
transition: animate
? "stroke-dashoffset 900ms var(--ease-out), stroke 300ms var(--ease-out)"
: undefined,
}}
/>
</svg>
<div className="absolute inset-0 grid place-items-center">
{showCheck ? (
<span
className="grid size-9 place-items-center rounded-full bg-success/15 text-success"
style={{
animation: animate
? "paragon-adherence-pop 420ms var(--ease-bounce) both"
: undefined,
}}
>
<Check aria-hidden className="size-5" strokeWidth={2.5} />
</span>
) : (
<span className="text-2xl font-semibold tabular-nums text-foreground">
{display}
<span className="text-base text-muted-foreground">%</span>
</span>
)}
</div>
</div>
<style href="paragon-adherence-ring" precedence="paragon">{`
@keyframes paragon-adherence-pop {
0% { opacity: 0; transform: scale(0.6); }
100% { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
[data-slot="adherence-ring"] * { animation: none !important; }
}
`}</style>
{caption && (
<span className="text-xs text-muted-foreground">{caption}</span>
)}
</div>
);
}