Healthcare

Adherence Ring

A circular adherence gauge whose arc sweeps and figure counts up on view, with a single gentle milestone moment when it reaches 100 percent.

Install

npx shadcn@latest add @paragon/adherence-ring

adherence-ring.tsx

"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>
  );
}