{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-step-timeline",
  "title": "Step Timeline",
  "author": "Cavemio",
  "description": "An expanding step selector with timed progress and accessible manual navigation.",
  "files": [
    {
      "path": "registry/components/step-timeline.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useEffectEvent,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\nimport { lifecycleEase, useLifecycleReducedMotion } from \"./lifecycle-motion\";\n\nexport interface TimelineStep {\n  id: string;\n  title: string;\n  description: ReactNode;\n}\n\nexport interface StepTimelineProps {\n  /** Nonempty steps with unique, stable IDs. */\n  steps: readonly TimelineStep[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Opt in to timed progression. */\n  autoPlay?: boolean;\n  duration?: number;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n  style?: CSSProperties;\n}\n\n/** An independently usable, expanding step selector with optional timed progress. */\nexport function StepTimeline({\n  steps,\n  value,\n  defaultValue,\n  onValueChange,\n  autoPlay = false,\n  duration = 5500,\n  disabled = false,\n  ariaLabel = \"Timeline\",\n  className = \"\",\n  style,\n}: StepTimelineProps) {\n  const [internalValue, setInternalValue] = useState(\n    defaultValue ?? steps[0]?.id,\n  );\n  const selected =\n    steps.find((step) => step.id === (value ?? internalValue))?.id ??\n    steps[0]?.id;\n  const [restart, setRestart] = useState(0);\n  const [paused, setPaused] = useState(false);\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [visible, setVisible] = useState(false);\n  const [pageVisible, setPageVisible] = useState(true);\n  const reduced = useLifecycleReducedMotion();\n  const root = useRef<HTMLDivElement>(null);\n  const fill = useRef<HTMLSpanElement>(null);\n  const animation = useRef<Animation | null>(null);\n  const cycling = autoPlay && !disabled && !reduced && steps.length > 1;\n  const running =\n    cycling && !paused && !hovered && !focused && visible && pageVisible;\n  const select = (id: string) => {\n    if (disabled) return;\n    setInternalValue(id);\n    setRestart((n) => n + 1);\n    if (id !== selected) onValueChange?.(id);\n  };\n  const advance = useEffectEvent(() => {\n    const next =\n      steps[\n        (steps.findIndex((step) => step.id === selected) + 1) % steps.length\n      ];\n    if (next) select(next.id);\n  });\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => setVisible(entry.isIntersecting),\n      { threshold: 0.25 },\n    );\n    observer.observe(node);\n    const update = () => setPageVisible(!document.hidden);\n    update();\n    document.addEventListener(\"visibilitychange\", update);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", update);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!cycling || !fill.current) return;\n    const playback = fill.current.animate(\n      [{ transform: \"scaleY(0)\" }, { transform: \"scaleY(1)\" }],\n      { duration: Math.max(1000, duration), fill: \"both\", easing: \"linear\" },\n    );\n    playback.pause();\n    playback.onfinish = () => advance();\n    animation.current = playback;\n    return () => {\n      playback.cancel();\n      animation.current = null;\n    };\n  }, [selected, restart, cycling, duration]);\n\n  useEffect(() => {\n    if (running) animation.current?.play();\n    else animation.current?.pause();\n  }, [running, selected, restart, cycling, duration]);\n\n  if (!steps.length) return null;\n  return (\n    <div\n      ref={root}\n      className={`w-full text-foreground ${className}`}\n      style={style}\n    >\n      <ol\n        aria-label={ariaLabel}\n        className=\"m-0 list-none p-0\"\n        onMouseEnter={() => setHovered(true)}\n        onMouseLeave={() => setHovered(false)}\n        onFocusCapture={() => setFocused(true)}\n        onBlurCapture={(event) => {\n          if (!event.currentTarget.contains(event.relatedTarget))\n            setFocused(false);\n        }}\n      >\n        {steps.map((step, index) => {\n          const active = selected === step.id;\n          return (\n            <li key={step.id}>\n              <button\n                type=\"button\"\n                aria-pressed={active}\n                aria-expanded={active}\n                aria-label={step.title}\n                disabled={disabled}\n                onClick={() => select(step.id)}\n                onKeyDown={(event) => {\n                  const direction =\n                    event.key === \"ArrowDown\" || event.key === \"ArrowRight\"\n                      ? 1\n                      : event.key === \"ArrowUp\" || event.key === \"ArrowLeft\"\n                        ? -1\n                        : 0;\n                  const next =\n                    event.key === \"Home\"\n                      ? 0\n                      : event.key === \"End\"\n                        ? steps.length - 1\n                        : direction\n                          ? (index + direction + steps.length) % steps.length\n                          : null;\n                  if (next === null) return;\n                  event.preventDefault();\n                  select(steps[next].id);\n                  root.current\n                    ?.querySelectorAll<HTMLButtonElement>(\"ol button\")\n                    [next]?.focus();\n                }}\n                className=\"group relative block w-full cursor-pointer rounded-sm py-4 pl-7 text-left outline-offset-4 focus-visible:outline-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-y-1 left-0 w-0.5 overflow-hidden rounded-full bg-border\"\n                >\n                  {active && (\n                    <span\n                      key={`${step.id}-${restart}`}\n                      ref={fill}\n                      className=\"absolute inset-0 origin-top bg-foreground\"\n                    />\n                  )}\n                </span>\n                <span\n                  className={`block transition-colors duration-300 motion-reduce:transition-none ${active ? \"text-foreground\" : \"text-muted-foreground group-hover:text-foreground\"}`}\n                >\n                  <span className=\"block text-[22px] leading-[1.25] font-normal tracking-[-0.02em] [font-family:var(--timeline-display-font,inherit)] md:text-[24px]\">\n                    {step.title}\n                  </span>\n                  <span\n                    aria-hidden={!active}\n                    inert={!active}\n                    className=\"grid motion-reduce:transition-none\"\n                    style={{\n                      gridTemplateRows: active ? \"1fr\" : \"0fr\",\n                      transition: reduced\n                        ? \"none\"\n                        : `grid-template-rows 500ms ${lifecycleEase}`,\n                    }}\n                  >\n                    <span className=\"min-h-0 overflow-hidden\">\n                      <span className=\"block max-w-96 pt-2 text-[16.5px] leading-[1.55] tracking-[-0.012em] text-pretty text-muted-foreground\">\n                        {step.description}\n                      </span>\n                    </span>\n                  </span>\n                </span>\n              </button>\n            </li>\n          );\n        })}\n      </ol>\n      {autoPlay && !reduced && (\n        <button\n          type=\"button\"\n          disabled={disabled}\n          onClick={() => setPaused((current) => !current)}\n          className=\"mt-4 min-h-11 cursor-pointer rounded-full border border-border px-4 text-xs text-muted-foreground outline-offset-4 focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50\"\n          aria-label={paused ? \"Play timeline\" : \"Pause timeline\"}\n        >\n          {paused ? \"Play\" : \"Pause\"}\n        </button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/lifecycle-motion.ts",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\nexport const lifecycleEase = \"cubic-bezier(0.22, 1, 0.36, 1)\";\nconst query = \"(prefers-reduced-motion: reduce)\";\nfunction subscribe(notify: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", notify);\n  return () => media.removeEventListener(\"change\", notify);\n}\nexport function useLifecycleReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => true,\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "step-timeline",
      "exportName": "StepTimeline",
      "usage": "import { StepTimeline } from \"@/components/step-timeline\";\n\n<StepTimeline steps={steps} value={stage} onValueChange={setStage} />",
      "accessibility": "Native buttons with pressed and expanded states, arrow/Home/End navigation, disabled support, and a pause control. Playback pauses on hover, keyboard focus, offscreen, and hidden tabs. Reduced motion disables timed progression and transitions.",
      "previewNote": "An expanding stage selector with controlled selection and optional autoplay.",
      "categories": [
        "blocks",
        "interactions"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "steps",
            "type": "readonly TimelineStep[]",
            "description": "Required. Nonempty steps with unique, stable IDs."
          },
          {
            "name": "value",
            "type": "string",
            "description": "Optional. Controlled selected step ID."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "Optional. Initial selection when uncontrolled."
          },
          {
            "name": "onValueChange",
            "type": "(value: string) => void",
            "description": "Optional. Called when the selection changes."
          },
          {
            "name": "autoPlay",
            "type": "boolean",
            "description": "Optional. Opt in to timed progression."
          },
          {
            "name": "duration",
            "type": "number",
            "description": "Optional. Duration of each timeline step in milliseconds."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "description": "Optional. Disable user interaction."
          },
          {
            "name": "ariaLabel",
            "type": "string",
            "description": "Optional. Accessible name for the component."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Optional. Additional CSS classes for the root element."
          },
          {
            "name": "style",
            "type": "CSSProperties",
            "description": "Optional. Inline styles for the root element."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}