{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-rings",
  "type": "registry:component",
  "title": "Activity rings",
  "description": "Shows one to six personal goals as concentric rings with a staggered entrance, gentle rotating encouragement, named progress and explicit missing-data states.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/activity-rings.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { useMergedRefs } from \"./merge-refs\";\n\nexport interface ActivityRingGoal {\n  id: string;\n  label: string;\n  /** A recorded amount; null is unrecorded and zero is valid. */\n  current: number | null;\n  /** A positive personal target supplied by the application. */\n  target: number | null;\n  unit: string;\n}\n\nexport interface ActivityRingsProps extends React.HTMLAttributes<HTMLElement> {\n  label: string;\n  /** One to six goals are drawn from the outside inward. Every goal retains a text record. */\n  goals: readonly ActivityRingGoal[];\n  description?: React.ReactNode;\n  /** Sweep each ring into its supplied value on first view. Reduced motion stays instant. */\n  animate?: boolean;\n  /** Gentle lines rotate every ten seconds without immediate repetition. false hides the line. */\n  encouragement?: readonly string[] | false;\n}\n\nconst defaultEncouragement = [\n  \"Small steps still count.\",\n  \"Find a rhythm that feels like you.\",\n  \"A little care goes a long way.\",\n  \"There is room to move at your own pace.\",\n  \"Every day can look a little different.\",\n];\nconst ringBuild = stylex.keyframes({ from: { strokeDashoffset: 100, opacity: 0 }, to: { opacity: 1 } });\nconst encouragementIn = stylex.keyframes({ from: { opacity: 0, transform: \"translateY(3px)\" }, to: { opacity: 1, transform: \"translateY(0)\" } });\n\nconst styles = stylex.create({\n  root: { margin: 0, width: \"100%\", minWidth: 0, color: vlak.ink, lineHeight: 1.45 },\n  caption: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"baseline\", justifyContent: \"space-between\", gap: \"0.25rem 1rem\", marginBottom: \"1rem\", fontSize: vlak.controlFs, fontWeight: 600 },\n  body: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: \"1.5rem 2rem\" },\n  graphic: { display: \"block\", flexShrink: 0, width: \"10.5rem\", height: \"10.5rem\", maxWidth: \"100%\", overflow: \"visible\" },\n  track: { fill: \"none\", stroke: { default: vlak.divider, [mq.forcedColors]: \"CanvasText\" } },\n  arc: { fill: \"none\", stroke: { default: vlak.ink, [mq.forcedColors]: \"Highlight\" }, strokeLinecap: \"round\", forcedColorAdjust: \"none\" },\n  motion: { animationName: { default: ringBuild, [mq.reduce]: \"none\" }, animationDuration: \"1s\", animationTimingFunction: \"cubic-bezier(0.16, 1, 0.3, 1)\", animationFillMode: \"backwards\", transitionProperty: \"stroke-dashoffset\", transitionDuration: { default: \"0.6s\", [mq.reduce]: \"0s\" }, transitionTimingFunction: \"cubic-bezier(0.16, 1, 0.3, 1)\" },\n  waiting: { animationPlayState: \"paused\" },\n  encouragement: { display: \"flex\", alignItems: \"center\", gap: \"0.5rem\", marginTop: \"1.25rem\", minHeight: vlak.hit, maxWidth: \"66ch\" },\n  encouragementText: { margin: 0, flex: \"1 1 auto\", minWidth: 0, fontSize: \"0.875rem\", lineHeight: 1.45, color: vlak.gray, overflowWrap: \"anywhere\", animationName: { default: encouragementIn, [mq.reduce]: \"none\" }, animationDuration: \"0.3s\", animationTimingFunction: \"ease-out\" },\n  pause: { display: \"inline-flex\", alignItems: \"center\", justifyContent: \"center\", flexShrink: 0, minWidth: vlak.hit, minHeight: vlak.hit, padding: 0, backgroundColor: \"transparent\", color: vlak.gray, borderWidth: 0, borderRadius: vlak.radiusSm, cursor: \"pointer\", outlineWidth: { default: null, \":focus-visible\": 2 }, outlineStyle: { default: null, \":focus-visible\": \"solid\" }, outlineColor: vlak.ink, outlineOffset: 2 },\n  pauseIcon: { width: \"0.875rem\", height: \"0.875rem\", fill: \"currentColor\" },\n  list: { listStyleType: \"none\", margin: 0, padding: 0, display: \"grid\", gap: \"1rem\", flex: \"1 1 10rem\", minWidth: 0 },\n  item: { display: \"grid\", gridTemplateColumns: \"1.25rem minmax(0, 1fr)\", alignItems: \"baseline\", columnGap: \"0.625rem\", overflowWrap: \"anywhere\" },\n  index: { fontSize: \"0.75rem\", color: vlak.gray, fontVariantNumeric: \"tabular-nums\" },\n  label: { margin: 0, fontSize: vlak.controlFs, fontWeight: 500 },\n  value: { margin: \"0.125rem 0 0\", fontSize: \"1.25rem\", fontVariantNumeric: \"tabular-nums\", letterSpacing: \"-0.02em\" },\n  target: { fontSize: \"0.875rem\", color: vlak.gray, letterSpacing: \"normal\" },\n  note: { margin: \"0.25rem 0 0\", fontSize: \"0.75rem\", fontWeight: 400, color: vlak.gray },\n  description: { margin: \"1rem 0 0\", fontSize: \"0.875rem\", color: vlak.gray, maxWidth: \"66ch\" },\n  progress: { position: \"absolute\", width: 1, height: 1, padding: 0, margin: -1, overflow: \"hidden\", clipPath: \"inset(50%)\", whiteSpace: \"nowrap\", borderWidth: 0 },\n});\n\n/** Concentric personal-goal progress, with a complete named record for each ring. */\nexport const ActivityRings = React.forwardRef<HTMLElement, ActivityRingsProps>(function ActivityRings({ label, goals, description, animate = true, encouragement = defaultEncouragement, className, style, ...props }, ref) {\n  const figureRef = React.useRef<HTMLElement>(null);\n  const mergedRef = useMergedRefs(ref, figureRef);\n  const [entered, setEntered] = React.useState(false);\n  const [inView, setInView] = React.useState(false);\n  const [reducedMotion, setReducedMotion] = React.useState(false);\n  const [visible, setVisible] = React.useState(true);\n  const [paused, setPaused] = React.useState(false);\n  // A stable content key keeps frequent goal updates from resetting the timer.\n  const messageKey = JSON.stringify(encouragement === false ? [] : [...new Set(encouragement.map(line => line.trim()).filter(Boolean))]);\n  const messages = React.useMemo<string[]>(() => JSON.parse(messageKey), [messageKey]);\n  const [message, setMessage] = React.useState(messages[0] ?? \"\");\n  const line = messages.includes(message) ? message : messages[0];\n  const hasGoals = goals.length > 0;\n  React.useEffect(() => {\n    const element = figureRef.current;\n    if (!element) return;\n    if (typeof IntersectionObserver === \"undefined\") { setEntered(true); setInView(true); return; }\n    const observer = new IntersectionObserver(entries => {\n      const shown = entries.some(entry => entry.isIntersecting);\n      setInView(shown);\n      if (shown) setEntered(true);\n    }, { threshold: 0.15 });\n    observer.observe(element);\n    return () => observer.disconnect();\n  }, []);\n  React.useEffect(() => {\n    const media = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\");\n    const updateMotion = () => setReducedMotion(media?.matches ?? false);\n    const updateVisibility = () => setVisible(document.visibilityState !== \"hidden\");\n    updateMotion(); updateVisibility();\n    media?.addEventListener(\"change\", updateMotion);\n    document.addEventListener(\"visibilitychange\", updateVisibility);\n    return () => { media?.removeEventListener(\"change\", updateMotion); document.removeEventListener(\"visibilitychange\", updateVisibility); };\n  }, []);\n  React.useEffect(() => {\n    if (!hasGoals || messages.length < 2 || paused || reducedMotion || !visible || !inView) return;\n    const timer = window.setInterval(() => {\n      const random = Math.random();\n      setMessage(previous => {\n        const choices = messages.filter(candidate => candidate !== (messages.includes(previous) ? previous : messages[0]));\n        return choices[Math.floor(random * choices.length)]!;\n      });\n    }, 10000);\n    return () => window.clearInterval(timer);\n  }, [hasGoals, messages, paused, reducedMotion, visible, inView]);\n  const id = React.useId();\n  const root = rs([\"rs-activity-rings\", className], styles.root);\n  const caption = rs([\"rs-activity-rings-caption\"], styles.caption);\n  const body = rs([\"rs-activity-rings-body\"], styles.body);\n  const graphic = rs([\"rs-activity-rings-graphic\"], styles.graphic);\n  const track = rs([\"rs-activity-rings-track\"], styles.track);\n  const arc = rs([\"rs-activity-rings-arc\", animate && \"rs-activity-rings-motion\", animate && !entered && \"rs-activity-rings-waiting\"], styles.arc, animate && styles.motion, animate && !entered && styles.waiting);\n  const encouragementStyle = rs([\"rs-activity-rings-encouragement\"], styles.encouragement);\n  const encouragementText = rs([\"rs-activity-rings-encouragement-text\"], styles.encouragementText);\n  const pause = rs([\"rs-activity-rings-pause\"], styles.pause);\n  const pauseIcon = rs([\"rs-activity-rings-pause-icon\"], styles.pauseIcon);\n  const list = rs([\"rs-activity-rings-list\"], styles.list);\n  const item = rs([\"rs-activity-rings-item\"], styles.item);\n  const indexStyle = rs([\"rs-activity-rings-index\"], styles.index);\n  const labelStyle = rs([\"rs-activity-rings-label\"], styles.label);\n  const valueStyle = rs([\"rs-activity-rings-value\"], styles.value);\n  const targetStyle = rs([\"rs-activity-rings-target\"], styles.target);\n  const note = rs([\"rs-activity-rings-note\"], styles.note);\n  const descriptionStyle = rs([\"rs-activity-rings-description\"], styles.description);\n  const progress = rs([\"rs-activity-rings-progress\"], styles.progress);\n  const readings = goals.map(goal => {\n    const recorded = goal.current != null && Number.isFinite(goal.current) && goal.current >= 0;\n    const targeted = goal.target != null && Number.isFinite(goal.target) && goal.target > 0;\n    return { ...goal, recorded, targeted, fraction: recorded && targeted ? Math.min(1, goal.current! / goal.target!) : null };\n  });\n  const drawable = readings.length > 0 && readings.length <= 6;\n  const step = readings.length <= 3 ? 22 : 13;\n  const strokeWidth = readings.length <= 3 ? 14 : 8;\n  return <figure {...props} ref={mergedRef} className={root.className} style={{ ...root.style, ...style }}>\n    <figcaption {...caption}>{label}{drawable && readings.length > 1 && <span {...note}>Outer to inner</span>}</figcaption>\n    <div {...body}>\n      {drawable && <svg {...graphic} viewBox=\"0 0 200 200\" aria-hidden=\"true\" focusable=\"false\">\n        {readings.map((goal, index) => <g key={goal.id} transform=\"rotate(-90 100 100)\">\n          <circle {...track} cx=\"100\" cy=\"100\" r={88 - index * step} strokeWidth={strokeWidth} strokeDasharray={goal.fraction == null ? \"2 4\" : undefined} />\n          {goal.fraction != null && goal.fraction > 0 && <circle {...arc} cx=\"100\" cy=\"100\" r={88 - index * step} strokeWidth={strokeWidth} pathLength=\"100\" strokeDasharray=\"100 100\" strokeDashoffset={100 - goal.fraction * 100} style={{ ...arc.style, ...(animate ? { animationDelay: `${index * 100}ms` } : {}) }} />}\n        </g>)}\n      </svg>}\n      {readings.length > 0 ? <ol {...list} aria-label={drawable ? `${label}, outer ring first` : `${label} goals`}>{readings.map((goal, index) => <li key={goal.id} {...item}>\n        <span {...indexStyle} aria-hidden=\"true\">{String(index + 1).padStart(2, \"0\")}</span>\n        <div>\n          <p {...labelStyle}>{goal.label}</p>\n          {goal.recorded ? <p {...valueStyle}>{goal.current} <span {...targetStyle}>{goal.targeted ? `of ${goal.target} ${goal.unit}` : goal.unit}</span></p> : <p {...note}>{goal.current == null ? \"No activity recorded\" : \"Activity unavailable\"}</p>}\n          {!goal.targeted && <p {...note}>{goal.target == null ? \"No goal set\" : \"Goal unavailable\"}</p>}\n          {goal.recorded && goal.targeted && <>\n            <progress {...progress} max={goal.target!} value={Math.min(goal.current!, goal.target!)} aria-label={drawable ? `${goal.label}, ring ${index + 1}` : goal.label} aria-valuetext={`${goal.current} of ${goal.target} ${goal.unit}`} aria-describedby={description != null ? `${id}-description` : undefined}>{goal.current} of {goal.target} {goal.unit}</progress>\n            {goal.current! >= goal.target! && <p {...note}>{goal.current! > goal.target! ? \"Goal exceeded\" : \"Goal reached\"}</p>}\n          </>}\n        </div>\n      </li>)}</ol> : <p {...note}>No activity goals supplied</p>}\n    </div>\n    {readings.length > 6 && <p {...descriptionStyle}>The ring view supports up to six goals. All supplied goals are listed.</p>}\n    {description != null && <p {...descriptionStyle} id={`${id}-description`}>{description}</p>}\n    {hasGoals && line && <div {...encouragementStyle}>\n      <p key={line} {...encouragementText} aria-live=\"off\">{line}</p>\n      {messages.length > 1 && !reducedMotion && <button {...pause} type=\"button\" aria-label={paused ? \"Resume encouragement\" : \"Pause encouragement\"} title={paused ? \"Resume encouragement\" : \"Pause encouragement\"} onClick={() => setPaused(current => !current)}>\n        <svg {...pauseIcon} viewBox=\"0 0 16 16\" aria-hidden=\"true\" focusable=\"false\">{paused ? <path d=\"M4 2L13 8L4 14Z\" /> : <path d=\"M4 3H6V13H4ZM10 3H12V13H10Z\" />}</svg>\n      </button>}\n    </div>}\n  </figure>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/activity-rings.tsx"
    },
    {
      "path": "vlak/styles/activity-rings.css",
      "content": "/* ── activity-rings: generated from packages/react/src/components/activity-rings.tsx ── */\n@keyframes rs-anim-ring-build{from{stroke-dashoffset:100;opacity:0}to{opacity:1}}\n@keyframes rs-anim-encouragement-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}\n.rs-activity-rings{margin:0;width:100%;min-width:0;color:var(--text);line-height:1.45}\n.rs-activity-rings-caption{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:0.25rem 1rem;margin-bottom:1rem;font-size:var(--control-fs);font-weight:600}\n.rs-activity-rings-body{display:flex;flex-wrap:wrap;align-items:center;gap:1.5rem 2rem}\n.rs-activity-rings-graphic{display:block;flex-shrink:0;width:10.5rem;height:10.5rem;max-width:100%;overflow:visible}\n.rs-activity-rings-track{fill:none;stroke:var(--divider)}\n@media (forced-colors: active){.rs-activity-rings-track{stroke:CanvasText}}\n.rs-activity-rings-arc{fill:none;stroke:var(--text);stroke-linecap:round;forced-color-adjust:none}\n@media (forced-colors: active){.rs-activity-rings-arc{stroke:Highlight}}\n.rs-activity-rings-motion{animation-name:rs-anim-ring-build;animation-duration:1s;animation-timing-function:cubic-bezier(0.16, 1, 0.3, 1);animation-fill-mode:backwards;transition-property:stroke-dashoffset;transition-duration:0.6s;transition-timing-function:cubic-bezier(0.16, 1, 0.3, 1)}\n@media (prefers-reduced-motion: reduce){.rs-activity-rings-motion{animation-name:none;transition-duration:0s}}\n.rs-activity-rings-waiting{animation-play-state:paused}\n.rs-activity-rings-encouragement{display:flex;align-items:center;gap:0.5rem;margin-top:1.25rem;min-height:var(--hit);max-width:66ch}\n.rs-activity-rings-encouragement-text{margin:0;flex:1 1 auto;min-width:0;font-size:0.875rem;line-height:1.45;color:var(--text-secondary);overflow-wrap:anywhere;animation-name:rs-anim-encouragement-in;animation-duration:0.3s;animation-timing-function:ease-out}\n@media (prefers-reduced-motion: reduce){.rs-activity-rings-encouragement-text{animation-name:none}}\n.rs-activity-rings-pause{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;min-width:var(--hit);min-height:var(--hit);padding:0;background-color:transparent;color:var(--text-secondary);border-width:0;border-radius:var(--radius-sm);cursor:pointer;outline-color:var(--text);outline-offset:2px}\n.rs-activity-rings-pause:focus-visible{outline-width:2px;outline-style:solid}\n.rs-activity-rings-pause-icon{width:0.875rem;height:0.875rem;fill:currentColor}\n.rs-activity-rings-list{list-style-type:none;margin:0;padding:0;display:grid;gap:1rem;flex:1 1 10rem;min-width:0}\n.rs-activity-rings-item{display:grid;grid-template-columns:1.25rem minmax(0, 1fr);align-items:baseline;column-gap:0.625rem;overflow-wrap:anywhere}\n.rs-activity-rings-index{font-size:0.75rem;color:var(--text-secondary);font-variant-numeric:tabular-nums}\n.rs-activity-rings-label{margin:0;font-size:var(--control-fs);font-weight:500}\n.rs-activity-rings-value{margin:0.125rem 0 0;font-size:1.25rem;font-variant-numeric:tabular-nums;letter-spacing:-0.02em}\n.rs-activity-rings-target{font-size:0.875rem;color:var(--text-secondary);letter-spacing:normal}\n.rs-activity-rings-note{margin:0.25rem 0 0;font-size:0.75rem;font-weight:400;color:var(--text-secondary)}\n.rs-activity-rings-description{margin:1rem 0 0;font-size:0.875rem;color:var(--text-secondary);max-width:66ch}\n.rs-activity-rings-progress{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border-width:0}\n",
      "type": "registry:file",
      "target": "styles/vlak/activity-rings.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "health",
      "classes": [
        "rs-activity-rings",
        "rs-activity-rings-caption",
        "rs-activity-rings-body",
        "rs-activity-rings-graphic",
        "rs-activity-rings-track",
        "rs-activity-rings-arc",
        "rs-activity-rings-list",
        "rs-activity-rings-item",
        "rs-activity-rings-index",
        "rs-activity-rings-label",
        "rs-activity-rings-value",
        "rs-activity-rings-target",
        "rs-activity-rings-note",
        "rs-activity-rings-description",
        "rs-activity-rings-progress",
        "rs-activity-rings-motion",
        "rs-activity-rings-waiting",
        "rs-activity-rings-encouragement",
        "rs-activity-rings-encouragement-text",
        "rs-activity-rings-pause",
        "rs-activity-rings-pause-icon"
      ],
      "snippet": "<figure class=\"rs-activity-rings\"><figcaption class=\"rs-activity-rings-caption\">Daily activity</figcaption><div class=\"rs-activity-rings-body\"><svg class=\"rs-activity-rings-graphic\" viewBox=\"0 0 200 200\" aria-hidden=\"true\"><g transform=\"rotate(-90 100 100)\"><circle class=\"rs-activity-rings-track\" cx=\"100\" cy=\"100\" r=\"88\" stroke-width=\"14\"/><circle class=\"rs-activity-rings-arc\" cx=\"100\" cy=\"100\" r=\"88\" stroke-width=\"14\" pathLength=\"100\" stroke-dasharray=\"60 100\"/></g></svg><ol class=\"rs-activity-rings-list\" aria-label=\"Daily activity, outer ring first\"><li class=\"rs-activity-rings-item\"><span class=\"rs-activity-rings-index\" aria-hidden=\"true\">01</span><div><p class=\"rs-activity-rings-label\">Walking</p><p class=\"rs-activity-rings-value\">18 <span class=\"rs-activity-rings-target\">of 30 minutes</span></p><progress class=\"rs-activity-rings-progress\" value=\"18\" max=\"30\" aria-label=\"Walking, ring 1\" aria-valuetext=\"18 of 30 minutes\">18 of 30 minutes</progress></div></li></ol></div></figure>",
      "cssOnly": false,
      "registryDependencies": [
        "vlak-lib"
      ],
      "aliases": [
        "Activity ring",
        "Fitness rings",
        "Goal rings",
        "Move rings",
        "Wellness progress",
        "Concentric progress"
      ],
      "example": "import { ActivityRings } from \"@noorddev/vlak-react\";\n\n<ActivityRings label=\"Daily activity\" goals={[\n  { id: \"walk\", label: \"Walking\", current: 18, target: 30, unit: \"minutes\" },\n  { id: \"move\", label: \"Movement breaks\", current: 5, target: 8, unit: \"breaks\" },\n  { id: \"stand\", label: \"Standing\", current: 7, target: 10, unit: \"hours\" },\n]} description=\"Personal targets, supplied by your application\" />",
      "usage": {
        "use": [
          "One activity ring or a compact set of personal movement, routine, or wellness goals.",
          "Supply goals in outer-to-inner order, with unique ids, labels, amounts, targets, and units.",
          "Pair with ActivityGoal for a linear progress view of the same data.",
          "Rings sweep into their supplied values on first view and ease into later updates. Set animate to false for an instant graphic.",
          "Encouragement picks a different line every ten seconds while visible. Supply your own encouragement strings, one string for a static line, or false to hide it. The first line stays deterministic during server rendering."
        ],
        "avoid": [
          "Deriving a readiness score, calorie prescription, or recommended target.",
          "Treating an unrecorded amount as zero. More than six goals retain their text records without a ring graphic."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab",
          "does": "Moves to the encouragement pause control when rotation is available."
        },
        {
          "keys": "Enter, Space",
          "does": "Pauses or resumes encouraging text rotation."
        }
      ],
      "a11y": [
        "Every valid goal has a named native progress element and an explicit amount, target, and unit.",
        "Concentric geometry is decorative. Visible numbering and outer-to-inner order connect the rings with their text records without relying on hue.",
        "Zero stays empty, amounts above target retain their actual value, and missing or invalid data has no progress arc.",
        "The figure forwards its native attributes and ref, and rings remain visible in forced colors. Actual readings and native progress values update immediately throughout the decorative entrance.",
        "Reduced motion makes ring fills instant and keeps encouragement static. Rotation also pauses while the figure is offscreen or the document is hidden.",
        "A 44px keyboard-accessible control pauses or resumes rotation. Encouragement has no automatic live announcement; text remains available in the reading order."
      ]
    }
  }
}
