{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feature-tabs",
  "type": "registry:ui",
  "title": "Feature Tabs",
  "description": "Feature tour whose rail fills per tab, freezing on hover, focus or scroll-away.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/feature-tabs.tsx",
      "content": "\"use client\";\nimport { Tabs as BaseTabs } from \"@base-ui/react/tabs\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { createContext, use, useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { swap, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Orientation = \"vertical\" | \"horizontal\";\n/** auto follows the autoplay prop (and reduced motion); the others are the viewer's own choice. */\ntype Mode = \"auto\" | \"playing\" | \"stopped\";\n\ntype Ctx = {\n  orientation: Orientation;\n  active: string;\n  /** Advancing on its own. Stays true while merely paused. */\n  playing: boolean;\n  /** Frozen for now: hovered, focused from the keyboard, off screen or in a hidden tab. */\n  paused: boolean;\n  interval: number;\n  advance: () => void;\n  toggle: () => void;\n};\n\nconst FeatureTabsContext = createContext<Ctx | null>(null);\n\n// Reduced motion decides whether the tour starts, which changes the markup, so it\n// must read \"no preference\" while hydrating and only then switch to the real value.\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nfunction subscribeReduced(onChange: () => void) {\n  const mq = window.matchMedia(reducedQuery);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n}\nconst useFeatureTabs = () => {\n  const ctx = use(FeatureTabsContext);\n  if (!ctx) throw new Error(\"FeatureTabs parts must be used inside <FeatureTabs>\");\n  return ctx;\n};\n\ntype ValueProps =\n  | { value: string; onValueChange?: (value: string) => void; defaultValue?: never }\n  | { defaultValue: string; onValueChange?: (value: string) => void; value?: undefined };\n\nexport type FeatureTabsProps = Omit<BaseTabs.Root.Props, \"value\" | \"defaultValue\" | \"onValueChange\" | \"className\" | \"orientation\"> &\n  ValueProps & {\n    /** vertical: the tabs stack beside the visual (below it on narrow containers). horizontal: a row of tabs over the visual. */\n    orientation?: Orientation;\n    /** Move to the next tab on its own. Off by default under reduced motion; the play button still starts it. */\n    autoplay?: boolean;\n    /** Milliseconds each tab stays before the next. */\n    interval?: number;\n    className?: string;\n  };\n\n/**\n * Tabs that walk through a product's features on their own. Each tab's rail\n * fills over `interval`, and the next tab takes over when it's full. Hovering,\n * keyboard focus, scrolling it off screen or leaving the browser tab freeze the\n * fill where it is; choosing a tab hands control to the reader for good.\n */\nexport function FeatureTabs({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  orientation = \"vertical\",\n  autoplay = true,\n  interval = 6000,\n  className,\n  children,\n  onKeyDownCapture,\n  onPointerDownCapture,\n  ...rest\n}: FeatureTabsProps) {\n  const [active, setActive] = useControllableState<string>({ value: valueProp, defaultValue: defaultValue ?? \"\", onChange: onValueChange });\n  const reduce = usePrefersReducedMotion();\n  const [mode, setMode] = useState<Mode>(\"auto\");\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [visible, setVisible] = useState(true);\n  const [pageHidden, setPageHidden] = useState(false);\n  const [el, setEl] = useState<HTMLDivElement | null>(null);\n\n  const playing = mode === \"playing\" || (mode === \"auto\" && autoplay && !reduce);\n  const paused = hovered || focused || !visible || pageHidden;\n\n  // A loop nobody can see shouldn't keep time.\n  useEffect(() => {\n    if (!el) return;\n    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting && e.intersectionRatio >= 0.25), { threshold: [0, 0.25] });\n    io.observe(el);\n    const onVis = () => setPageHidden(document.hidden);\n    document.addEventListener(\"visibilitychange\", onVis);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [el]);\n\n  const advance = useCallback(() => {\n    if (!el) return;\n    // DOM order is the reading order, and disabled tabs are skipped.\n    const tabs = [...el.querySelectorAll<HTMLElement>(\"[data-feature-tab]:not([data-disabled])\")];\n    if (tabs.length < 2) return;\n    const i = tabs.findIndex((t) => t.dataset.featureTab === active);\n    const next = tabs[(i + 1) % tabs.length]?.dataset.featureTab;\n    if (next != null) setActive(next);\n  }, [el, active, setActive]);\n\n  const toggle = useCallback(() => setMode(playing ? \"stopped\" : \"playing\"), [playing]);\n\n  return (\n    <FeatureTabsContext value={{ orientation, active, playing, paused, interval, advance, toggle }}>\n      <BaseTabs.Root\n        ref={setEl}\n        value={active}\n        onValueChange={(next, details) => {\n          // A tab chosen by hand is being read: stop the tour rather than take it away.\n          // Automatic fallbacks (a tab removed or disabled) keep the tour going.\n          if (details.reason === \"none\") setMode(\"stopped\");\n          setActive(String(next));\n        }}\n        orientation={orientation}\n        data-slot=\"feature-tabs\"\n        data-orientation={orientation}\n        data-playing={playing || undefined}\n        data-paused={(playing && paused) || undefined}\n        onPointerEnter={(e) => e.pointerType === \"mouse\" && setHovered(true)}\n        onPointerLeave={() => setHovered(false)}\n        onFocus={(e) => {\n          // Only keyboard focus pauses; a click already stops the tour, and the play button must be able to play.\n          if (e.target.matches(\":focus-visible\") && !e.target.closest(\"[data-feature-tabs-toggle]\")) setFocused(true);\n        }}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n        }}\n        // Arrow keys switch on the same frame; pointer switches crossfade.\n        onKeyDownCapture={(e) => {\n          e.currentTarget.dataset.nav = \"key\";\n          onKeyDownCapture?.(e);\n        }}\n        onPointerDownCapture={(e) => {\n          e.currentTarget.dataset.nav = \"pointer\";\n          onPointerDownCapture?.(e);\n        }}\n        className={cn(\"group/ft @container/ft min-w-0\", className)}\n        {...rest}\n      >\n        <div\n          className={cn(\n            \"grid min-w-0 gap-6\",\n            orientation === \"vertical\" ? \"@[620px]/ft:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] @[620px]/ft:items-center @[620px]/ft:gap-8\" : \"grid-cols-1 gap-5\",\n          )}\n        >\n          {children}\n        </div>\n      </BaseTabs.Root>\n    </FeatureTabsContext>\n  );\n}\n\nexport type FeatureTabsListProps = Omit<BaseTabs.List.Props, \"className\"> & { className?: string };\n\nexport function FeatureTabsList({ className, activateOnFocus = true, ...rest }: FeatureTabsListProps) {\n  const { orientation } = useFeatureTabs();\n  return (\n    <BaseTabs.List\n      activateOnFocus={activateOnFocus}\n      className={cn(\n        \"relative flex min-w-0\",\n        orientation === \"vertical\"\n          ? \"flex-col\"\n          : \"-mx-1 overflow-x-auto overscroll-x-contain px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nexport type FeatureTabsTabProps = Omit<BaseTabs.Tab.Props, \"className\" | \"children\" | \"value\" | \"title\"> & {\n  value: string;\n  /** The feature's name. It is the tab's accessible name. */\n  title: React.ReactNode;\n  /** One or two sentences. Vertical tabs reveal it for the active tab only; horizontal tabs always show it. */\n  children?: React.ReactNode;\n  /** 16px icon before the title. */\n  icon?: React.ReactNode;\n  className?: string;\n};\n\nexport function FeatureTabsTab({ value, title, icon, children, className, ...rest }: FeatureTabsTabProps) {\n  const { orientation, active, playing, paused, interval, advance } = useFeatureTabs();\n  const isActive = active === value;\n  const vertical = orientation === \"vertical\";\n  const titleId = useId();\n  const descId = useId();\n  const fill = useRef<HTMLSpanElement>(null);\n  const run = useRef<Animation | null>(null);\n  const pausedNow = useRef(paused);\n  const advanceNow = useRef(advance);\n\n  useEffect(() => {\n    pausedNow.current = paused;\n    advanceNow.current = advance;\n    const anim = run.current;\n    if (!anim || anim.playState === \"finished\") return;\n    if (paused) anim.pause();\n    else anim.play();\n  }, [paused, advance]);\n\n  // The fill is a Web Animation, so pausing freezes it exactly where it is and\n  // the advance fires from the same clock the reader is watching. It animates\n  // the `scale` property, the same one the resting classes set.\n  useEffect(() => {\n    const node = fill.current;\n    if (!node || !isActive || !playing) return;\n    const anim = node.animate([{ scale: vertical ? \"1 0\" : \"0 1\" }, { scale: \"1 1\" }], { duration: interval, easing: \"linear\", fill: \"forwards\" });\n    if (pausedNow.current) anim.pause();\n    anim.onfinish = () => advanceNow.current();\n    run.current = anim;\n    return () => {\n      run.current = null;\n      anim.onfinish = null;\n      // Freeze the progress into an inline style, then decide where it goes from there.\n      try {\n        anim.commitStyles();\n      } catch {\n        return; // Unmounted: nothing left to tidy.\n      }\n      anim.cancel();\n      if (node.closest(\"[data-feature-tab]\")?.hasAttribute(\"data-active\")) {\n        // Still selected, just stopped: let the resting transition draw the rest of the rail.\n        requestAnimationFrame(() => node.style.removeProperty(\"scale\"));\n        return;\n      }\n      // Handed off: the rail fades out where it stands, then resets unseen.\n      const fade = node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 220, easing: \"ease-out\", fill: \"forwards\" });\n      fade.onfinish = () => {\n        node.style.transition = \"none\";\n        node.style.removeProperty(\"scale\");\n        void node.offsetWidth;\n        node.style.removeProperty(\"transition\");\n        fade.cancel();\n      };\n    };\n  }, [isActive, playing, interval, vertical]);\n\n  return (\n    <BaseTabs.Tab\n      value={value}\n      data-feature-tab={value}\n      aria-labelledby={titleId}\n      aria-describedby={children ? descId : undefined}\n      className={cn(\n        \"group/tab relative flex min-w-0 select-none flex-col items-start text-left outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n        vertical ? \"w-full rounded-r-lg py-3 pl-5 pr-3 focus-visible:-outline-offset-1\" : \"min-w-[168px] flex-1 rounded-t-lg px-1 pb-4 pt-1 focus-visible:outline-offset-2\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* The rail: a hairline track with a fill that measures the time left on this tab. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute overflow-hidden rounded-full bg-line-2/70\",\n          vertical ? \"inset-y-0 left-0 w-px\" : \"inset-x-1 bottom-0 h-px\",\n        )}\n      >\n        <span\n          ref={fill}\n          className={cn(\n            \"absolute inset-0 bg-fg\",\n            vertical ? \"origin-top\" : \"origin-left\",\n            // Chosen by hand (or not playing): the rail draws in full, quickly, as a selection mark.\n            isActive && !playing ? \"scale-100\" : vertical ? \"scale-y-0\" : \"scale-x-0\",\n            \"transition-[scale] duration-300 ease-out-expo group-data-[nav=key]/ft:duration-0\",\n          )}\n        />\n      </span>\n\n      <span\n        className={cn(\n          \"flex min-w-0 items-center gap-2.5 text-[13.5px] font-medium tracking-[-0.01em]\",\n          \"text-fg-3 group-hover/tab:text-fg-2 group-data-active/tab:text-fg\",\n          \"transition-[color,scale] duration-150 ease-out-quart group-active/tab:scale-[0.985] group-active/tab:duration-75\",\n        )}\n      >\n        {icon && (\n          <span className=\"grid size-4 shrink-0 place-items-center text-fg-4 transition-colors duration-150 group-hover/tab:text-fg-3 group-data-active/tab:text-fg-2 [&_svg]:size-4\">\n            {icon}\n          </span>\n        )}\n        <span id={titleId} className=\"min-w-0\">\n          {title}\n        </span>\n      </span>\n\n      {children &&\n        (vertical ? (\n          // Height to content on a grid row, so the list reflows smoothly as one description closes and the next opens.\n          <span\n            className={cn(\n              \"grid w-full transition-[grid-template-rows,opacity] duration-300 ease-in-out-quart group-data-[nav=key]/ft:duration-0\",\n              isActive ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n            )}\n          >\n            <span className=\"min-h-0 overflow-hidden\">\n              <span id={descId} className={cn(\"block pt-1.5 text-[12.5px] leading-[1.55] text-pretty text-fg-2\", icon ? \"pl-[26px]\" : null)}>\n                {children}\n              </span>\n            </span>\n          </span>\n        ) : (\n          <span\n            id={descId}\n            className=\"mt-1.5 line-clamp-2 text-[12.5px] leading-[1.5] text-pretty text-fg-3 transition-colors duration-150 group-data-active/tab:text-fg-2\"\n          >\n            {children}\n          </span>\n        ))}\n    </BaseTabs.Tab>\n  );\n}\n\nexport type FeatureTabsPanelsProps = React.ComponentProps<\"div\">;\n\n/**\n * The frame the visuals crossfade in. Every panel sits in the same grid cell, so\n * the outgoing and incoming visual overlap instead of stacking. Give it a fixed\n * aspect ratio (the default is 4:3) so the section never changes height between features.\n */\nexport function FeatureTabsPanels({ className, ...rest }: FeatureTabsPanelsProps) {\n  return (\n    <div\n      data-slot=\"feature-tabs-panels\"\n      className={cn(\"relative isolate grid aspect-[4/3] min-w-0 overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)}\n      {...rest}\n    />\n  );\n}\n\nexport type FeatureTabsPanelProps = Omit<BaseTabs.Panel.Props, \"className\" | \"value\"> & { value: string; className?: string };\n\nexport function FeatureTabsPanel({ className, ...rest }: FeatureTabsPanelProps) {\n  return (\n    <BaseTabs.Panel\n      className={cn(\n        \"col-start-1 row-start-1 min-h-0 min-w-0 rounded-[inherit] outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-4 focus-visible:outline-fg-3\",\n        // In: fade up from 98.5% with a 4px blur that clears, 420ms. Out: a plain 220ms fade underneath it.\n        \"transition-[opacity,scale,filter] duration-[420ms] ease-out-quart\",\n        \"data-starting-style:scale-[0.985] data-starting-style:opacity-0 data-starting-style:blur-[4px]\",\n        \"data-ending-style:opacity-0 data-ending-style:duration-[220ms] data-ending-style:ease-out\",\n        \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none\",\n        \"group-data-[nav=key]/ft:transition-none\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n\nexport type FeatureTabsPlayToggleProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onClick\"> & {\n  pauseLabel?: string;\n  playLabel?: string;\n};\n\n/**\n * Pauses or resumes the tour for good. Autoplay that runs longer than five seconds\n * needs a control like this; put it near the tabs.\n */\nexport function FeatureTabsPlayToggle({ pauseLabel = \"Pause autoplay\", playLabel = \"Play autoplay\", className, ...rest }: FeatureTabsPlayToggleProps) {\n  const { playing, toggle } = useFeatureTabs();\n  const reduce = usePrefersReducedMotion();\n  return (\n    <button\n      type=\"button\"\n      data-feature-tabs-toggle=\"\"\n      data-state={playing ? \"playing\" : \"stopped\"}\n      aria-label={playing ? pauseLabel : playLabel}\n      onClick={toggle}\n      className={cn(\n        \"relative inline-grid size-7 shrink-0 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)]\",\n        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out-quart hover:border-fg-4 hover:bg-hover hover:text-fg\",\n        \"active:scale-[0.92] active:duration-75\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent] pointer-coarse:before:absolute pointer-coarse:before:-inset-2 pointer-coarse:before:content-['']\",\n        className,\n      )}\n      {...rest}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.svg\n          key={playing ? \"pause\" : \"play\"}\n          width={12}\n          height={12}\n          viewBox=\"0 0 16 16\"\n          fill=\"currentColor\"\n          aria-hidden\n          focusable={false}\n          initial={reduce ? { opacity: 0 } : swap.initial}\n          animate={swap.animate}\n          exit={reduce ? { opacity: 0 } : swap.exit}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n        >\n          {playing ? (\n            <>\n              <rect x=\"4\" y=\"3\" width=\"2.75\" height=\"10\" rx=\"1\" />\n              <rect x=\"9.25\" y=\"3\" width=\"2.75\" height=\"10\" rx=\"1\" />\n            </>\n          ) : (\n            // Nudged right: a triangle looks centered when its mass is, not its box.\n            <path d=\"M5.5 3.6v8.8a.8.8 0 0 0 1.2.7l7-4.4a.8.8 0 0 0 0-1.4l-7-4.4a.8.8 0 0 0-1.2.7z\" transform=\"translate(-0.6 0)\" />\n          )}\n        </motion.svg>\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/feature-tabs"
}
