{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sub-nav",
  "type": "registry:ui",
  "title": "Sub Nav",
  "description": "Scrolling link strip with a gliding hover pill, edge fades and arrows.",
  "categories": [
    "navigation"
  ],
  "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/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/sub-nav.tsx",
      "content": "\"use client\";\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\n/** Width of the edge fades, and the scroll padding that keeps a focused or active link clear of them. */\nconst FADE = 40;\n\n/** Marks which edges of a horizontal scroller have more to show, on the element itself, so scrolling never re-renders. */\nfunction useOverflowEdges(ref: React.RefObject<HTMLElement | null>, target: React.RefObject<HTMLElement | null>) {\n  useEffect(() => {\n    const el = ref.current;\n    const host = target.current;\n    if (!el || !host) return;\n    let frame = 0;\n    const update = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        const x = Math.abs(el.scrollLeft);\n        const max = el.scrollWidth - el.clientWidth;\n        const start = x > 1;\n        const end = max - x > 1;\n        el.style.setProperty(\"--fade-start\", start ? `${FADE}px` : \"0px\");\n        el.style.setProperty(\"--fade-end\", end ? `${FADE}px` : \"0px\");\n        host.toggleAttribute(\"data-overflow-start\", start);\n        host.toggleAttribute(\"data-overflow-end\", end);\n      });\n    };\n    update();\n    el.addEventListener(\"scroll\", update, { passive: true });\n    const ro = new ResizeObserver(update);\n    ro.observe(el);\n    if (el.firstElementChild) ro.observe(el.firstElementChild);\n    return () => {\n      cancelAnimationFrame(frame);\n      el.removeEventListener(\"scroll\", update);\n      ro.disconnect();\n    };\n  }, [ref, target]);\n}\n\nconst reducedNow = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/** Scrolls the strip, sideways only, until the element clears the fades. The page itself never moves. */\nfunction reveal(scroller: HTMLElement, el: HTMLElement, smooth: boolean) {\n  if (scroller.scrollWidth <= scroller.clientWidth) return;\n  const left = el.offsetLeft - FADE;\n  const right = el.offsetLeft + el.offsetWidth + FADE - scroller.clientWidth;\n  const target = scroller.scrollLeft > left ? left : scroller.scrollLeft < right ? right : null;\n  if (target !== null) scroller.scrollTo({ left: target, behavior: smooth && !reducedNow() ? \"smooth\" : \"auto\" });\n}\n\nexport type SubNavProps = React.ComponentProps<\"nav\"> & {\n  /** Names the landmark (\"Project\", \"Billing\"). Required when a page has more than one nav. */\n  \"aria-label\": string;\n  /** Draw a hairline under the strip that the active bar sits on. */\n  bordered?: boolean;\n};\n\n/**\n * A strip of page links that scrolls sideways when it runs out of room. One\n * hover pill follows the pointer; one bar marks the current page.\n */\nexport function SubNav({ bordered = true, className, children, ...rest }: SubNavProps) {\n  const host = useRef<HTMLElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const list = useRef<HTMLUListElement>(null);\n  const bar = useRef<HTMLSpanElement>(null);\n  const reduce = useReducedMotion();\n  useOverflowEdges(scroller, host);\n\n  // The hover pill: one element, moved on a spring, placed without travel when the pointer first arrives.\n  const hx = useMotionValue(0);\n  const hw = useMotionValue(0);\n  const ho = useMotionValue(0);\n  const shown = useRef(false);\n\n  const onPointerOver = (e: React.PointerEvent) => {\n    if (e.pointerType !== \"mouse\") return;\n    const link = (e.target as HTMLElement).closest<HTMLElement>(\"[data-subnav-item]\");\n    if (!link || link.hasAttribute(\"data-disabled\")) return;\n    const x = link.offsetLeft;\n    const w = link.offsetWidth;\n    if (!shown.current || reduce) {\n      hx.jump(x);\n      hw.jump(w);\n    } else {\n      animate(hx, x, spring.follow);\n      animate(hw, w, spring.follow);\n    }\n    shown.current = true;\n    animate(ho, 1, { duration: 0.12 });\n  };\n  const onPointerLeave = () => {\n    shown.current = false;\n    animate(ho, 0, { duration: 0.15 });\n  };\n\n  // The current-page bar and its scroll position follow aria-current, wherever it comes from.\n  useEffect(() => {\n    const s = scroller.current;\n    const l = list.current;\n    const b = bar.current;\n    if (!s || !l || !b) return;\n    let first = true;\n    const place = () => {\n      const active = l.querySelector<HTMLElement>(\"[aria-current=page]\");\n      b.style.opacity = active ? \"1\" : \"0\";\n      if (!active) return;\n      b.style.transitionDuration = first ? \"0ms\" : \"\";\n      b.style.translate = `${active.offsetLeft + 8}px 0`;\n      b.style.width = `${Math.max(active.offsetWidth - 16, 8)}px`;\n      reveal(s, active, !first);\n      first = false;\n    };\n    place();\n    const mo = new MutationObserver(place);\n    mo.observe(l, { subtree: true, childList: true, attributeFilter: [\"aria-current\"] });\n    const ro = new ResizeObserver(place);\n    ro.observe(l);\n    return () => {\n      mo.disconnect();\n      ro.disconnect();\n    };\n  }, []);\n\n  const scrollByPage = (dir: 1 | -1) => {\n    const s = scroller.current;\n    if (!s) return;\n    s.scrollBy({ left: dir * s.clientWidth * 0.7, behavior: reducedNow() ? \"auto\" : \"smooth\" });\n  };\n\n  return (\n    <nav ref={host} className={cn(\"group/subnav relative min-w-0\", className)} {...rest}>\n      {bordered && <span aria-hidden className=\"pointer-events-none absolute inset-x-0 bottom-0 h-px bg-line\" />}\n      <div\n        ref={scroller}\n        onPointerOver={onPointerOver}\n        onPointerLeave={onPointerLeave}\n        // Tabbing along the strip keeps the focused link clear of the fade and the arrows.\n        onFocus={(e) => {\n          const link = (e.target as HTMLElement).closest<HTMLElement>(\"[data-subnav-item] > *\");\n          if (link && scroller.current) reveal(scroller.current, link, false);\n        }}\n        className={cn(\n          \"relative overflow-x-auto overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          \"[mask-image:linear-gradient(to_right,transparent,var(--fg)_var(--fade-start,0px),var(--fg)_calc(100%-var(--fade-end,0px)),transparent)]\",\n          \"scroll-px-10\",\n        )}\n      >\n        {/* Decoration sits beside the list, not in it, so the list holds only list items. */}\n        <div className=\"relative isolate h-11 w-max min-w-full\">\n          <motion.span\n            aria-hidden\n            className=\"pointer-events-none absolute top-1.5 left-0 -z-10 h-8 rounded-lg bg-fg/[0.06]\"\n            style={{ x: hx, width: hw, opacity: ho }}\n          />\n          <ul ref={list} className=\"flex h-full items-center gap-0.5\">\n            {children}\n          </ul>\n          <span\n            ref={bar}\n            aria-hidden\n            className=\"pointer-events-none absolute bottom-0 left-0 h-0.5 rounded-full bg-fg opacity-0 transition-[translate,width,opacity] duration-[260ms] ease-in-out-quart\"\n          />\n        </div>\n      </div>\n      <ScrollButton dir={-1} onClick={() => scrollByPage(-1)} />\n      <ScrollButton dir={1} onClick={() => scrollByPage(1)} />\n    </nav>\n  );\n}\n\n// Mouse affordances only: keyboard users tab through the links, which scroll themselves into view.\nfunction ScrollButton({ dir, onClick }: { dir: 1 | -1; onClick: () => void }) {\n  const Icon = dir === 1 ? ChevronRight : ChevronLeft;\n  return (\n    <button\n      type=\"button\"\n      tabIndex={-1}\n      aria-hidden\n      onClick={onClick}\n      className={cn(\n        \"absolute top-1/2 z-10 -mt-3 grid size-6 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] outline-none\",\n        \"transition-[opacity,scale,background-color,color] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n        \"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5 pointer-coarse:before:content-['']\",\n        dir === 1\n          ? \"right-1 pointer-events-none scale-90 opacity-0 group-data-overflow-end/subnav:pointer-events-auto group-data-overflow-end/subnav:scale-100 group-data-overflow-end/subnav:opacity-100\"\n          : \"left-1 pointer-events-none scale-90 opacity-0 group-data-overflow-start/subnav:pointer-events-auto group-data-overflow-start/subnav:scale-100 group-data-overflow-start/subnav:opacity-100\",\n      )}\n    >\n      <Icon size={14} />\n    </button>\n  );\n}\n\nexport type SubNavItemProps = useRender.ComponentProps<\"a\"> & {\n  /** The current page. Sets aria-current and moves the bar here. */\n  active?: boolean;\n  /** Shown dimmed and not followable, for pages that aren't available yet. */\n  disabled?: boolean;\n  /** A count after the label (\"Issues 12\"). */\n  count?: number;\n  /** 16px icon before the label. */\n  icon?: React.ReactNode;\n};\n\nexport function SubNavItem({ active = false, disabled = false, count, icon, render, className, children, ...rest }: SubNavItemProps) {\n  const element = useRender({\n    defaultTagName: \"a\",\n    render,\n    props: mergeProps<\"a\">(\n      {\n        \"aria-current\": active ? \"page\" : undefined,\n        \"aria-disabled\": disabled || undefined,\n        tabIndex: disabled ? -1 : undefined,\n        onClick: disabled ? (e: React.MouseEvent) => e.preventDefault() : undefined,\n        className: cn(\n          \"group/item relative flex h-8 shrink-0 select-none items-center gap-2 rounded-lg px-2.5 text-[13px] whitespace-nowrap\",\n          \"cursor-pointer touch-manipulation outline-none [-webkit-tap-highlight-color:transparent]\",\n          \"text-fg-3 transition-colors duration-150 hover:text-fg aria-[current=page]:text-fg\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          \"aria-disabled:pointer-events-none aria-disabled:text-fg-4\",\n          className,\n        ),\n        children: (\n          <span className=\"flex items-center gap-2 transition-[scale] duration-150 ease-out-quart group-active/item:scale-[0.97] group-active/item:duration-75\">\n            {icon && <span className=\"-ml-0.5 flex shrink-0 text-fg-4 transition-colors duration-150 group-hover/item:text-fg-3 group-aria-[current=page]/item:text-fg-2 [&_svg]:size-4\">{icon}</span>}\n            {children}\n            {count !== undefined && (\n              <span className=\"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-fg/[0.06] px-1.5 text-[11px] leading-none text-fg-3 tabular\">\n                <span className=\"sr-only\">(</span>\n                {count}\n                <span className=\"sr-only\">)</span>\n              </span>\n            )}\n          </span>\n        ),\n      },\n      rest,\n    ),\n  });\n\n  return (\n    <li className=\"flex\" data-subnav-item=\"\" data-disabled={disabled || undefined}>\n      {element}\n    </li>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sub-nav"
}
