{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "preview-rail", "type": "registry:component", "title": "Preview Rail", "description": "A link rail that previews each destination on hover.", "dependencies": [ "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/utils" ], "files": [ { "path": "components/motion/preview-rail.tsx", "type": "registry:component", "target": "components/motion/preview-rail.tsx", "content": "import { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n type MouseEvent,\n type PointerEvent,\n type ReactNode,\n useCallback,\n useId,\n useRef,\n useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PreviewRailItem {\n id: string;\n label: string;\n ariaLabel?: 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 label?: string;\n orientation?: \"vertical\" | \"horizontal\";\n activeId?: string;\n defaultActiveId?: string;\n onActiveChange?: (id: string) => void;\n onItemSelect?: (item: PreviewRailItem) => void;\n renderPreview?: (item: PreviewRailItem) => ReactNode;\n showPreview?: boolean;\n previewSide?: \"before\" | \"after\";\n highlightActive?: boolean;\n itemSize?: number;\n children?: ReactNode;\n className?: string;\n railClassName?: string;\n previewContainerClassName?: string;\n previewClassName?: string;\n}\n\nfunction DefaultPreview({ item }: { item: PreviewRailItem }) {\n return (\n \n \n {item.label}\n

\n {item.description ? (\n \n {item.description}\n \n ) : null}\n \n );\n}\n\nexport function PreviewRail({\n items,\n label = \"Section navigation\",\n orientation = \"vertical\",\n activeId,\n defaultActiveId,\n onActiveChange,\n onItemSelect,\n renderPreview,\n showPreview = true,\n previewSide = \"after\",\n highlightActive = false,\n itemSize = 24,\n children,\n className,\n railClassName,\n previewContainerClassName,\n previewClassName,\n}: PreviewRailProps) {\n const uid = useId();\n const reduce = useReducedMotion();\n const rootRef = useRef(null);\n const [internalActiveId, setInternalActiveId] = useState(\n defaultActiveId ?? items[0]?.id ?? \"\",\n );\n const [hoveredId, setHoveredId] = useState(null);\n // A finger cannot hover, so a tap lights the tick instead. Kept apart from\n // the hovered one: they end in different ways, and a stray mouse move must\n // not clear a tick the keyboard or a tap chose.\n const [pinnedId, setPinnedId] = useState(null);\n const [focusedId, setFocusedId] = useState(null);\n // A click carries no pointerType, so the pointerdown before it is what says\n // whether the activation was a tap. Keyboard activation has none at all.\n const tap = useTapGesture();\n const hover = useHoverGesture();\n\n const clearPinned = useCallback(() => setPinnedId(null), []);\n\n // The next tap outside the rail stands in for the pointer leaving it. The\n // card is a preview, so that tap passes through to whatever it landed on.\n useDismiss(pinnedId !== null, clearPinned, rootRef);\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 ?? pinnedId ?? focusedId ?? \"\";\n const highlightedId = displayedId || (highlightActive ? selectedId : \"\");\n const displayedIndex = items.findIndex((item) => item.id === highlightedId);\n const rowTemplate = items.length\n ? `repeat(${items.length}, ${itemSize}px)`\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 {\n // Both tick sources leave with the focus: a tap does not always land\n // focus, but when it does, tabbing away must not strand the card.\n if (!event.currentTarget.contains(event.relatedTarget)) {\n setFocusedId(null);\n setPinnedId(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 {\n // A touch pointer leaves on lift, which would clear the tick the tap\n // just chosethat one is cleared by the outside tap instead.\n if (hover.leave(event)) setHoveredId(null);\n }}\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 highlighted = item.id === highlightedId;\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 const itemContent = (\n <>\n \n \n );\n\n const sharedClassName = 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-6 items-end justify-center\"\n : \"h-6 w-12 items-center\",\n );\n const sharedStyle = isHorizontal\n ? { width: itemSize }\n : { height: itemSize };\n const handlePointerEnter = (event: PointerEvent) => {\n if (hover.enter(event)) setHoveredId(item.id);\n };\n const handlePointerDown = (event: PointerEvent) => {\n tap.start(event, pinnedId === item.id);\n setFocusedId(null);\n };\n // A gesture the platform takes away sends no click, and a key press\n // starts an activation that never had a pointer behind it: either\n // one leaves a record the next click would read as a tap of its own.\n const dropGesture = () => tap.drop();\n const handleFocus = (currentTarget: HTMLElement) => {\n if (currentTarget.matches(\":focus-visible\")) {\n setFocusedId(item.id);\n }\n };\n const handleSelect = (event: MouseEvent) => {\n const gesture = tap.take();\n const tapped =\n gesture !== null && gesture.pointerType !== \"mouse\";\n\n if (tapped) {\n // A link would otherwise show its preview and leave the page in\n // the same tap, so the card is never read: the first tap lights\n // the tick, the second follows the link.\n if (item.href && !gesture.state) {\n event.preventDefault();\n setPinnedId(item.id);\n return;\n }\n setPinnedId(item.id);\n }\n\n selectItem(item.id);\n onItemSelect?.(item);\n };\n\n return item.href ? (\n handleFocus(event.currentTarget)}\n onClick={handleSelect}\n style={sharedStyle}\n className={sharedClassName}\n >\n {itemContent}\n \n ) : (\n handleFocus(event.currentTarget)}\n onClick={handleSelect}\n style={sharedStyle}\n className={sharedClassName}\n >\n {itemContent}\n \n );\n })}\n \n\n {showPreview ? (\n \n {items.map((item) => (\n \n {item.id === displayedId ? (\n \n \n \n \n {renderPreview ? (\n renderPreview(item)\n ) : (\n \n )}\n \n \n \n \n ) : null}\n \n ))}\n \n ) : null}\n\n {children ? (\n
{children}
\n ) : null}\n \n );\n}\n" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/preview-rail" } }