{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "section-rail",
  "type": "registry:ui",
  "title": "Section Rail",
  "description": "A compact navigation rail that tracks the reader's position through a long page, revealing labels and editorial context cards on hover and focus.",
  "files": [
    {
      "path": "registry/sonaui/section-rail/section-rail.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\nimport {\n  type CSSProperties,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface SectionRailThumbnail {\n  /** Image source for the context card. */\n  src: string;\n  /** Alternative text. The card is supplementary, so this may be decorative. */\n  alt: string;\n}\n\nexport interface SectionRailItem {\n  /** Unique key. In scroll mode this must match the target element's DOM id. */\n  id: string;\n  /** Short destination name. This is the accessible name of the item. */\n  label: string;\n  /** Optional one-to-two-line summary shown in the fine-pointer context card. */\n  description?: string;\n  /** Optional image shown in the fine-pointer context card. */\n  thumbnail?: SectionRailThumbnail;\n  /** Renders the item as present but non-navigable. */\n  disabled?: boolean;\n}\n\nexport interface SectionRailProps {\n  /** The sections represented by the rail, in document order. */\n  items: SectionRailItem[];\n  /**\n   * `\"scroll\"` resolves the active item from the page; `\"controlled\"` leaves it\n   * to `activeId` / `onActiveChange`.\n   * @default \"scroll\"\n   */\n  mode?: \"scroll\" | \"controlled\";\n  /** Active item id for controlled usage. @default undefined */\n  activeId?: string;\n  /** Initial active item id for uncontrolled usage. @default undefined */\n  defaultActiveId?: string;\n  /** Called whenever the active item changes. @default undefined */\n  onActiveChange?: (id: string) => void;\n  /**\n   * When labels sit next to the rail on fine pointers. Defaults to `\"hidden\"`\n   * because the context card already names the section, so an inline label\n   * would only appear underneath it. Coarse pointers, which never get a card,\n   * always show labels.\n   * @default \"hidden\"\n   */\n  showLabels?: \"hidden\" | \"always\" | \"active\" | \"hover\";\n  /**\n   * How the active item is marked. `\"progress\"` fills the active line from the\n   * section's own scroll position.\n   * @default \"progress\"\n   */\n  activeIndicator?: \"dot\" | \"fill\" | \"progress\";\n  /** Direction the rail runs in. @default \"vertical\" */\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Which edge the rail is anchored to, which flips label and card placement. @default \"right\" */\n  side?: \"left\" | \"right\";\n  /** Pixels of fixed-header compensation used when scrolling to a section. @default 96 */\n  scrollOffset?: number;\n  /** Resolves a section element when ids are not real DOM ids. @default undefined */\n  getSectionElement?: (id: string) => HTMLElement | null;\n  /**\n   * Scrolling element the sections live in. Defaults to the page itself.\n   * @default undefined\n   */\n  scrollRoot?: React.RefObject<HTMLElement | null>;\n  /**\n   * When the context card appears. Cards never appear on coarse pointers.\n   * @default \"hover-and-focus\"\n   */\n  showPreview?: \"never\" | \"hover\" | \"focus\" | \"hover-and-focus\";\n  /** Accessible name for the rail's landmark. @default \"Section navigation\" */\n  ariaLabel?: string;\n  /** Additional classes for the rail root. @default undefined */\n  className?: string;\n  /** Additional classes for each rail line. @default undefined */\n  lineClassName?: string;\n  /** Additional classes for the item labels. @default undefined */\n  labelClassName?: string;\n  /** Additional classes for the context card surface. @default undefined */\n  previewClassName?: string;\n}\n\nconst tokenStyle = {\n  \"--section-rail-track\":\n    \"color-mix(in oklab, var(--foreground) 22%, transparent)\",\n  \"--section-rail-active\": \"var(--foreground)\",\n  \"--section-rail-hover\":\n    \"color-mix(in oklab, var(--foreground) 55%, transparent)\",\n  \"--section-rail-label\": \"var(--muted-foreground)\",\n  \"--section-rail-focus-ring\": \"var(--ring)\",\n  \"--section-rail-progress\": \"var(--primary)\",\n  \"--section-rail-preview-surface\":\n    \"color-mix(in oklab, var(--popover) 78%, transparent)\",\n  \"--section-rail-preview-border\":\n    \"color-mix(in oklab, var(--foreground) 10%, transparent)\",\n  \"--section-rail-preview-shadow\":\n    \"0 12px 32px -12px color-mix(in oklab, var(--foreground) 30%, transparent)\",\n  \"--section-rail-preview-thumbnail\":\n    \"color-mix(in oklab, var(--foreground) 6%, transparent)\",\n} as CSSProperties;\n\n/**\n * The active line is the longest line the rail can ever draw, so it doubles as\n * the rail's fixed track width. Every line is drawn inside a slot of exactly\n * this size, which keeps the rail's footprint constant: growing a line on hover\n * never reflows the page, and labels and context cards always anchor to the\n * same edge instead of sliding with whichever line happens to be widest.\n */\nconst LINE_TRACK = 30;\nconst LINE_REST = 18;\n/** Capped so an emphasised inactive line (18 + 10) stays under the track. */\nconst LINE_EMPHASIS = 10;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction useMediaQuery(query: string) {\n  const [matches, setMatches] = useState(false);\n\n  useEffect(() => {\n    const list = window.matchMedia(query);\n    const update = () => setMatches(list.matches);\n    update();\n    list.addEventListener(\"change\", update);\n    return () => list.removeEventListener(\"change\", update);\n  }, [query]);\n\n  return matches;\n}\n\nexport default function SectionRail({\n  items,\n  mode = \"scroll\",\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  showLabels = \"hidden\",\n  activeIndicator = \"progress\",\n  orientation = \"vertical\",\n  side = \"right\",\n  scrollOffset = 96,\n  getSectionElement,\n  scrollRoot,\n  showPreview = \"hover-and-focus\",\n  ariaLabel = \"Section navigation\",\n  className,\n  lineClassName,\n  labelClassName,\n  previewClassName,\n}: SectionRailProps) {\n  const descriptionIdPrefix = useId();\n  const coarsePointer = useMediaQuery(\"(pointer: coarse)\");\n  const reducedMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n  const isVertical = orientation === \"vertical\";\n\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 resolvedActiveId = activeId ?? internalActiveId;\n  const activeIndex = items.findIndex((item) => item.id === resolvedActiveId);\n  // One shared value for the whole rail: only the active line reads it, so\n  // scrolling never re-renders React or animates every item.\n  const progress = useMotionValue(0);\n\n  // Latest-value refs so the observer below depends only on things that\n  // genuinely change what it watches. Callers routinely pass inline `items`\n  // arrays and inline handlers; without this the observer would be torn down\n  // and rebuilt on every render — and again on every active change — which\n  // makes the active item flicker mid-scroll and the dot's travel erratic.\n  const itemsRef = useRef(items);\n  itemsRef.current = items;\n  const getSectionElementRef = useRef(getSectionElement);\n  getSectionElementRef.current = getSectionElement;\n  const onActiveChangeRef = useRef(onActiveChange);\n  onActiveChangeRef.current = onActiveChange;\n  const isControlledRef = useRef(activeId !== undefined);\n  isControlledRef.current = activeId !== undefined;\n  const activeIdRef = useRef(resolvedActiveId);\n  activeIdRef.current = resolvedActiveId;\n\n  const itemsKey = items.map((item) => item.id).join(\"\u0000\");\n\n  const resolveElement = useCallback(\n    (id: string) =>\n      getSectionElementRef.current\n        ? getSectionElementRef.current(id)\n        : document.getElementById(id),\n    [],\n  );\n\n  const commitActive = useCallback((id: string) => {\n    if (!isControlledRef.current) setInternalActiveId(id);\n    onActiveChangeRef.current?.(id);\n  }, []);\n\n  // The dot is one element for the whole rail, positioned from the active\n  // item's measured slot. A per-item dot matched with `layoutId` had to hand\n  // off between mounting and unmounting nodes on every active change, so its\n  // travel time depended on how much else re-rendered that frame; this is a\n  // single value animating to a measured target, which is always the same.\n  const listRef = useRef<HTMLOListElement>(null);\n  const dotSlots = useRef(new Map<string, HTMLElement>());\n  const dotX = useMotionValue(0);\n  const dotY = useMotionValue(0);\n  const dotPlaced = useRef(false);\n  const dotAnimations = useRef<ReturnType<typeof animate>[]>([]);\n\n  useIsomorphicLayoutEffect(() => {\n    if (activeIndicator !== \"dot\") return;\n\n    const list = listRef.current;\n    const slot = dotSlots.current.get(resolvedActiveId ?? \"\");\n    if (!list || !slot) return;\n\n    const place = (animated: boolean) => {\n      const listRect = list.getBoundingClientRect();\n      const slotRect = slot.getBoundingClientRect();\n      const targetX = slotRect.left - listRect.left;\n      const targetY = slotRect.top - listRect.top;\n\n      for (const animation of dotAnimations.current) animation.stop();\n      dotAnimations.current = [];\n\n      // The first placement and any resize are corrections, not transitions.\n      if (!animated || !dotPlaced.current || reducedMotion) {\n        dotPlaced.current = true;\n        dotX.set(targetX);\n        dotY.set(targetY);\n        return;\n      }\n\n      // A fixed duration, not a spring. A spring's settle time grows with the\n      // distance it has to cover, so the dot visibly dragged when a fast scroll\n      // skipped several sections at once and snapped when it moved one. The\n      // travel should read the same no matter how far the active item jumped.\n      const options = {\n        duration: 0.26,\n        ease: [0.22, 1, 0.36, 1],\n      } as const;\n      dotAnimations.current = [\n        animate(dotX, targetX, options),\n        animate(dotY, targetY, options),\n      ];\n    };\n\n    place(true);\n\n    const resizeObserver = new ResizeObserver(() => place(false));\n    resizeObserver.observe(list);\n\n    return () => {\n      resizeObserver.disconnect();\n      for (const animation of dotAnimations.current) animation.stop();\n      dotAnimations.current = [];\n    };\n  }, [activeIndicator, resolvedActiveId, reducedMotion, dotX, dotY, itemsKey]);\n\n  // Active section resolution. IntersectionObserver decides *which* section is\n  // current; a rAF-throttled scroll read decides how far through it we are.\n  // `items` is read through a ref so inline arrays don't rebuild the observer;\n  // `itemsKey` is what actually signals the observed set of sections changed.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: intentional, see above\n  useEffect(() => {\n    const items = itemsRef.current;\n    if (mode !== \"scroll\" || items.length === 0) return;\n\n    const elements = items\n      .map((item) => ({ id: item.id, element: resolveElement(item.id) }))\n      .filter(\n        (entry): entry is { id: string; element: HTMLElement } =>\n          entry.element !== null,\n      );\n    if (elements.length === 0) return;\n\n    const root = scrollRoot?.current ?? null;\n    const visible = new Set<string>();\n    let currentId = activeIdRef.current;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          const id =\n            entry.target.id ||\n            elements.find((e) => e.element === entry.target)?.id;\n          if (!id) continue;\n          if (entry.isIntersecting) visible.add(id);\n          else visible.delete(id);\n        }\n\n        const next = items.find((item) => visible.has(item.id))?.id;\n        if (next && next !== currentId) {\n          currentId = next;\n          commitActive(next);\n        }\n      },\n      { root, rootMargin: `-${scrollOffset}px 0px -55% 0px`, threshold: 0 },\n    );\n\n    for (const { element } of elements) observer.observe(element);\n\n    const readMetrics = () => {\n      if (root) {\n        return {\n          scrollTop: root.scrollTop,\n          maxScroll: Math.max(root.scrollHeight - root.clientHeight, 0),\n          viewportTop: root.getBoundingClientRect().top,\n        };\n      }\n      const doc = document.documentElement;\n      return {\n        scrollTop: window.scrollY,\n        maxScroll: Math.max(doc.scrollHeight - window.innerHeight, 0),\n        viewportTop: 0,\n      };\n    };\n\n    let frame = 0;\n    const readProgress = () => {\n      frame = 0;\n      const { scrollTop, maxScroll, viewportTop } = readMetrics();\n\n      // The bottom of the page is a hard stop: the last section can never\n      // scroll its own end past the offset line, so without this its fill\n      // would top out short of 100%. Treat reaching the end as arriving.\n      const atEnd = scrollTop >= maxScroll - 1;\n      if (atEnd) {\n        const last = elements[elements.length - 1]?.id;\n        if (last && last !== currentId) {\n          currentId = last;\n          commitActive(last);\n        }\n      }\n\n      const element = resolveElement(currentId ?? \"\");\n      if (!element) return;\n\n      const rect = element.getBoundingClientRect();\n      const sectionTop = rect.top - viewportTop + scrollTop;\n      const start = sectionTop - scrollOffset;\n      // Clamp the end of the section's travel to the furthest the scroller can\n      // actually go, so every section — including the last — completes.\n      const end = Math.min(start + rect.height, maxScroll);\n      const span = end - start;\n\n      progress.set(span <= 0 ? 1 : clamp((scrollTop - start) / span, 0, 1));\n    };\n    const onScroll = () => {\n      if (frame) return;\n      frame = requestAnimationFrame(readProgress);\n    };\n\n    const scroller: HTMLElement | Window = root ?? window;\n    readProgress();\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n\n    return () => {\n      observer.disconnect();\n      scroller.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      if (frame) cancelAnimationFrame(frame);\n    };\n  }, [\n    scrollRoot,\n    commitActive,\n    itemsKey,\n    mode,\n    progress,\n    resolveElement,\n    scrollOffset,\n  ]);\n\n  // In controlled mode there is no scroll position to read, so the active line\n  // is simply full.\n  useEffect(() => {\n    if (mode === \"controlled\") progress.set(1);\n  }, [mode, progress]);\n\n  const navigate = (\n    event: React.MouseEvent<HTMLElement>,\n    item: SectionRailItem,\n  ) => {\n    if (item.disabled) {\n      event.preventDefault();\n      return;\n    }\n\n    commitActive(item.id);\n    if (mode !== \"scroll\") return;\n\n    const element = resolveElement(item.id);\n    if (!element) return;\n\n    event.preventDefault();\n    // `detail === 0` means the click came from Enter/Space, which must land\n    // instantly rather than animating.\n    const keyboardActivated = event.detail === 0;\n    const behavior = keyboardActivated || reducedMotion ? \"auto\" : \"smooth\";\n    const root = scrollRoot?.current ?? null;\n\n    if (root) {\n      const top =\n        root.scrollTop +\n        (element.getBoundingClientRect().top -\n          root.getBoundingClientRect().top) -\n        scrollOffset;\n      root.scrollTo({ top, behavior });\n    } else {\n      const top =\n        element.getBoundingClientRect().top + window.scrollY - scrollOffset;\n      window.scrollTo({ top, behavior });\n    }\n\n    // Only own the page URL when the rail actually navigates the page.\n    if (element.id && !root) {\n      window.history.replaceState(null, \"\", `#${element.id}`);\n    }\n    element.setAttribute(\"tabindex\", \"-1\");\n    element.focus({ preventScroll: true });\n  };\n\n  const previewOnHover =\n    showPreview === \"hover\" || showPreview === \"hover-and-focus\";\n  const previewOnFocus =\n    showPreview === \"focus\" || showPreview === \"hover-and-focus\";\n\n  return (\n    <nav\n      aria-label={ariaLabel}\n      data-orientation={orientation}\n      data-side={side}\n      className={cn(\n        \"group/section-rail relative\",\n        isVertical ? \"w-fit\" : \"h-fit\",\n        className,\n      )}\n      style={tokenStyle}\n      onPointerLeave={() => setHoveredId(null)}\n    >\n      <ol\n        ref={listRef}\n        className={cn(\n          \"relative flex list-none\",\n          isVertical\n            ? cn(\"flex-col\", side === \"right\" ? \"items-end\" : \"items-start\")\n            : \"flex-row items-end\",\n        )}\n      >\n        {activeIndicator === \"dot\" && (\n          <motion.span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-0 left-0 z-10 size-1.5 rounded-full bg-(--section-rail-progress)\"\n            style={{ x: dotX, y: dotY }}\n          />\n        )}\n        {items.map((item, index) => {\n          const isActive = index === activeIndex;\n          const isHovered = hoveredId === item.id;\n          const isFocused = focusedId === item.id;\n          const distance =\n            hoveredId === null\n              ? Number.POSITIVE_INFINITY\n              : Math.abs(index - items.findIndex((i) => i.id === hoveredId));\n\n          const previewVisible =\n            !coarsePointer &&\n            showPreview !== \"never\" &&\n            Boolean(item.description || item.thumbnail) &&\n            ((previewOnHover && isHovered) || (previewOnFocus && isFocused));\n\n          // A visible card already names the section, so the inline label steps\n          // aside rather than rendering behind it. Focus still reveals the\n          // label whenever no card is showing.\n          const labelVisible =\n            coarsePointer ||\n            showLabels === \"always\" ||\n            (showLabels === \"active\" && isActive) ||\n            ((showLabels === \"hover\" || isFocused) &&\n              (isHovered || isFocused) &&\n              !previewVisible);\n\n          // Neighbours of the hovered item grow a little too, so the rail is\n          // easier to aim at without any of them becoming a separate target.\n          const emphasis =\n            distance === 0\n              ? 1\n              : distance === 1\n                ? 0.45\n                : distance === 2\n                  ? 0.15\n                  : 0;\n          const lineLength = isActive\n            ? LINE_TRACK\n            : LINE_REST + emphasis * LINE_EMPHASIS;\n\n          const descriptionId = item.description\n            ? `${descriptionIdPrefix}-${item.id}`\n            : undefined;\n\n          const content = (\n            <>\n              {/* Fixed-size slot. The line animates inside it, so the rail\n                  never changes width and nothing around it reflows. */}\n              <span\n                aria-hidden=\"true\"\n                className=\"relative block shrink-0\"\n                style={\n                  isVertical\n                    ? { width: LINE_TRACK, height: 2 }\n                    : { height: LINE_TRACK, width: 2 }\n                }\n              >\n                <motion.span\n                  className={cn(\n                    \"absolute overflow-hidden rounded-full\",\n                    isVertical\n                      ? cn(\"inset-y-0\", side === \"right\" ? \"end-0\" : \"start-0\")\n                      : \"inset-x-0 bottom-0\",\n                    item.disabled && \"opacity-40\",\n                    lineClassName,\n                  )}\n                  initial={false}\n                  animate={{\n                    [isVertical ? \"width\" : \"height\"]: lineLength,\n                    backgroundColor: isActive\n                      ? activeIndicator === \"progress\"\n                        ? \"var(--section-rail-track)\"\n                        : \"var(--section-rail-active)\"\n                      : distance <= 1\n                        ? \"var(--section-rail-hover)\"\n                        : \"var(--section-rail-track)\",\n                  }}\n                  transition={\n                    reducedMotion\n                      ? { duration: 0 }\n                      : {\n                          type: \"spring\",\n                          stiffness: 520,\n                          damping: 42,\n                          mass: 0.7,\n                        }\n                  }\n                  style={\n                    isVertical ? { width: lineLength } : { height: lineLength }\n                  }\n                >\n                  {isActive && activeIndicator === \"progress\" && (\n                    <motion.span\n                      className={cn(\n                        \"absolute inset-0 rounded-full bg-(--section-rail-progress)\",\n                        // Fill grows away from the edge the rail is anchored\n                        // to, so it always reads as advancing outward-in.\n                        isVertical\n                          ? side === \"right\"\n                            ? \"origin-right\"\n                            : \"origin-left\"\n                          : \"origin-top\",\n                      )}\n                      style={\n                        isVertical ? { scaleX: progress } : { scaleY: progress }\n                      }\n                    />\n                  )}\n                </motion.span>\n              </span>\n\n              {/* Reserved for every item, not just the active one, so the\n                  item's width — and with it the label and card anchor —\n                  doesn't change as the dot moves down the rail. The single\n                  rail dot is measured against these slots. */}\n              {activeIndicator === \"dot\" && (\n                <span\n                  ref={(node) => {\n                    if (node) dotSlots.current.set(item.id, node);\n                    else dotSlots.current.delete(item.id);\n                  }}\n                  aria-hidden=\"true\"\n                  className=\"block size-1.5 shrink-0\"\n                />\n              )}\n\n              <motion.span\n                className={cn(\n                  \"whitespace-nowrap font-medium text-(--section-rail-label) text-xs\",\n                  isActive && \"text-(--section-rail-active)\",\n                  coarsePointer\n                    ? \"static\"\n                    : cn(\n                        \"pointer-events-none absolute\",\n                        isVertical\n                          ? side === \"right\"\n                            ? \"end-full me-3\"\n                            : \"start-full ms-3\"\n                          : \"bottom-full mb-3\",\n                      ),\n                  labelClassName,\n                )}\n                initial={false}\n                animate={{\n                  opacity: labelVisible ? 1 : 0,\n                  filter: labelVisible ? \"blur(0px)\" : \"blur(3px)\",\n                  [isVertical ? \"x\" : \"y\"]: labelVisible\n                    ? 0\n                    : isVertical\n                      ? side === \"right\"\n                        ? 6\n                        : -6\n                      : 6,\n                }}\n                transition={\n                  reducedMotion\n                    ? { duration: 0 }\n                    : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }\n                }\n              >\n                {item.label}\n              </motion.span>\n\n              {descriptionId && (\n                <span id={descriptionId} className=\"sr-only\">\n                  {item.description}\n                </span>\n              )}\n            </>\n          );\n\n          const interactiveProps = {\n            \"aria-current\": isActive\n              ? ((mode === \"scroll\" ? \"location\" : \"true\") as\n                  | \"location\"\n                  | \"true\")\n              : undefined,\n            \"aria-describedby\": descriptionId,\n            \"aria-disabled\": item.disabled || undefined,\n            \"data-active\": isActive || undefined,\n            className: cn(\n              // The visible line is 2px; the hit area is not.\n              \"relative flex cursor-pointer items-center gap-2 rounded-sm outline-none\",\n              \"focus-visible:ring-2 focus-visible:ring-(--section-rail-focus-ring) focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n              isVertical ? \"px-2 py-1.5\" : \"flex-col px-1.5 py-2\",\n              // The line always sits flush against the edge the rail is\n              // anchored to; the dot slot tucks in behind it. Reversing for\n              // `side=\"right\"` is what keeps every line right-aligned instead\n              // of the dot slot pushing them inward.\n              side === \"right\" && isVertical && \"flex-row-reverse\",\n              item.disabled && \"cursor-not-allowed\",\n            ),\n            onClick: (event: React.MouseEvent<HTMLElement>) =>\n              navigate(event, item),\n            onPointerEnter: () => setHoveredId(item.id),\n            onFocus: () => setFocusedId(item.id),\n            onBlur: () =>\n              setFocusedId((current) => (current === item.id ? null : current)),\n          };\n\n          return (\n            <li key={item.id} className=\"relative flex\">\n              {mode === \"scroll\" ? (\n                <a href={`#${item.id}`} {...interactiveProps}>\n                  {content}\n                </a>\n              ) : (\n                <button\n                  type=\"button\"\n                  disabled={item.disabled}\n                  {...interactiveProps}\n                >\n                  {content}\n                </button>\n              )}\n\n              <AnimatePresence>\n                {previewVisible && (\n                  <SectionRailPreview\n                    item={item}\n                    isVertical={isVertical}\n                    side={side}\n                    reducedMotion={reducedMotion}\n                    className={previewClassName}\n                  />\n                )}\n              </AnimatePresence>\n            </li>\n          );\n        })}\n      </ol>\n    </nav>\n  );\n}\n\nfunction SectionRailPreview({\n  item,\n  isVertical,\n  side,\n  reducedMotion,\n  className,\n}: {\n  item: SectionRailItem;\n  isVertical: boolean;\n  side: \"left\" | \"right\";\n  reducedMotion: boolean;\n  className?: string;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [shift, setShift] = useState(0);\n  const [flipped, setFlipped] = useState(false);\n\n  // Collision handling: flip across the anchor if the preferred side has no\n  // room, then slide along the rail axis so the card stays fully on screen.\n  useIsomorphicLayoutEffect(() => {\n    const node = ref.current;\n    if (!node) return;\n\n    const margin = 12;\n    const rect = node.getBoundingClientRect();\n\n    if (!flipped) {\n      const overflowsStart = rect.left < margin;\n      const overflowsEnd = rect.right > window.innerWidth - margin;\n      if (isVertical && (overflowsStart || overflowsEnd)) {\n        setFlipped(true);\n        return;\n      }\n      if (!isVertical && rect.top < margin) {\n        setFlipped(true);\n        return;\n      }\n    }\n\n    if (isVertical) {\n      if (rect.top < margin) setShift(margin - rect.top);\n      else if (rect.bottom > window.innerHeight - margin)\n        setShift(window.innerHeight - margin - rect.bottom);\n    } else if (rect.left < margin) {\n      setShift(margin - rect.left);\n    } else if (rect.right > window.innerWidth - margin) {\n      setShift(window.innerWidth - margin - rect.right);\n    }\n  }, [flipped, isVertical]);\n\n  const inlineSide = side === \"right\" ? !flipped : flipped;\n  const placement = isVertical\n    ? inlineSide\n      ? \"end-full me-4 top-1/2 -translate-y-1/2\"\n      : \"start-full ms-4 top-1/2 -translate-y-1/2\"\n    : flipped\n      ? \"top-full mt-4 start-1/2 -translate-x-1/2\"\n      : \"bottom-full mb-4 start-1/2 -translate-x-1/2\";\n\n  return (\n    <motion.div\n      ref={ref}\n      aria-hidden=\"true\"\n      initial={\n        reducedMotion\n          ? { opacity: 0 }\n          : { opacity: 0, scale: 0.96, filter: \"blur(8px)\" }\n      }\n      animate={\n        reducedMotion\n          ? { opacity: 1 }\n          : { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n      }\n      exit={\n        reducedMotion\n          ? { opacity: 0 }\n          : { opacity: 0, scale: 0.98, filter: \"blur(6px)\" }\n      }\n      transition={\n        reducedMotion\n          ? { duration: 0 }\n          : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }\n      }\n      style={{\n        [isVertical ? \"marginTop\" : \"marginLeft\"]: shift,\n        transformOrigin: isVertical\n          ? inlineSide\n            ? \"right center\"\n            : \"left center\"\n          : flipped\n            ? \"center top\"\n            : \"center bottom\",\n      }}\n      className={cn(\n        \"pointer-events-none absolute z-50 w-60 rounded-xl border p-3 backdrop-blur-xl\",\n        \"border-(--section-rail-preview-border) bg-(--section-rail-preview-surface) shadow-(--section-rail-preview-shadow)\",\n        placement,\n        className,\n      )}\n    >\n      {item.thumbnail && (\n        <div className=\"mb-2.5 aspect-video w-full overflow-hidden rounded-lg bg-(--section-rail-preview-thumbnail)\">\n          {/* biome-ignore lint/performance/noImgElement: registry components stay framework-agnostic */}\n          <img\n            src={item.thumbnail.src}\n            alt=\"\"\n            loading=\"lazy\"\n            decoding=\"async\"\n            className=\"size-full object-cover\"\n          />\n        </div>\n      )}\n      <p className=\"font-medium text-foreground text-sm\">{item.label}</p>\n      {item.description && (\n        <p className=\"mt-1 line-clamp-2 text-muted-foreground text-xs leading-relaxed\">\n          {item.description}\n        </p>\n      )}\n    </motion.div>\n  );\n}\n",
      "target": "components/ui/section-rail/section-rail.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}