{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"breadcrumbs","type":"registry:component","title":"Breadcrumbs","description":"Folds the middle of a deep path into a menu when space runs out.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/breadcrumbs.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useIsPresent } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Crumb = { label: string; href: string };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Segments slide over to close the gap at UI speed, with no bounce: a trail\n// that overshoots reads as unstable.\nconst SLIDE = { type: \"spring\", visualDuration: 0.25, bounce: 0 } as const;\nconst INSTANT = { duration: 0 } as const;\nconst ENTER = { duration: 0.2, ease: EASE_OUT };\n// Leaving segments go faster than arriving ones, so the gap they leave is\n// already closing by the time the eye looks for it.\nconst EXIT = { duration: 0.12, ease: EASE_OUT };\nconst MENU_ENTER = { duration: 0.18, ease: EASE_OUT };\nconst MENU_EXIT = { duration: 0.1, ease: EASE_OUT };\n\n/*\n * Fitting works off a hidden copy of the full trail, so the widths never\n * depend on what is currently folded and the decision can't oscillate.\n * The first segment and the current page always stay; middle segments fold\n * into the menu from the left, so the nearest parents stay visible longest.\n */\nexport function Breadcrumbs({\n  items,\n  onNavigate,\n  className,\n}: {\n  items: readonly Crumb[];\n  onNavigate?: (item: Crumb, index: number) => void;\n  className?: string;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const navRef = useRef<HTMLElement>(null);\n  const rulerRef = useRef<HTMLOListElement>(null);\n  // Index of the first segment shown after the fold. 1 means none hidden.\n  const [start, setStart] = useState(1);\n\n  useEffect(() => {\n    const nav = navRef.current;\n    const ruler = rulerRef.current;\n    if (!nav || !ruler) return;\n\n    const fit = () => {\n      const widths = [...ruler.children].map(\n        (n) => n.getBoundingClientRect().width,\n      );\n      const fold = widths.pop() ?? 0;\n      const n = widths.length;\n      const available = nav.clientWidth;\n      let next = Math.max(n - 1, 1);\n      let tail = widths.slice(1).reduce((a, b) => a + b, 0);\n      for (let k = 1; k < n; k++) {\n        // Half a pixel of slack absorbs subpixel rounding between the ruler\n        // and the live row.\n        if (widths[0] + (k > 1 ? fold : 0) + tail <= available + 0.5) {\n          next = k;\n          break;\n        }\n        tail -= widths[k];\n      }\n      // Bails out when unchanged, so a continuous resize costs no renders.\n      setStart(next);\n    };\n\n    const observer = new ResizeObserver(fit);\n    observer.observe(nav);\n    observer.observe(ruler);\n    let alive = true;\n    // Webfonts can land after the first measure and change every width.\n    document.fonts?.ready.then(() => {\n      if (alive) fit();\n    });\n    return () => {\n      alive = false;\n      observer.disconnect();\n    };\n  }, [items]);\n\n  const last = items.length - 1;\n  const folded = Math.min(start, Math.max(last, 1));\n  const hidden = items.slice(1, folded);\n  const layout = reduce ? INSTANT : SLIDE;\n  const reveal = {\n    initial: { opacity: 0, filter: reduce ? \"blur(0px)\" : \"blur(4px)\" },\n    animate: { opacity: 1, filter: \"blur(0px)\", transition: ENTER },\n    exit: { opacity: 0, filter: \"blur(0px)\", transition: EXIT },\n    transition: { layout },\n    layout: \"position\" as const,\n    // Only a fold change moves segments, so other re-renders (opening the\n    // menu) skip the layout measure entirely.\n    layoutDependency: folded,\n  };\n\n  const segments: React.ReactNode[] = [];\n  items.forEach((item, index) => {\n    if (index !== 0 && index < folded) return;\n    segments.push(\n      <motion.li\n        key={item.href}\n        {...reveal}\n        className={cn(\n          \"flex items-center\",\n          index === last ? \"min-w-0\" : \"shrink-0\",\n        )}\n      >\n        {index > 0 && <Separator />}\n        {index === last ? (\n          // The current page is text, not a link: it can't take you anywhere.\n          <span\n            aria-current=\"page\"\n            className=\"block truncate px-1.5 text-sm font-medium text-foreground\"\n          >\n            {item.label}\n          </span>\n        ) : (\n          <a\n            href={item.href}\n            onClick={(e) => {\n              if (!onNavigate) return;\n              e.preventDefault();\n              onNavigate(item, index);\n            }}\n            className=\"inline-flex h-9 touch-manipulation items-center rounded-md px-1.5 text-sm whitespace-nowrap text-muted-foreground underline decoration-transparent underline-offset-4 outline-hidden transition-[color,text-decoration-color,scale] duration-150 ease-out select-none hover:text-foreground hover:decoration-foreground/40 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,text-decoration-color]\"\n          >\n            {item.label}\n          </a>\n        )}\n      </motion.li>,\n    );\n    if (index === 0 && hidden.length > 0) {\n      segments.push(\n        <motion.li key=\"fold\" {...reveal} className=\"flex shrink-0 items-center\">\n          <Separator />\n          <FoldMenu items={hidden} onNavigate={onNavigate} />\n        </motion.li>,\n      );\n    }\n  });\n\n  return (\n    <nav\n      ref={navRef}\n      aria-label=\"Folder breadcrumb\"\n      className={cn(\"relative w-full min-w-0\", className)}\n    >\n      <ol\n        ref={rulerRef}\n        aria-hidden\n        inert\n        className=\"pointer-events-none invisible absolute top-0 left-0 flex items-center whitespace-nowrap\"\n      >\n        {items.map((item, index) => (\n          <li key={item.href} className=\"flex shrink-0 items-center\">\n            {index > 0 && <Separator />}\n            <span\n              className={cn(\"px-1.5 text-sm\", index === last && \"font-medium\")}\n            >\n              {item.label}\n            </span>\n          </li>\n        ))}\n        <li className=\"flex shrink-0 items-center\">\n          <Separator />\n          <span className=\"block w-9\" />\n        </li>\n      </ol>\n\n      <ol className=\"relative flex h-9 min-w-0 items-center\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {segments}\n        </AnimatePresence>\n      </ol>\n    </nav>\n  );\n}\n\nfunction Separator() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      aria-hidden\n      className=\"size-4 shrink-0 text-muted-foreground/50\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n    >\n      <path d=\"M10 3 6 13\" />\n    </svg>\n  );\n}\n\nfunction FoldMenu({\n  items,\n  onNavigate,\n}: {\n  items: readonly Crumb[];\n  onNavigate?: (item: Crumb, index: number) => void;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const id = useId();\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLAnchorElement | null)[]>([]);\n  const focusFirst = useRef(false);\n\n  useEffect(() => {\n    if (!open) return;\n    if (focusFirst.current) itemRefs.current[0]?.focus({ preventScroll: true });\n    else menuRef.current?.focus({ preventScroll: true });\n  }, [open]);\n\n  const count = items.length;\n\n  const openMenu = (first: boolean) => {\n    focusFirst.current = first;\n    setOpen(true);\n  };\n\n  const close = (returnFocus: boolean) => {\n    setOpen(false);\n    if (returnFocus) triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const focusAt = (index: number) =>\n    itemRefs.current[(index + count) % count]?.focus({ preventScroll: true });\n\n  const move = (step: number) => {\n    const at = itemRefs.current.indexOf(\n      document.activeElement as HTMLAnchorElement,\n    );\n    focusAt(at === -1 ? (step > 0 ? 0 : -1) : at + step);\n  };\n\n  return (\n    <span ref={rootRef} className=\"relative flex\">\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-label={`Show ${count} hidden ${count === 1 ? \"folder\" : \"folders\"}`}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? `${id}-menu` : undefined}\n        onClick={(e) => {\n          if (open) close(false);\n          // detail is 0 for keyboard and assistive tech clicks, which want\n          // focus on the first item rather than on the menu itself.\n          else openMenu(e.detail === 0);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            openMenu(true);\n          }\n        }}\n        className={cn(\n          \"flex h-8 w-9 touch-manipulation items-center justify-center rounded-md text-muted-foreground outline-hidden transition-[color,background-color,scale] duration-150 ease-out select-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n          open && \"bg-foreground/[0.06] text-foreground\",\n        )}\n      >\n        <svg\n          viewBox=\"0 0 16 16\"\n          aria-hidden\n          className=\"size-4\"\n          fill=\"currentColor\"\n        >\n          <circle cx=\"3.5\" cy=\"8\" r=\"1.25\" />\n          <circle cx=\"8\" cy=\"8\" r=\"1.25\" />\n          <circle cx=\"12.5\" cy=\"8\" r=\"1.25\" />\n        </svg>\n      </button>\n\n      <AnimatePresence>\n        {open && (\n          <Menu\n            ref={menuRef}\n            id={`${id}-menu`}\n            reduce={reduce}\n            onKeyDown={(e) => {\n              switch (e.key) {\n                case \"ArrowDown\":\n                  e.preventDefault();\n                  move(1);\n                  return;\n                case \"ArrowUp\":\n                  e.preventDefault();\n                  move(-1);\n                  return;\n                case \"Home\":\n                  e.preventDefault();\n                  focusAt(0);\n                  return;\n                case \"End\":\n                  e.preventDefault();\n                  focusAt(-1);\n                  return;\n                case \"Escape\":\n                  e.preventDefault();\n                  close(true);\n                  return;\n                case \"Tab\":\n                  // Focus moves on as usual; the menu just gets out of the way.\n                  close(false);\n              }\n            }}\n            onBlur={(e) => {\n              // Covers clicking outside, tabbing away and leaving the window.\n              if (!rootRef.current?.contains(e.relatedTarget as Node | null)) {\n                close(false);\n              }\n            }}\n          >\n            {items.map((item, i) => (\n              <a\n                key={item.href}\n                ref={(el) => {\n                  itemRefs.current[i] = el;\n                }}\n                href={item.href}\n                role=\"menuitem\"\n                tabIndex={-1}\n                onPointerMove={(e) => {\n                  if (e.pointerType === \"touch\") return;\n                  if (document.activeElement !== e.currentTarget) {\n                    e.currentTarget.focus({ preventScroll: true });\n                  }\n                }}\n                onClick={(e) => {\n                  if (onNavigate) {\n                    e.preventDefault();\n                    // Hidden segments start right after the first one.\n                    onNavigate(item, i + 1);\n                  }\n                  close(true);\n                }}\n                // No transition on the highlight: it follows every hover, so\n                // any easing reads as lag.\n                className=\"flex h-9 cursor-default items-center gap-2.5 rounded-lg px-2 text-sm text-foreground outline-hidden select-none focus:bg-foreground/[0.06]\"\n              >\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  aria-hidden\n                  className=\"size-4 shrink-0 text-muted-foreground\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  strokeLinejoin=\"round\"\n                >\n                  <path d=\"M2.25 4.5c0-.69.56-1.25 1.25-1.25h2.6l1.4 1.5h5c.69 0 1.25.56 1.25 1.25v5.5c0 .69-.56 1.25-1.25 1.25h-9c-.69 0-1.25-.56-1.25-1.25Z\" />\n                </svg>\n                <span className=\"truncate\">{item.label}</span>\n              </a>\n            ))}\n          </Menu>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Menu({\n  ref,\n  id,\n  reduce,\n  onKeyDown,\n  onBlur,\n  children,\n}: {\n  ref: React.Ref<HTMLDivElement>;\n  id: string;\n  reduce: boolean;\n  onKeyDown: (e: React.KeyboardEvent) => void;\n  onBlur: (e: React.FocusEvent) => void;\n  children: React.ReactNode;\n}) {\n  // A closing menu stops taking pointer input at once, so it never blocks\n  // the next click while it fades.\n  const isPresent = useIsPresent();\n  return (\n    <motion.div\n      ref={ref}\n      id={id}\n      role=\"menu\"\n      aria-label=\"Hidden folders\"\n      tabIndex={-1}\n      initial={{ opacity: 0, transform: reduce ? \"scale(1)\" : \"scale(0.95)\" }}\n      animate={{ opacity: 1, transform: \"scale(1)\", transition: MENU_ENTER }}\n      exit={{\n        opacity: 0,\n        transform: reduce ? \"scale(1)\" : \"scale(0.97)\",\n        transition: MENU_EXIT,\n      }}\n      onKeyDown={onKeyDown}\n      onBlur={onBlur}\n      // Grows out of the dots: 18px is the centre of the 36px trigger.\n      style={{ transformOrigin: \"18px 0\" }}\n      // 12px radius around 4px padding keeps the 8px items concentric.\n      className={cn(\n        \"absolute top-full left-0 z-50 mt-1.5 w-48 rounded-xl bg-muted p-1 shadow-raised outline-hidden\",\n        !isPresent && \"pointer-events-none\",\n      )}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nconst PATH: Crumb[] = [\n  { label: \"Workspace\", href: \"/workspace\" },\n  { label: \"Projects\", href: \"/workspace/projects\" },\n  { label: \"ui-lab\", href: \"/workspace/projects/ui-lab\" },\n  { label: \"src\", href: \"/workspace/projects/ui-lab/src\" },\n  { label: \"lab\", href: \"/workspace/projects/ui-lab/src/lab\" },\n  { label: \"components\", href: \"/workspace/projects/ui-lab/src/lab/components\" },\n  {\n    label: \"button.tsx\",\n    href: \"/workspace/projects/ui-lab/src/lab/components/button.tsx\",\n  },\n];\n\n// Narrow enough to fold everything, wide enough that the current page still\n// reads in full.\nconst MIN_WIDTH = 220;\nconst KEY_STEP = 16;\n\nexport default function BreadcrumbsDemo() {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const frameRef = useRef<HTMLDivElement>(null);\n  const handleRef = useRef<HTMLDivElement>(null);\n  const drag = useRef<{ x: number; width: number } | null>(null);\n\n  // Written straight to the DOM: resizing is continuous, and only a fold\n  // change inside the trail needs React.\n  const resize = (width: number) => {\n    const frame = frameRef.current;\n    const max = wrapRef.current?.clientWidth ?? width;\n    if (!frame) return;\n    const next = Math.round(Math.min(Math.max(width, MIN_WIDTH), max));\n    frame.style.width = `${next}px`;\n    handleRef.current?.setAttribute(\"aria-valuenow\", String(next));\n    handleRef.current?.setAttribute(\"aria-valuemax\", String(Math.round(max)));\n  };\n\n  useEffect(() => {\n    // Syncs the slider's range with the real width on narrow screens.\n    const max = wrapRef.current?.clientWidth ?? 0;\n    handleRef.current?.setAttribute(\"aria-valuemax\", String(Math.round(max)));\n    handleRef.current?.setAttribute(\"aria-valuenow\", String(Math.round(max)));\n  }, []);\n\n  const stop = () => {\n    drag.current = null;\n  };\n\n  return (\n    <div ref={wrapRef} className=\"flex w-[min(520px,100%)] flex-col gap-4\">\n      <div\n        ref={frameRef}\n        className=\"relative flex h-14 max-w-full items-center rounded-2xl bg-background px-2.5 shadow-raised\"\n      >\n        <Breadcrumbs items={PATH} onNavigate={() => {}} />\n        <div\n          ref={handleRef}\n          role=\"slider\"\n          tabIndex={0}\n          aria-label=\"Container width\"\n          aria-orientation=\"horizontal\"\n          aria-valuemin={MIN_WIDTH}\n          aria-valuemax={520}\n          aria-valuenow={520}\n          onPointerDown={(e) => {\n            if (e.button !== 0) return;\n            e.currentTarget.setPointerCapture(e.pointerId);\n            drag.current = {\n              x: e.clientX,\n              width: frameRef.current?.offsetWidth ?? 0,\n            };\n          }}\n          onPointerMove={(e) => {\n            const from = drag.current;\n            if (from) resize(from.width + e.clientX - from.x);\n          }}\n          onPointerUp={stop}\n          onPointerCancel={stop}\n          onKeyDown={(e) => {\n            const width = frameRef.current?.offsetWidth ?? 0;\n            const next = {\n              ArrowLeft: width - KEY_STEP,\n              ArrowDown: width - KEY_STEP,\n              ArrowRight: width + KEY_STEP,\n              ArrowUp: width + KEY_STEP,\n              PageDown: width - KEY_STEP * 4,\n              PageUp: width + KEY_STEP * 4,\n              Home: MIN_WIDTH,\n              End: Infinity,\n            }[e.key];\n            if (next === undefined) return;\n            e.preventDefault();\n            resize(next);\n          }}\n          // A 24px wide hit area around a 6px grip, centred on the edge.\n          className=\"group absolute top-1/2 -right-3 flex h-10 w-6 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-full outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n        >\n          <span className=\"h-6 w-1.5 rounded-full bg-border transition-[background-color,scale] duration-150 ease-out group-hover:bg-muted-foreground group-active:scale-y-[1.15] group-active:bg-foreground motion-reduce:transition-[background-color]\" />\n        </div>\n      </div>\n      <p className=\"px-1 text-[13px] text-muted-foreground\">\n        Drag the edge to resize. Folded folders live behind the dots.\n      </p>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/breadcrumbs"}