{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slide-stepper-shadcn",
  "title": "Slide Stepper (shadcn-native)",
  "author": "Lloyd Humphreys",
  "description": "The auto-advancing slide stepper composed shadcn-natively: Tailwind theme tokens for every color, shadcn's <Button> as the pause circle, lucide icons, cn. Same model as slide-stepper — headless useSlideStepper hook, the pill with tape-counter clipping, and a zero-wiring crossfade SlideStepperCarousel — self-contained in one file.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/slide-stepper/slide-stepper-shadcn.tsx",
      "content": "// slide-stepper-shadcn — the auto-advancing slide stepper composed shadcn-natively.\n//\n// Same model as the vanilla `slide-stepper` (a headless engine with composable\n// pause-reasons; a pill of dots whose active dot stretches into a filling bar; a\n// crossfading carousel on top), but built from your app's actual pieces: Tailwind theme\n// tokens for every color, `cn` for classes, shadcn's <Button> as the pause circle, lucide\n// icons. Inside a shadcn app it matches your theme untouched.\n//\n//   const stepper = useSlideStepper({ count: 10 })   headless — bring your own content\n//   <SlideStepper engine={stepper.engine} clip={5} />\n//   <SlideStepperCarousel slides={[...]} />          zero wiring\n//\n// Self-contained on purpose: the engine is inlined rather than imported from the vanilla\n// core, so this file installs alone (plus shadcn's button, pulled in as a\n// registryDependency). See slide-stepper.ts for the annotated reference implementation —\n// the timer semantics here are identical: JS setTimeout is the authoritative clock, CSS\n// transitions only display progress, and pausing is a set of reasons so a user pause\n// survives a hover-out.\n//\n// State ownership: `index` is an uncontrolled starting point, not a controlled prop — the\n// engine owns navigation, since timing/progress is ephemeral UI state that a controlled\n// value would fight on every render. Drive jumps through the returned engine instead.\n\n'use client'\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from 'react'\nimport { PauseIcon, PlayIcon, RotateCcwIcon } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\n// ── Engine ─────────────────────────────────────────────────────────────────────────────\n\ntype PauseReason = 'user' | 'hover' | 'hidden' | 'offscreen' | 'gesture' | 'focus'\ntype StepChangeReason = 'advance' | 'next' | 'prev' | 'goto' | 'loop'\n\ninterface StepperEngineOptions {\n  /** How many slides. Required; clamped to >= 1. */\n  count: number\n  /** Default per-slide duration in ms. Default 5000. */\n  duration?: number\n  /** Sparse per-slide overrides by index (array with holes, or a record like `{ 2: 8000 }`). */\n  durations?: number[] | Record<number, number>\n  /** Wrap after the last slide. Default true; with loop off the deck stops (`done`) and the\n   *  pause button becomes Replay. */\n  loop?: boolean\n  /** Start with a 'user' pause already applied. */\n  startPaused?: boolean\n  index?: number\n  onChange?: (index: number, prevIndex: number, reason: StepChangeReason) => void\n  onComplete?: (index: number) => void\n  onPauseChange?: (paused: boolean, reasons: PauseReason[]) => void\n}\n\ninterface StepperEngineState {\n  index: number\n  count: number\n  /** 0..1 through the current slide, computed on demand — never ticked. */\n  progress: number\n  paused: boolean\n  pauseReasons: PauseReason[]\n  done: boolean\n}\n\ninterface StepperEngine {\n  getState(): StepperEngineState\n  durationFor(index: number): number\n  /** Start auto-advance. Idempotent; safe to call from every mounted consumer. */\n  start(): void\n  subscribe(fn: (state: StepperEngineState) => void): () => void\n  next(): void\n  prev(): void\n  /** Jump to a slide. Restarts that slide's progress unless `restart: false`. */\n  goTo(index: number, opts?: { restart?: boolean }): void\n  pause(reason?: PauseReason): void\n  resume(reason?: PauseReason): void\n  toggleUserPause(): void\n  isPausedBy(reason: PauseReason): boolean\n  setOptions(patch: Partial<Pick<StepperEngineOptions, 'count' | 'duration' | 'durations' | 'loop'>>): void\n  destroy(): void\n}\n\nconst clampIndex = (i: number, count: number) => Math.min(Math.max(Math.floor(i), 0), count - 1)\n\nfunction createEngine(opts: StepperEngineOptions): StepperEngine {\n  let count = Math.max(1, Math.floor(opts.count))\n  let duration = opts.duration ?? 5000\n  let durations = opts.durations\n  let loop = opts.loop ?? true\n  let index = clampIndex(opts.index ?? 0, count)\n  let done = false\n  let elapsed = 0\n  let runStart: number | null = null\n  let started = false\n  let timer: ReturnType<typeof setTimeout> | undefined\n  const reasons = new Set<PauseReason>()\n  if (opts.startPaused) reasons.add('user')\n  const subs = new Set<(s: StepperEngineState) => void>()\n\n  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now())\n  const durationFor = (i: number): number => {\n    const v = durations ? (durations as Record<number, number>)[i] : undefined\n    return typeof v === 'number' && v > 0 ? v : duration\n  }\n  const getProgress = () => {\n    if (done) return 1\n    const live = runStart != null ? now() - runStart : 0\n    return Math.min(1, (elapsed + live) / durationFor(index))\n  }\n  const getState = (): StepperEngineState => ({\n    index, count, progress: getProgress(), paused: reasons.size > 0, pauseReasons: [...reasons], done,\n  })\n  const notify = () => {\n    const s = getState()\n    subs.forEach((fn) => fn(s))\n  }\n  const clearTimer = () => {\n    if (timer !== undefined) {\n      clearTimeout(timer)\n      timer = undefined\n    }\n  }\n  const armTimer = () => {\n    clearTimer()\n    if (!started || reasons.size > 0 || done) {\n      runStart = null\n      return\n    }\n    runStart = now()\n    timer = setTimeout(complete, Math.max(0, durationFor(index) - elapsed))\n  }\n  const complete = () => {\n    opts.onComplete?.(index)\n    if (index < count - 1) jump(index + 1, 'advance')\n    else if (loop) jump(0, 'loop')\n    else {\n      done = true\n      elapsed = durationFor(index)\n      clearTimer()\n      runStart = null\n      notify()\n    }\n  }\n  const jump = (to: number, reason: StepChangeReason) => {\n    const prev = index\n    index = clampIndex(to, count)\n    done = false\n    elapsed = 0\n    runStart = null\n    if (index !== prev) opts.onChange?.(index, prev, reason)\n    armTimer()\n    notify()\n  }\n  const pause = (reason: PauseReason = 'user') => {\n    if (reasons.has(reason)) return\n    const wasRunning = started && reasons.size === 0 && !done\n    if (wasRunning && runStart != null) {\n      elapsed += now() - runStart\n      runStart = null\n    }\n    reasons.add(reason)\n    clearTimer()\n    if (wasRunning) opts.onPauseChange?.(true, [...reasons])\n    notify()\n  }\n  const resume = (reason: PauseReason = 'user') => {\n    if (!reasons.delete(reason)) return\n    if (reasons.size === 0) {\n      armTimer()\n      if (started) opts.onPauseChange?.(false, [])\n    }\n    notify()\n  }\n\n  return {\n    getState,\n    durationFor,\n    start() {\n      if (started) return\n      started = true\n      armTimer()\n    },\n    subscribe(fn) {\n      subs.add(fn)\n      return () => subs.delete(fn)\n    },\n    next() {\n      if (index < count - 1) jump(index + 1, 'next')\n      else if (loop) jump(0, 'next')\n    },\n    prev() {\n      if (index > 0) jump(index - 1, 'prev')\n      else if (loop) jump(count - 1, 'prev')\n    },\n    goTo(i, o) {\n      if (o?.restart === false && clampIndex(i, count) === index) return\n      jump(i, 'goto')\n    },\n    pause,\n    resume,\n    toggleUserPause() {\n      if (done) {\n        reasons.delete('user')\n        jump(0, 'goto')\n      } else if (reasons.has('user')) resume('user')\n      else pause('user')\n    },\n    isPausedBy: (reason) => reasons.has(reason),\n    // Keys present in the patch are applied — `undefined` resets to the default (`count`,\n    // having none, is kept) — absent keys are untouched. The components pass every prop\n    // each sync, so a removed prop genuinely resets.\n    setOptions(patch) {\n      if (runStart != null) {\n        elapsed += now() - runStart\n        runStart = null\n      }\n      if ('count' in patch && patch.count != null) {\n        count = Math.max(1, Math.floor(patch.count))\n        const prev = index\n        index = clampIndex(index, count)\n        if (index !== prev) {\n          // The clamp moved us to a different slide — a jump, not a silent renumber.\n          elapsed = 0\n          done = false\n          opts.onChange?.(index, prev, 'goto')\n        } else if (done && index < count - 1) {\n          // A finished deck grew: its last slide isn't last anymore, so it resumes.\n          done = false\n          elapsed = 0\n        }\n      }\n      if ('duration' in patch) duration = patch.duration ?? 5000\n      if ('durations' in patch) durations = patch.durations\n      if ('loop' in patch) loop = patch.loop ?? true\n      armTimer()\n      notify()\n    },\n    destroy() {\n      clearTimer()\n      runStart = null\n      started = false\n      subs.clear()\n    },\n  }\n}\n\n// ── Hook ───────────────────────────────────────────────────────────────────────────────\n\ninterface UseSlideStepperOptions extends StepperEngineOptions {}\n\ninterface UseSlideStepperReturn extends StepperEngineState {\n  engine: StepperEngine\n  next: () => void\n  prev: () => void\n  goTo: (index: number) => void\n  pause: () => void\n  resume: () => void\n  toggle: () => void\n}\n\n/** Headless: an engine plus its live state. Share `engine` with <SlideStepper> and key your\n *  own content off `index` — one timer, one source of truth. */\nfunction useSlideStepper(opts: UseSlideStepperOptions): UseSlideStepperReturn {\n  const cb = useRef({ onChange: opts.onChange, onComplete: opts.onComplete, onPauseChange: opts.onPauseChange })\n  cb.current = { onChange: opts.onChange, onComplete: opts.onComplete, onPauseChange: opts.onPauseChange }\n\n  const [engine] = useState(() =>\n    createEngine({\n      count: opts.count,\n      duration: opts.duration,\n      durations: opts.durations,\n      loop: opts.loop,\n      startPaused: opts.startPaused,\n      index: opts.index,\n      onChange: (i, p, r) => cb.current.onChange?.(i, p, r),\n      onComplete: (i) => cb.current.onComplete?.(i),\n      onPauseChange: (p, r) => cb.current.onPauseChange?.(p, r),\n    }),\n  )\n  const [state, setState] = useState<StepperEngineState>(() => engine.getState())\n\n  useEffect(() => {\n    const unsubscribe = engine.subscribe(setState)\n    // Construction is side-effect-free; start only after mount, and re-arm after the\n    // StrictMode cleanup below. Shared consumers may also call this; start is idempotent.\n    engine.start()\n    return () => {\n      unsubscribe()\n      engine.destroy()\n    }\n  }, [engine])\n\n  useEffect(() => {\n    engine.setOptions({ count: opts.count, duration: opts.duration, durations: opts.durations, loop: opts.loop })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [engine, opts.count, opts.duration, opts.durations, opts.loop])\n\n  return {\n    engine,\n    ...state,\n    next: engine.next,\n    prev: engine.prev,\n    goTo: (i) => engine.goTo(i),\n    pause: () => engine.pause('user'),\n    resume: () => engine.resume('user'),\n    toggle: engine.toggleUserPause,\n  }\n}\n\n// ── Shared attachments ─────────────────────────────────────────────────────────────────\n\nfunction useAutoPause(\n  ref: RefObject<HTMLElement | null>,\n  engine: StepperEngine,\n  opts: { hover?: boolean; hidden?: boolean; offscreen?: boolean; offscreenThreshold?: number },\n) {\n  const { hover, hidden, offscreen, offscreenThreshold } = opts\n  useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    const detach: Array<() => void> = []\n    if (hidden !== false) {\n      const onVis = () => (document.hidden ? engine.pause('hidden') : engine.resume('hidden'))\n      document.addEventListener('visibilitychange', onVis)\n      if (document.hidden) engine.pause('hidden')\n      detach.push(() => {\n        document.removeEventListener('visibilitychange', onVis)\n        engine.resume('hidden')\n      })\n    }\n    // Only on actually-hovering fine pointers: on touch, pointerenter fires on tap and never\n    // leaves, which would strand the pause.\n    if (hover !== false && typeof matchMedia !== 'undefined' && matchMedia('(hover: hover) and (pointer: fine)').matches) {\n      const enter = () => engine.pause('hover')\n      const leave = () => engine.resume('hover')\n      el.addEventListener('pointerenter', enter)\n      el.addEventListener('pointerleave', leave)\n      detach.push(() => {\n        el.removeEventListener('pointerenter', enter)\n        el.removeEventListener('pointerleave', leave)\n        engine.resume('hover')\n      })\n    }\n    if (offscreen !== false && typeof IntersectionObserver !== 'undefined') {\n      const io = new IntersectionObserver(\n        (entries) => {\n          const entry = entries[entries.length - 1]\n          if (entry) (entry.isIntersecting ? engine.resume('offscreen') : engine.pause('offscreen'))\n        },\n        { threshold: offscreenThreshold ?? 0 },\n      )\n      io.observe(el)\n      detach.push(() => {\n        io.disconnect()\n        engine.resume('offscreen')\n      })\n    }\n    return () => detach.forEach((fn) => fn())\n  }, [ref, engine, hover, hidden, offscreen, offscreenThreshold])\n}\n\nfunction useSwipeNav(\n  ref: RefObject<HTMLElement | null>,\n  engine: StepperEngine,\n  axis: 'x' | 'y',\n  enabled: boolean,\n) {\n  useEffect(() => {\n    const el = ref.current\n    if (!el || !enabled) return\n    const threshold = 24\n    let pointerId: number | null = null\n    let startX = 0\n    let startY = 0\n    let swiping = false\n    // Time-bounded: a touch swipe fires no click, so a sticky flag would eat the next tap.\n    let swallowUntil = 0\n    // Once a pointer is down, the rest of the gesture is tracked on window — a press that\n    // drifts off the element and releases outside still ends, so the 'gesture' pause can\n    // never stick.\n    const finish = () => {\n      unbindWindow()\n      pointerId = null\n      swiping = false\n      engine.resume('gesture')\n    }\n    const onDown = (e: PointerEvent) => {\n      if (!e.isPrimary || pointerId != null) return\n      pointerId = e.pointerId\n      startX = e.clientX\n      startY = e.clientY\n      swiping = false\n      bindWindow()\n      engine.pause('gesture')\n    }\n    const onMove = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId || swiping) return\n      const dx = e.clientX - startX\n      const dy = e.clientY - startY\n      const main = axis === 'x' ? dx : dy\n      const cross = axis === 'x' ? dy : dx\n      if (Math.abs(main) > threshold && Math.abs(main) > Math.abs(cross)) swiping = true\n    }\n    const onUp = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId) return\n      if (swiping) {\n        const main = axis === 'x' ? e.clientX - startX : e.clientY - startY\n        swallowUntil = Date.now() + 350\n        if (main < 0) engine.next()\n        else engine.prev()\n      }\n      finish()\n    }\n    const onCancel = (e: PointerEvent) => {\n      if (e.pointerId === pointerId) finish()\n    }\n    // A swipe that started on a dot must not also click it.\n    const onClick = (e: MouseEvent) => {\n      if (Date.now() >= swallowUntil) return\n      swallowUntil = 0\n      e.preventDefault()\n      e.stopPropagation()\n    }\n    const bindWindow = () => {\n      window.addEventListener('pointermove', onMove)\n      window.addEventListener('pointerup', onUp)\n      window.addEventListener('pointercancel', onCancel)\n    }\n    const unbindWindow = () => {\n      window.removeEventListener('pointermove', onMove)\n      window.removeEventListener('pointerup', onUp)\n      window.removeEventListener('pointercancel', onCancel)\n    }\n    el.addEventListener('pointerdown', onDown)\n    el.addEventListener('click', onClick, true)\n    return () => {\n      el.removeEventListener('pointerdown', onDown)\n      el.removeEventListener('click', onClick, true)\n      if (pointerId != null) finish()\n    }\n  }, [ref, engine, axis, enabled])\n}\n\n/** Live `prefers-reduced-motion`. The pill's decorative transitions are inline styles\n *  (their durations are computed), which Tailwind's motion-reduce variant can't turn off —\n *  so they're gated in JS instead. The fill sweep is deliberately not gated: it *is* the\n *  progress information (matching the vanilla stylesheet's exemption). */\nfunction useReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false)\n  useEffect(() => {\n    const mq = matchMedia('(prefers-reduced-motion: reduce)')\n    const update = () => setReduced(mq.matches)\n    update()\n    mq.addEventListener('change', update)\n    return () => mq.removeEventListener('change', update)\n  }, [])\n  return reduced\n}\n\n// ── Pill ───────────────────────────────────────────────────────────────────────────────\n\ninterface SlideStepperLabels {\n  root?: string\n  slide?: (index: number, count: number) => string\n  pause?: string\n  play?: string\n  replay?: string\n}\n\n/** Geometry presets (px): dot diameter, dot spacing, bar length, pill height / tap target,\n *  pill inline padding. Every color comes from theme tokens instead. */\nconst SIZES = {\n  sm: { dot: 5, gap: 5, bar: 22, hit: 30, pad: 11 },\n  md: { dot: 6, gap: 6, bar: 28, hit: 36, pad: 14 },\n  lg: { dot: 8, gap: 7, bar: 36, hit: 44, pad: 17 },\n} as const\n\nconst GLIDE = 'cubic-bezier(0.22,1,0.36,1)'\n\ninterface SlideStepperProps {\n  /** Share the engine from useSlideStepper; omit to let the pill run its own. Ownership is\n   *  fixed at mount — supply it from the first render. */\n  engine?: StepperEngine\n  count?: number\n  duration?: number\n  durations?: number[] | Record<number, number>\n  loop?: boolean\n  startPaused?: boolean\n  /** Initial slide (self-managed only) — an uncontrolled starting point, not a controlled\n   *  value; drive jumps through the engine instead. */\n  index?: number\n  onChange?: (index: number, prevIndex: number, reason: StepChangeReason) => void\n  onComplete?: (index: number) => void\n  onPauseChange?: (paused: boolean, reasons: PauseReason[]) => void\n  orientation?: 'horizontal' | 'vertical'\n  /** Max dots visible at once; more slides turn the strip into a clamped tape counter. */\n  clip?: number\n  showPause?: boolean\n  pauseOnHover?: boolean\n  pauseWhenHidden?: boolean\n  pauseWhenOffscreen?: boolean\n  offscreenThreshold?: number\n  size?: 'sm' | 'md' | 'lg'\n  /** Ids of your own slide elements, wired to each dot's aria-controls. */\n  slideIds?: (string | undefined)[]\n  labels?: SlideStepperLabels\n  className?: string\n}\n\n/** The pill: dots, stretching progress bar, tape-counter clipping, pause circle. */\nfunction SlideStepper({\n  engine: engineProp,\n  count = 1,\n  duration,\n  durations,\n  loop,\n  startPaused,\n  index,\n  onChange,\n  onComplete,\n  onPauseChange,\n  orientation = 'horizontal',\n  clip,\n  showPause = true,\n  pauseOnHover,\n  pauseWhenHidden,\n  pauseWhenOffscreen,\n  offscreenThreshold,\n  size = 'md',\n  slideIds,\n  labels,\n  className,\n}: SlideStepperProps) {\n  // Engine ownership is frozen at mount: with an external engine, none is created here at\n  // all — no decoy timer just to satisfy the unconditional-hooks rule.\n  const ownsEngine = useRef(engineProp == null).current\n  const cb = useRef({ onChange, onComplete, onPauseChange })\n  cb.current = { onChange, onComplete, onPauseChange }\n  const [own] = useState(() =>\n    ownsEngine\n      ? createEngine({\n          count,\n          duration,\n          durations,\n          loop,\n          startPaused,\n          index,\n          onChange: (i, p, r) => cb.current.onChange?.(i, p, r),\n          onComplete: (i) => cb.current.onComplete?.(i),\n          onPauseChange: (p, r) => cb.current.onPauseChange?.(p, r),\n        })\n      : null,\n  )\n  const engine = (engineProp ?? own) as StepperEngine\n  const [state, setState] = useState<StepperEngineState>(() => engine.getState())\n  useEffect(() => {\n    setState(engine.getState())\n    const unsubscribe = engine.subscribe(setState)\n    engine.start()\n    return () => {\n      unsubscribe()\n      if (ownsEngine) engine.destroy()\n    }\n  }, [engine, ownsEngine])\n  useEffect(() => {\n    if (ownsEngine) engine.setOptions({ count, duration, durations, loop })\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [engine, ownsEngine, count, duration, durations, loop])\n\n  const rootRef = useRef<HTMLDivElement>(null)\n  const pillRef = useRef<HTMLDivElement>(null)\n  const dotRefs = useRef<(HTMLButtonElement | null)[]>([])\n  const fillRefs = useRef<(HTMLSpanElement | null)[]>([])\n\n  useAutoPause(rootRef, engine, {\n    hover: pauseOnHover,\n    hidden: pauseWhenHidden,\n    offscreen: pauseWhenOffscreen,\n    offscreenThreshold,\n  })\n  useSwipeNav(pillRef, engine, orientation === 'vertical' ? 'y' : 'x', true)\n\n  const horizontal = orientation !== 'vertical'\n  const reduced = useReducedMotion()\n  const g = SIZES[size]\n  const slot = g.dot + g.gap\n  const barSlot = g.bar + g.gap\n  const n = state.count\n  // The tape-counter window: active bar centered, clamped at the deck's ends — same math as\n  // the vanilla core's clip-window calc()/clamp(), just computed in JS.\n  const effClip = Math.max(1, Math.min(clip ?? n, n))\n  const win = (effClip - 1) * slot + barSlot\n  const ideal = (state.index - (effClip - 1) / 2) * slot\n  const shift = Math.max(0, Math.min((n - effClip) * slot, ideal))\n  // The unfloored shift mirrored into slot units, floor/ceil'd outward so a half-visible\n  // edge dot (even clip values) is treated as visible on both edges.\n  const slotShift = shift / slot\n  const first = Math.floor(slotShift)\n  const last = Math.min(n - 1, Math.ceil(slotShift) + effClip - 1)\n\n  // The fill sweep: paint the engine's numbers, flush, then glide to 100% over what remains.\n  // One branch covers mount, advance, jump-mid-fill, pause-freeze, resume-from-fraction.\n  useLayoutEffect(() => {\n    const dim = horizontal ? 'width' : 'height'\n    fillRefs.current.forEach((fill, i) => {\n      if (!fill) return\n      fill.style.transition = 'none'\n      if (i !== state.index) {\n        fill.style[dim] = i < state.index ? '100%' : '0%'\n        return\n      }\n      // Remapped to run from one dot to full, so a freshly active bar starts dot-sized\n      // and is visibly growing from the first frame.\n      fill.style[dim] = `${g.dot + state.progress * (g.bar - g.dot)}px`\n      if (!state.paused && !state.done) {\n        void fill.offsetHeight\n        fill.style.transition = `${dim} ${Math.max(0, engine.durationFor(i) * (1 - state.progress))}ms linear`\n        fill.style[dim] = '100%'\n      }\n    })\n  })\n\n  // Taps in the pill's padding land on the nearest dot — the dots are small, the pill is\n  // the target. React's bubble-phase onClick never fires for a swallowed post-swipe click\n  // (the swipe recognizer stops those in capture phase).\n  const onPillClick = (e: ReactMouseEvent<HTMLDivElement>) => {\n    if ((e.target as Element).closest('button')) return\n    const at = horizontal ? e.clientX : e.clientY\n    let best = -1\n    let bestDist = Infinity\n    dotRefs.current.forEach((el, i) => {\n      if (!el || el.getAttribute('aria-hidden') === 'true') return\n      const r = el.getBoundingClientRect()\n      const c = horizontal ? r.left + r.width / 2 : r.top + r.height / 2\n      const dist = Math.abs(at - c)\n      if (dist < bestDist) {\n        bestDist = dist\n        best = i\n      }\n    })\n    if (best >= 0) engine.goTo(best)\n  }\n\n  const onKeyDown = (e: ReactKeyboardEvent) => {\n    let handled = true\n    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') engine.next()\n    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') engine.prev()\n    else if (e.key === 'Home') engine.goTo(0)\n    else if (e.key === 'End') engine.goTo(n - 1)\n    else handled = false\n    if (handled) {\n      e.preventDefault()\n      dotRefs.current[engine.getState().index]?.focus()\n    }\n  }\n\n  const kind = state.done ? 'replay' : engine.isPausedBy('user') ? 'play' : 'pause'\n  const pauseLabel =\n    kind === 'replay' ? (labels?.replay ?? 'Replay') : kind === 'play' ? (labels?.play ?? 'Play') : (labels?.pause ?? 'Pause')\n\n  return (\n    <div\n      data-slot=\"slide-stepper\"\n      data-orientation={orientation}\n      ref={rootRef}\n      className={cn('inline-flex items-center gap-2', !horizontal && 'flex-col', className)}\n    >\n      <div\n        data-slot=\"slide-stepper-pill\"\n        ref={pillRef}\n        onClick={onPillClick}\n        className={cn('flex items-center rounded-full bg-muted', !horizontal && 'flex-col')}\n        style={\n          horizontal\n            ? { height: g.hit, padding: `0 ${g.pad}px`, touchAction: 'pan-y' }\n            : { width: g.hit, padding: `${g.pad}px 0`, touchAction: 'pan-x' }\n        }\n      >\n        <div data-slot=\"slide-stepper-window\" className=\"overflow-hidden\" style={horizontal ? { width: win } : { height: win }}>\n          <div\n            data-slot=\"slide-stepper-strip\"\n            role=\"tablist\"\n            aria-orientation={orientation}\n            aria-label={labels?.root ?? 'Slide progress'}\n            onKeyDown={onKeyDown}\n            className={cn('flex w-max', !horizontal && 'w-auto h-max flex-col')}\n            style={{\n              transform: horizontal ? `translateX(${-shift}px)` : `translateY(${-shift}px)`,\n              transition: reduced ? undefined : `transform 320ms ${GLIDE}`,\n            }}\n          >\n            {Array.from({ length: n }, (_, i) => {\n              const active = i === state.index\n              const offWindow = i < first || i > last\n              return (\n                <button\n                  key={i}\n                  data-slot=\"slide-stepper-dot\"\n                  data-active={active || undefined}\n                  data-done={i < state.index || undefined}\n                  ref={(el) => {\n                    dotRefs.current[i] = el\n                  }}\n                  type=\"button\"\n                  role=\"tab\"\n                  aria-selected={active}\n                  aria-label={labels?.slide?.(i, n) ?? `Slide ${i + 1} of ${n}`}\n                  aria-controls={slideIds?.[i]}\n                  aria-hidden={offWindow || undefined}\n                  tabIndex={active ? 0 : -1}\n                  onClick={() => engine.goTo(i)}\n                  className=\"group grid cursor-pointer place-items-center rounded-full border-0 bg-transparent p-0 focus-visible:outline-none\"\n                  style={{\n                    width: horizontal ? (active ? barSlot : slot) : g.hit,\n                    height: horizontal ? g.hit : active ? barSlot : slot,\n                    transition: reduced ? undefined : `width 250ms ${GLIDE}, height 250ms ${GLIDE}`,\n                  }}\n                >\n                  {/* A soft ring reads smeared on a target this small, so this hugs the\n                      visible track with a crisp outline instead of the canonical\n                      focus-visible ring (same deviation as the vanilla tier's dot track). */}\n                  <span\n                    data-slot=\"slide-stepper-dot-track\"\n                    className={cn(\n                      'relative block overflow-hidden rounded-full group-focus-visible:outline group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-ring',\n                      active ? 'bg-border opacity-100' : i < state.index ? 'bg-muted-foreground opacity-80' : 'bg-muted-foreground opacity-55',\n                    )}\n                    style={{\n                      width: horizontal && active ? g.bar : g.dot,\n                      height: !horizontal && active ? g.bar : g.dot,\n                      transition: reduced ? undefined : `width 250ms ${GLIDE}, height 250ms ${GLIDE}, background-color 200ms ease, opacity 200ms ease`,\n                    }}\n                  >\n                    <span\n                      data-slot=\"slide-stepper-dot-fill\"\n                      ref={(el) => {\n                        fillRefs.current[i] = el\n                      }}\n                      aria-hidden=\"true\"\n                      className={cn(\n                        'absolute rounded-[inherit] bg-primary',\n                        horizontal ? 'inset-y-0 left-0' : 'inset-x-0 top-0',\n                        active ? 'opacity-100' : 'opacity-0',\n                      )}\n                      style={horizontal ? { width: 0 } : { height: 0 }}\n                    />\n                  </span>\n                </button>\n              )\n            })}\n          </div>\n        </div>\n      </div>\n      {showPause ? (\n        <Button\n          data-slot=\"slide-stepper-pause\"\n          data-kind={kind}\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          aria-label={pauseLabel}\n          onClick={() => engine.toggleUserPause()}\n          // ghost + explicit bg-muted so the circle tracks the same token as the pill\n          // (variant=\"secondary\" would tie it to --secondary instead).\n          className=\"rounded-full bg-muted hover:bg-muted/80\"\n          style={{ width: g.hit, height: g.hit }}\n        >\n          {kind === 'replay' ? <RotateCcwIcon /> : kind === 'play' ? <PlayIcon /> : <PauseIcon />}\n        </Button>\n      ) : null}\n    </div>\n  )\n}\n\n// ── Carousel ───────────────────────────────────────────────────────────────────────────\n\ninterface SlideStepperCarouselProps\n  extends Omit<SlideStepperProps, 'engine' | 'slideIds'> {\n  /** The slides: an array of nodes, or a factory for lazy content (rendered once a slide\n   *  comes within one step of showing, then kept mounted). */\n  slides: ReactNode[] | ((index: number) => ReactNode)\n  /** Crossfade duration in ms. Default 300. */\n  transitionMs?: number\n  /** Where the pill sits. Default 'bottom' for a horizontal pill, 'right' for vertical. */\n  pillPosition?: 'top' | 'bottom' | 'left' | 'right'\n  /** Swipe on the slide area for prev/next. Default true. */\n  swipe?: boolean\n  /** Hold a 'focus' pause while focus is inside the carousel (WCAG 2.2.2). Default true. */\n  pauseOnFocusWithin?: boolean\n}\n\n/** The full carousel: crossfading viewport + pill sharing one engine, zero wiring. */\nfunction SlideStepperCarousel({\n  slides,\n  count: countProp,\n  duration,\n  durations,\n  loop,\n  startPaused,\n  index: initialIndex,\n  onChange,\n  onComplete,\n  onPauseChange,\n  orientation = 'horizontal',\n  clip,\n  showPause,\n  size,\n  labels,\n  transitionMs,\n  pillPosition,\n  swipe = true,\n  pauseOnHover,\n  pauseWhenHidden,\n  pauseWhenOffscreen,\n  offscreenThreshold,\n  pauseOnFocusWithin = true,\n  className,\n}: SlideStepperCarouselProps) {\n  const lazy = typeof slides === 'function'\n  const count = lazy ? Math.max(1, Math.floor(countProp ?? 1)) : slides.length\n  if (!lazy && count === 0) {\n    throw new Error('SlideStepperCarousel: `slides` must contain at least one slide')\n  }\n  const stepper = useSlideStepper({ count, duration, durations, loop, startPaused, index: initialIndex, onChange, onComplete, onPauseChange })\n  const { engine, index } = stepper\n\n  const rootRef = useRef<HTMLDivElement>(null)\n  const viewportRef = useRef<HTMLDivElement>(null)\n  const slideRefs = useRef<(HTMLDivElement | null)[]>([])\n\n  useAutoPause(rootRef, engine, {\n    hover: pauseOnHover,\n    hidden: pauseWhenHidden,\n    offscreen: pauseWhenOffscreen,\n    offscreenThreshold,\n  })\n  useSwipeNav(viewportRef, engine, orientation === 'vertical' ? 'y' : 'x', swipe)\n\n  // Keyboard/AT users can't hover-pause; holding focus anywhere inside pauses instead.\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root || !pauseOnFocusWithin) return\n    const onIn = () => engine.pause('focus')\n    const onOut = (e: FocusEvent) => {\n      if (!root.contains(e.relatedTarget as Node | null)) engine.resume('focus')\n    }\n    root.addEventListener('focusin', onIn)\n    root.addEventListener('focusout', onOut)\n    return () => {\n      root.removeEventListener('focusin', onIn)\n      root.removeEventListener('focusout', onOut)\n      engine.resume('focus')\n    }\n  }, [engine, pauseOnFocusWithin])\n\n  // `inert` via attribute (the React prop needs React 19); blocks focus/AT into off-slides.\n  useEffect(() => {\n    slideRefs.current.forEach((el, i) => el?.toggleAttribute('inert', i !== index))\n  }, [index, count])\n\n  // Lazy slides mount once they've come within one step of showing, then stay — a far jump\n  // must not blank the outgoing slide mid-crossfade.\n  const seen = useRef(new Set<number>())\n  if (lazy) {\n    const wraps = loop ?? true\n    seen.current.add(index)\n    seen.current.add(index + 1 < count ? index + 1 : wraps ? 0 : index)\n    seen.current.add(index - 1 >= 0 ? index - 1 : wraps ? count - 1 : index)\n  }\n\n  const uid = useId()\n  const ids = Array.from({ length: count }, (_, i) => `slide-stepper-${uid}-slide-${i + 1}`)\n  const position = pillPosition ?? (orientation === 'vertical' ? 'right' : 'bottom')\n\n  return (\n    <div\n      data-slot=\"slide-stepper-carousel\"\n      ref={rootRef}\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label={labels?.root ?? 'Slides'}\n      className={cn(\n        'flex flex-col items-center gap-4',\n        position === 'top' && 'flex-col-reverse',\n        position === 'right' && 'flex-row',\n        position === 'left' && 'flex-row-reverse',\n        className,\n      )}\n    >\n      <div\n        data-slot=\"slide-stepper-carousel-viewport\"\n        ref={viewportRef}\n        className=\"grid\"\n        style={{ touchAction: orientation === 'vertical' ? 'pan-x' : 'pan-y' }}\n      >\n        {Array.from({ length: count }, (_, i) => (\n          <div\n            key={i}\n            data-slot=\"slide-stepper-carousel-slide\"\n            data-active={i === index || undefined}\n            id={ids[i]}\n            ref={(el) => {\n              slideRefs.current[i] = el\n            }}\n            role=\"group\"\n            aria-roledescription=\"slide\"\n            aria-label={labels?.slide?.(i, count) ?? `Slide ${i + 1} of ${count}`}\n            aria-hidden={i === index ? undefined : true}\n            className={cn(\n              'col-start-1 row-start-1 transition-[opacity,transform] motion-reduce:transition-none',\n              i === index ? 'scale-100 opacity-100' : 'pointer-events-none scale-[0.98] opacity-0',\n            )}\n            style={{ transitionDuration: `${transitionMs ?? 300}ms` } as CSSProperties}\n          >\n            {lazy ? (seen.current.has(i) ? (slides as (index: number) => ReactNode)(i) : null) : (slides as ReactNode[])[i]}\n          </div>\n        ))}\n      </div>\n      <SlideStepper\n        engine={engine}\n        orientation={orientation}\n        clip={clip}\n        showPause={showPause}\n        size={size}\n        labels={labels}\n        slideIds={ids}\n        pauseOnHover={false}\n        pauseWhenHidden={false}\n        pauseWhenOffscreen={false}\n      />\n    </div>\n  )\n}\n\nexport {\n  useSlideStepper,\n  SlideStepper,\n  SlideStepperCarousel,\n  type PauseReason,\n  type StepChangeReason,\n  type StepperEngineOptions,\n  type StepperEngineState,\n  type StepperEngine,\n  type UseSlideStepperOptions,\n  type UseSlideStepperReturn,\n  type SlideStepperLabels,\n  type SlideStepperProps,\n  type SlideStepperCarouselProps,\n}\n",
      "type": "registry:component",
      "target": "components/slide-stepper/slide-stepper-shadcn.tsx"
    }
  ],
  "type": "registry:component"
}