{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"preview-rail","type":"registry:component","title":"Preview Rail","description":"Codex app-inspired navigation rail with compact ticks that form a hover pyramid and reveal a floating destination preview.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/preview-rail.tsx","type":"registry:component","target":"@components/motion/preview-rail.tsx","content":"\"use client\";\n// beui.dev/components/motion/preview-rail\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useState, type ReactNode } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PreviewRailItem {\n  id: string;\n  label: string;\n  description?: ReactNode;\n  href: string;\n  target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n  rel?: string;\n}\n\nexport interface PreviewRailProps {\n  items: PreviewRailItem[];\n  orientation?: \"vertical\" | \"horizontal\";\n  activeId?: string;\n  defaultActiveId?: string;\n  onActiveChange?: (id: string) => void;\n  renderPreview?: (item: PreviewRailItem) => ReactNode;\n  children?: ReactNode;\n  className?: string;\n  railClassName?: string;\n  previewClassName?: string;\n}\n\nfunction DefaultPreview({ item }: { item: PreviewRailItem }) {\n  return (\n    <div className=\"rounded-2xl border border-border bg-card p-4 shadow-sm\">\n      <p className=\"font-medium text-card-foreground\">{item.label}</p>\n      {item.description ? (\n        <div className=\"mt-1 text-sm leading-6 text-muted-foreground\">\n          {item.description}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport function PreviewRail({\n  items,\n  orientation = \"vertical\",\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  renderPreview,\n  children,\n  className,\n  railClassName,\n  previewClassName,\n}: PreviewRailProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const [internalActiveId, setInternalActiveId] = useState(\n    defaultActiveId ?? items[0]?.id ?? \"\",\n  );\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n\n  const requestedActiveId = activeId ?? internalActiveId;\n  const selectedId = items.some((item) => item.id === requestedActiveId)\n    ? requestedActiveId\n    : (items[0]?.id ?? \"\");\n  const displayedId = hoveredId ?? focusedId ?? \"\";\n  const displayedIndex = items.findIndex((item) => item.id === displayedId);\n  const rowTemplate = items.length\n    ? `repeat(${items.length}, 1.25rem)`\n    : undefined;\n  const isHorizontal = orientation === \"horizontal\";\n\n  const selectItem = (id: string) => {\n    if (activeId === undefined) setInternalActiveId(id);\n    onActiveChange?.(id);\n  };\n\n  return (\n    <motion.div\n      layoutRoot\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) {\n          setFocusedId(null);\n        }\n      }}\n      className={cn(\n        \"isolate relative flex w-full overflow-visible\",\n        isHorizontal\n          ? \"min-h-64 flex-col items-center justify-center\"\n          : \"min-h-80\",\n        className,\n      )}\n    >\n      <nav\n        aria-label=\"Section navigation\"\n        onPointerLeave={() => setHoveredId(null)}\n        style={\n          isHorizontal\n            ? { gridTemplateColumns: rowTemplate }\n            : { gridTemplateRows: rowTemplate }\n        }\n        className={cn(\n          \"relative z-10 grid shrink-0\",\n          isHorizontal\n            ? \"h-12 w-fit max-w-full self-center justify-center\"\n            : \"w-12 content-center\",\n          railClassName,\n        )}\n      >\n        {items.map((item, index) => {\n          const selected = item.id === selectedId;\n          const displayed = item.id === displayedId;\n          const highlighted = displayed;\n          const distance =\n            displayedIndex < 0 ? Number.POSITIVE_INFINITY : Math.abs(index - displayedIndex);\n          const scale = highlighted\n            ? 1\n            : distance === 1\n              ? 0.68\n              : distance === 2\n                ? 0.44\n                : 0.25;\n\n          return (\n            <a\n              key={item.id}\n              href={item.href}\n              target={item.target}\n              rel={\n                item.rel ??\n                (item.target === \"_blank\" ? \"noreferrer noopener\" : undefined)\n              }\n              aria-label={item.label}\n              aria-current={selected ? \"page\" : undefined}\n              onPointerEnter={() => {\n                if (canHover) setHoveredId(item.id);\n              }}\n              onPointerDown={() => setFocusedId(null)}\n              onFocus={(event) => {\n                if (event.currentTarget.matches(\":focus-visible\")) {\n                  setFocusedId(item.id);\n                }\n              }}\n              onClick={() => selectItem(item.id)}\n              className={cn(\n                \"relative flex text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                isHorizontal\n                  ? \"h-12 w-5 items-end justify-center\"\n                  : \"h-5 w-12 items-center\",\n              )}\n            >\n              <motion.span\n                aria-hidden=\"true\"\n                animate={isHorizontal ? { scaleY: scale } : { scaleX: scale }}\n                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                className={cn(\n                  \"block bg-current\",\n                  isHorizontal\n                    ? \"h-12 w-0.5 origin-bottom\"\n                    : \"h-0.5 w-12 origin-left\",\n                  highlighted ? \"text-foreground\" : undefined,\n                )}\n              />\n            </a>\n          );\n        })}\n      </nav>\n\n      <div\n        aria-hidden=\"true\"\n        style={\n          isHorizontal\n            ? { gridTemplateColumns: rowTemplate }\n            : { gridTemplateRows: rowTemplate }\n        }\n        className={cn(\n          \"pointer-events-none absolute z-50 grid\",\n          isHorizontal\n            ? \"top-1/2 left-1/2 h-5 w-fit max-w-full -translate-x-1/2 -translate-y-1/2 justify-center\"\n            : \"inset-y-0 right-4 left-16 content-center\",\n        )}\n      >\n        {items.map((item) => (\n          <div\n            key={item.id}\n            className={cn(\n              \"relative flex h-5 items-center\",\n              isHorizontal ? \"w-5 justify-center\" : undefined,\n            )}\n          >\n            {item.id === displayedId ? (\n              <div\n                className={cn(\n                  isHorizontal\n                    ? \"absolute bottom-12 left-1/2 w-72 -translate-x-1/2\"\n                    : \"w-full max-w-sm\",\n                  previewClassName,\n                )}\n              >\n                <motion.div\n                  layoutId={`preview-rail-card-${uid}`}\n                  transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                >\n                  <AnimatePresence mode=\"wait\" initial={false}>\n                    <motion.div\n                      key={item.id}\n                      initial={\n                        reduce\n                          ? { opacity: 0 }\n                          : { opacity: 0, y: 4, filter: \"blur(6px)\" }\n                      }\n                      animate={\n                        reduce\n                          ? { opacity: 1 }\n                          : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                      }\n                      exit={\n                        reduce\n                          ? { opacity: 0 }\n                          : {\n                              opacity: 0,\n                              y: -2,\n                              filter: \"blur(4px)\",\n                              transition: {\n                                duration: 0.12,\n                                ease: EASE_OUT,\n                              },\n                            }\n                      }\n                      transition={{\n                        duration: reduce ? 0 : 0.18,\n                        ease: EASE_OUT,\n                      }}\n                    >\n                      {renderPreview ? (\n                        renderPreview(item)\n                      ) : (\n                        <DefaultPreview item={item} />\n                      )}\n                    </motion.div>\n                  </AnimatePresence>\n                </motion.div>\n              </div>\n            ) : null}\n          </div>\n        ))}\n      </div>\n\n      {children ? (\n        <div className=\"min-h-0 min-w-0 flex-1\">{children}</div>\n      ) : null}\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}