{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "detent",
  "type": "registry:ui",
  "title": "Detent",
  "description": "Animated tabs with a shared-layout sliding pill indicator that flashes white as it lands and rests as a neutral pill with a subtle top hairline. Full roving-tabindex keyboard support with arrow keys, Home/End, and correct tablist/tab/tabpanel roles.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/navigation/detent/detent.tsx",
      "type": "registry:ui",
      "target": "components/crucible/detent.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DetentItem {\n  /** Unique, URL-safe-ish value identifying this tab. */\n  value: string;\n  /** Tab label. */\n  label: React.ReactNode;\n  /** Panel content shown when this tab is active. */\n  content: React.ReactNode;\n  /** Disable this tab — it's skipped by pointer and keyboard navigation. */\n  disabled?: boolean;\n}\n\nexport interface DetentProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n  /** Tabs to render, in order. */\n  items: DetentItem[];\n  /** Controlled active tab value. */\n  value?: string;\n  /** Initial active tab value when uncontrolled. @default first enabled item */\n  defaultValue?: string;\n  /** Fires whenever the active tab changes, controlled or not. */\n  onValueChange?: (value: string) => void;\n  /** Tab list orientation. @default \"horizontal\" */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Accessible name for the tablist (e.g. \"Filter by team\"). Props on the root don't reach the tablist itself. */\n  label?: string;\n  /** Extra classes for the tablist track — e.g. `max-w-full overflow-x-auto` to let many tabs scroll. */\n  listClassName?: string;\n  /** Extra classes for every tab panel (replaces nothing; merged after the focus-ring defaults). */\n  panelClassName?: string;\n}\n\n/**\n * Detent — animated tabs with a shared-layout sliding pill indicator that\n * flashes white as it lands and rests as a neutral pill with a subtle top\n * hairline. Full roving-tabindex keyboard support: Arrow keys move and\n * activate, Home/End jump to the ends, disabled tabs are skipped. Indicator\n * motion collapses to an instant snap under `prefers-reduced-motion`.\n */\nexport function Detent({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  orientation = \"horizontal\",\n  label,\n  listClassName,\n  panelClassName,\n  className,\n  ...props\n}: DetentProps) {\n  const reducedMotion = useReducedMotion();\n  const baseId = React.useId();\n  const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);\n  // Bumped each time the sliding pill finishes settling into a tab; retriggers\n  // the white bloom pulse — the detent \"landing\". Stays 0 on first paint so the\n  // component mounts at rest, without a flash.\n  const [landCount, setLandCount] = React.useState(0);\n\n  const firstEnabled = items.find((item) => !item.disabled)?.value ?? items[0]?.value;\n  const [internalValue, setInternalValue] = React.useState(defaultValue ?? firstEnabled);\n  const activeValue = value ?? internalValue;\n\n  const selectTab = React.useCallback(\n    (next: string) => {\n      if (value === undefined) setInternalValue(next);\n      onValueChange?.(next);\n    },\n    [value, onValueChange]\n  );\n\n  const nextEnabledIndex = React.useCallback(\n    (from: number, direction: 1 | -1) => {\n      const count = items.length;\n      let index = from;\n      for (let step = 0; step < count; step++) {\n        index = (index + direction + count) % count;\n        if (!items[index]?.disabled) return index;\n      }\n      return from;\n    },\n    [items]\n  );\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      const currentIndex = items.findIndex((item) => item.value === activeValue);\n      const forwardKey = orientation === \"vertical\" ? \"ArrowDown\" : \"ArrowRight\";\n      const backwardKey = orientation === \"vertical\" ? \"ArrowUp\" : \"ArrowLeft\";\n\n      let nextIndex: number | null = null;\n      if (event.key === forwardKey) nextIndex = nextEnabledIndex(currentIndex, 1);\n      else if (event.key === backwardKey) nextIndex = nextEnabledIndex(currentIndex, -1);\n      else if (event.key === \"Home\") nextIndex = nextEnabledIndex(-1, 1);\n      else if (event.key === \"End\") nextIndex = nextEnabledIndex(0, -1);\n\n      if (nextIndex === null) return;\n      event.preventDefault();\n      const nextItem = items[nextIndex];\n      if (!nextItem) return;\n      selectTab(nextItem.value);\n      tabRefs.current[nextIndex]?.focus();\n    },\n    [items, activeValue, orientation, nextEnabledIndex, selectTab]\n  );\n\n  return (\n    <div data-crucible=\"detent\" className={cn(\"flex flex-col gap-4\", className)} {...props}>\n      <style>{`\n        @keyframes crucible-detent-sheen {\n          0% { background-position: 150% 0; }\n          22% { background-position: -50% 0; }\n          100% { background-position: -50% 0; }\n        }\n        [data-crucible=\"detent\"] .detent-sheen {\n          position: absolute;\n          inset: 0;\n          border-radius: inherit;\n          background-image: linear-gradient(105deg, transparent 42%, rgba(255,255,255,0.16) 50%, transparent 58%);\n          background-size: 220% 100%;\n          background-repeat: no-repeat;\n          background-position: -50% 0;\n          animation: crucible-detent-sheen 6s ease-in-out infinite;\n        }\n        @media (prefers-reduced-motion: reduce) {\n          [data-crucible=\"detent\"] .detent-sheen { animation: none; }\n        }\n      `}</style>\n      <div\n        role=\"tablist\"\n        aria-label={label}\n        aria-orientation={orientation}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"relative inline-flex items-center gap-1 self-start rounded-full border border-white/10 p-1\",\n          orientation === \"vertical\" && \"flex-col items-stretch self-stretch\",\n          listClassName\n        )}\n        style={{\n          // Machined groove: the track is a shaded well the pills sit down into.\n          backgroundImage: \"linear-gradient(180deg, #171719 0%, #0f0f11 100%)\",\n          boxShadow:\n            \"inset 0 1px 2px rgba(0,0,0,0.65), inset 0 -1px 0 rgba(255,255,255,0.03)\",\n        }}\n      >\n        {items.map((item, index) => {\n          const selected = item.value === activeValue;\n          return (\n            <button\n              key={item.value}\n              ref={(el) => {\n                tabRefs.current[index] = el;\n              }}\n              role=\"tab\"\n              type=\"button\"\n              id={`${baseId}-tab-${item.value}`}\n              aria-selected={selected}\n              aria-controls={`${baseId}-panel-${item.value}`}\n              tabIndex={selected ? 0 : -1}\n              disabled={item.disabled}\n              onClick={() => !item.disabled && selectTab(item.value)}\n              className={cn(\n                \"relative z-10 rounded-full px-4 py-1.5 text-sm font-medium whitespace-nowrap text-white/55 transition-colors duration-200\",\n                \"hover:text-white/85\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n                \"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-white/55\",\n                selected && \"text-white\"\n              )}\n            >\n              {selected && (\n                <motion.span\n                  layoutId={`${baseId}-detent-indicator`}\n                  aria-hidden\n                  className=\"absolute inset-0 -z-10 rounded-full\"\n                  style={{\n                    // Machined pill: lit from above (lighter top → darker base),\n                    // a bright top hairline, a soft drop shadow so it rides ABOVE\n                    // the groove, a hairline rim, and a faint neutral underglow.\n                    backgroundImage:\n                      \"linear-gradient(180deg, #3b3b3f 0%, #2a2a2d 55%, #232326 100%)\",\n                    boxShadow: [\n                      \"inset 0 1px 0 rgba(255,255,255,0.22)\",\n                      \"inset 0 -1px 0 rgba(0,0,0,0.45)\",\n                      \"0 1px 1px rgba(0,0,0,0.5)\",\n                      \"0 2px 6px -1px rgba(0,0,0,0.55)\",\n                      \"0 0 0 1px rgba(255,255,255,0.08)\",\n                      \"0 0 18px -6px rgba(255,255,255,0.2)\",\n                    ].join(\", \"),\n                  }}\n                  transition={\n                    reducedMotion\n                      ? { duration: 0 }\n                      : { type: \"spring\", stiffness: 480, damping: 26, mass: 0.9 }\n                  }\n                  onLayoutAnimationComplete={() => {\n                    if (!reducedMotion) setLandCount((c) => c + 1);\n                  }}\n                >\n                  {/* Idle life: a slow, faint sheen glints across the resting\n                      pill every few seconds. Pure CSS; frozen under reduced motion. */}\n                  {!reducedMotion && <span className=\"detent-sheen\" />}\n                  {/* Detent landing: a white brightness + bloom pulse fired the\n                      instant the pill settles. Gated on landCount>0 so mount is calm. */}\n                  <AnimatePresence>\n                    {landCount > 0 && (\n                      <motion.span\n                        key={landCount}\n                        className=\"pointer-events-none absolute inset-0 rounded-full bg-white\"\n                        style={{ boxShadow: \"0 0 22px 2px rgba(255,255,255,0.45)\" }}\n                        initial={{ opacity: 0.5 }}\n                        animate={{ opacity: 0 }}\n                        exit={{ opacity: 0 }}\n                        transition={{ duration: 0.42, ease: \"easeOut\" }}\n                      />\n                    )}\n                  </AnimatePresence>\n                </motion.span>\n              )}\n              <span className=\"relative\">{item.label}</span>\n            </button>\n          );\n        })}\n      </div>\n      {items.map((item) => (\n        <div\n          key={item.value}\n          role=\"tabpanel\"\n          id={`${baseId}-panel-${item.value}`}\n          aria-labelledby={`${baseId}-tab-${item.value}`}\n          hidden={item.value !== activeValue}\n          tabIndex={0}\n          className={cn(\n            \"rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 focus-visible:ring-offset-4 focus-visible:ring-offset-neutral-950\",\n            panelClassName\n          )}\n        >\n          {item.content}\n        </div>\n      ))}\n    </div>\n  );\n}\n"
    }
  ]
}