{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "breadcrumbs",
  "type": "registry:ui",
  "title": "Breadcrumbs",
  "description": "A trail that folds its middle into a menu to fit its width.",
  "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/breadcrumbs.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronRight, MoreH } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type BreadcrumbItem = {\n  label: string;\n  href?: string;\n  /** Decorative icon before the label: a workspace mark, a folder. */\n  icon?: React.ReactNode;\n};\n\ntype LinkProps = React.ComponentProps<\"a\"> & { href: string };\n\nexport type BreadcrumbsProps = Omit<React.ComponentProps<\"nav\">, \"children\"> & {\n  /** From the root to the current page. The last item is the page you're on. */\n  items: BreadcrumbItem[];\n  separator?: \"chevron\" | \"slash\";\n  /**\n   * \"auto\" folds middle items into a menu when the trail doesn't fit its container.\n   * A number folds them whenever there are more items than that.\n   */\n  maxItems?: number | \"auto\";\n  /** With a numeric maxItems, items always kept visible after the folded ones, the current page included. */\n  itemsAfterCollapse?: number;\n  /** Longest a single label may draw, in px, before it truncates. */\n  maxItemWidth?: number;\n  size?: \"sm\" | \"md\";\n  /** Render links with your router's Link. Receives everything an anchor needs. */\n  renderLink?: (props: LinkProps, item: BreadcrumbItem) => React.ReactElement;\n  /** Called when a crumb or a folded item is chosen, before navigation. */\n  onItemClick?: (item: BreadcrumbItem, index: number, event: React.MouseEvent) => void;\n};\n\nexport function Breadcrumbs({\n  items,\n  separator = \"chevron\",\n  maxItems = \"auto\",\n  itemsAfterCollapse = 2,\n  maxItemWidth = 180,\n  size = \"md\",\n  renderLink,\n  onItemClick,\n  className,\n  style,\n  ...rest\n}: BreadcrumbsProps) {\n  const reduce = useReducedMotion();\n  const measureRef = useRef<HTMLOListElement>(null);\n  const navRef = useRef<HTMLElement>(null);\n  const fixed = typeof maxItems === \"number\" ? Math.max(0, items.length - Math.max(2, maxItems)) : 0;\n  const [fitted, setFitted] = useState(0);\n  // A fixed limit keeps itemsAfterCollapse; fitting to width may fold everything between the\n  // first item and the current page, because the page you're on matters more than its parent.\n  const hiddenCount = typeof maxItems === \"number\" ? Math.min(fixed, Math.max(0, items.length - 1 - itemsAfterCollapse)) : Math.min(fitted, Math.max(0, items.length - 2));\n\n  // How many middle items to fold is measured from an invisible copy of the whole trail,\n  // so the answer never depends on what's currently folded and resizing can't oscillate.\n  useEffect(() => {\n    if (maxItems !== \"auto\") return;\n    const nav = navRef.current;\n    const measure = measureRef.current;\n    if (!nav || !measure) return;\n    const fit = () => {\n      const available = nav.clientWidth;\n      const crumbs = [...measure.querySelectorAll<HTMLElement>(\"[data-measure=crumb]\")].map((el) => el.offsetWidth);\n      const sep = measure.querySelector<HTMLElement>(\"[data-measure=sep]\")?.offsetWidth ?? 0;\n      const more = measure.querySelector<HTMLElement>(\"[data-measure=more]\")?.offsetWidth ?? 0;\n      const n = crumbs.length;\n      const full = crumbs.reduce((a, b) => a + b, 0) + sep * (n - 1);\n      if (full <= available || n < 3) return setFitted(0);\n      // Fold from the second item onward, oldest first, until the rest fits.\n      for (let k = 1; k <= n - 2; k++) {\n        const kept = crumbs[0] + crumbs.slice(k + 1).reduce((a, b) => a + b, 0);\n        if (kept + more + sep * (n - k) <= available) return setFitted(k);\n      }\n      setFitted(n - 2);\n    };\n    const observer = new ResizeObserver(fit);\n    observer.observe(nav);\n    observer.observe(measure);\n    return () => observer.disconnect();\n  }, [maxItems, items]);\n\n  const folded = hiddenCount > 0 ? items.slice(1, 1 + hiddenCount) : [];\n  const visible = items.map((item, index) => ({ item, index })).filter(({ index }) => index === 0 || index > hiddenCount);\n  const text = size === \"sm\" ? \"text-[12.5px]\" : \"text-[13px]\";\n  const vars = { \"--crumb-max\": `${maxItemWidth}px`, ...style } as React.CSSProperties;\n\n  const sepEl = <Separator kind={separator} size={size} />;\n\n  return (\n    <nav ref={navRef} aria-label=\"Breadcrumb\" data-size={size} className={cn(\"relative min-w-0\", text, className)} style={vars} {...rest}>\n      {/* The measuring copy: laid out like the real trail, never seen or read. */}\n      {maxItems === \"auto\" && (\n        <ol ref={measureRef} aria-hidden inert className=\"pointer-events-none invisible absolute top-0 left-0 flex h-0 items-center overflow-hidden whitespace-nowrap\">\n          {items.map((item, i) => (\n            <li key={i} data-measure=\"crumb\" className=\"flex shrink-0 items-center\">\n              <CrumbFace item={item} current={i === items.length - 1} size={size} />\n            </li>\n          ))}\n          <li data-measure=\"sep\" className=\"flex shrink-0\">\n            {sepEl}\n          </li>\n          <li data-measure=\"more\" className={cn(\"flex shrink-0\", moreClass(size))} />\n        </ol>\n      )}\n\n      <Tooltip.Provider delay={500} closeDelay={0}>\n        <ol className=\"flex min-w-0 flex-nowrap items-center\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {visible.map(({ item, index }, position) => {\n              const current = index === items.length - 1;\n              return (\n                <motion.li\n                  key={`${index}:${item.href ?? item.label}`}\n                  // A crumb added by going deeper slides in from the one before it; crumbs\n                  // dropped by going back fade where they are.\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, x: -6, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n                  className={cn(\"flex items-center\", current ? \"min-w-0 shrink\" : \"shrink-0\", position === 0 && !current && \"min-w-0 shrink\")}\n                >\n                  {position > 0 && sepEl}\n                  {position === 1 && folded.length > 0 && (\n                    <>\n                      <FoldedMenu items={folded} offset={1} size={size} renderLink={renderLink} onItemClick={onItemClick} />\n                      {sepEl}\n                    </>\n                  )}\n                  <Crumb item={item} index={index} current={current} size={size} renderLink={renderLink} onItemClick={onItemClick} />\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ol>\n      </Tooltip.Provider>\n    </nav>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * A crumb, and its full name when it's cut short\n * -----------------------------------------------------------------------------------------------*/\n\nconst crumbClass = (size: \"sm\" | \"md\") =>\n  cn(\n    \"relative flex min-w-0 items-center gap-1.5 rounded-md outline-none\",\n    size === \"sm\" ? \"h-6 px-1\" : \"h-7 px-1.5\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n  );\n\nfunction CrumbFace({ item, current, size }: { item: BreadcrumbItem; current: boolean; size: \"sm\" | \"md\" }) {\n  return (\n    <span className={crumbClass(size)}>\n      {item.icon && <span className=\"flex shrink-0 [&_svg]:size-3.5\">{item.icon}</span>}\n      <span className={cn(\"max-w-(--crumb-max) truncate\", current && \"font-medium\")}>{item.label}</span>\n    </span>\n  );\n}\n\nfunction Crumb({\n  item,\n  index,\n  current,\n  size,\n  renderLink,\n  onItemClick,\n}: {\n  item: BreadcrumbItem;\n  index: number;\n  current: boolean;\n  size: \"sm\" | \"md\";\n  renderLink?: BreadcrumbsProps[\"renderLink\"];\n  onItemClick?: BreadcrumbsProps[\"onItemClick\"];\n}) {\n  const labelRef = useRef<HTMLSpanElement>(null);\n  const [open, setOpen] = useState(false);\n\n  const face = (\n    <>\n      {item.icon && <span aria-hidden className={cn(\"flex shrink-0 transition-colors duration-150 [&_svg]:size-3.5\", current ? \"text-fg-2\" : \"text-fg-4 group-hover/crumb:text-fg-3\")}>{item.icon}</span>}\n      <span ref={labelRef} className={cn(\"max-w-(--crumb-max) truncate\", current && \"font-medium\")}>\n        {item.label}\n      </span>\n    </>\n  );\n\n  const linkClass = cn(\n    crumbClass(size),\n    \"group/crumb touch-manipulation text-fg-3 transition-[color,background-color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n    // A 44px tall hit area on touch without changing the drawing.\n    \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2 pointer-coarse:after:content-['']\",\n  );\n\n  const trigger = current || !item.href ? (\n    <span aria-current={current ? \"page\" : undefined} tabIndex={current ? -1 : undefined} className={cn(crumbClass(size), current ? \"text-fg\" : \"text-fg-3\")}>\n      {face}\n    </span>\n  ) : renderLink ? (\n    renderLink({ href: item.href, className: linkClass, children: face, onClick: (e) => onItemClick?.(item, index, e) }, item)\n  ) : (\n    <a href={item.href} className={linkClass} onClick={(e) => onItemClick?.(item, index, e)}>\n      {face}\n    </a>\n  );\n\n  // The full label shows only when this one is actually cut short.\n  return (\n    <Tooltip.Root\n      open={open}\n      onOpenChange={(next) => {\n        const el = labelRef.current;\n        setOpen(next && !!el && el.scrollWidth > el.clientWidth + 1);\n      }}\n    >\n      <Tooltip.Trigger render={trigger} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"bottom\" sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"max-w-[min(24rem,var(--available-width))] origin-(--transform-origin) rounded-lg border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 break-words text-fg shadow-pop\",\n              \"transition-[opacity,scale] duration-140 ease-out-expo data-ending-style:duration-100\",\n              \"data-starting-style:scale-[0.97] data-starting-style:opacity-0 data-ending-style:opacity-0\",\n              \"data-instant:duration-0 motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            {item.label}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\nfunction Separator({ kind, size }: { kind: \"chevron\" | \"slash\"; size: \"sm\" | \"md\" }) {\n  return (\n    <span aria-hidden className={cn(\"flex shrink-0 select-none items-center justify-center text-fg-4\", size === \"sm\" ? \"w-4\" : \"w-5\")}>\n      {kind === \"chevron\" ? <ChevronRight size={size === \"sm\" ? 12 : 14} /> : <span className=\"text-[1.05em] font-light\">/</span>}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The folded middle\n * -----------------------------------------------------------------------------------------------*/\n\nconst moreClass = (size: \"sm\" | \"md\") => cn(\"inline-flex shrink-0 items-center justify-center rounded-md\", size === \"sm\" ? \"h-6 w-7\" : \"h-7 w-8\");\n\nfunction FoldedMenu({\n  items,\n  offset,\n  size,\n  renderLink,\n  onItemClick,\n}: {\n  items: BreadcrumbItem[];\n  offset: number;\n  size: \"sm\" | \"md\";\n  renderLink?: BreadcrumbsProps[\"renderLink\"];\n  onItemClick?: BreadcrumbsProps[\"onItemClick\"];\n}) {\n  return (\n    <Menu.Root>\n      <Menu.Trigger\n        aria-label={`Show ${items.length} more ${items.length === 1 ? \"level\" : \"levels\"}`}\n        className={cn(\n          moreClass(size),\n          \"relative text-fg-3 outline-none transition-[color,background-color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n          \"data-popup-open:bg-hover data-popup-open:text-fg\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n          \"pointer-coarse:after:absolute pointer-coarse:after:-inset-2 pointer-coarse:after:content-['']\",\n        )}\n      >\n        <MoreH size={16} />\n      </Menu.Trigger>\n      <Menu.Portal>\n        <Menu.Positioner side=\"bottom\" align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n          <Menu.Popup\n            className={cn(\n              \"relative isolate max-h-(--available-height) min-w-44 max-w-[min(20rem,var(--available-width))] overflow-y-auto overscroll-contain rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n              \"data-starting-style:-translate-y-1 data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n              \"data-instant:duration-0 motion-reduce:translate-none motion-reduce:scale-100\",\n            )}\n          >\n            <Glide />\n            {items.map((item, i) => {\n              const index = offset + i;\n              const face = (\n                <>\n                  {/* Each folded level is indented one step, so the menu still reads as a path. */}\n                  <span aria-hidden className=\"shrink-0\" style={{ width: Math.min(i, 4) * 10 }} />\n                  <span className=\"flex shrink-0 text-fg-4 [&_svg]:size-3.5\">{item.icon ?? <FolderGlyph />}</span>\n                  <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n                </>\n              );\n              const cls = \"relative flex h-8 cursor-default items-center gap-2 rounded-lg pr-2.5 pl-2 text-[13px] text-fg-2 outline-none select-none data-highlighted:text-fg pointer-coarse:h-10\";\n              return (\n                <Menu.LinkItem\n                  key={index}\n                  href={item.href}\n                  closeOnClick\n                  label={item.label}\n                  className={cls}\n                  onClick={(e) => onItemClick?.(item, index, e)}\n                  render={renderLink && item.href ? (props) => renderLink({ ...(props as LinkProps), href: item.href! }, item) : undefined}\n                >\n                  {face}\n                </Menu.LinkItem>\n              );\n            })}\n          </Menu.Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.Root>\n  );\n}\n\n// One highlight for the menu: it springs after the pointer and jumps for arrow keys.\nfunction Glide() {\n  const reduce = useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    const popup = ref.current?.parentElement;\n    if (!popup) return;\n    let keyboard = false;\n    let shown = false;\n    const place = () => {\n      const el = popup.querySelector<HTMLElement>(\"[data-highlighted]\");\n      if (!el) {\n        shown = false;\n        animate(opacity, 0, { duration: 0.12 });\n        return;\n      }\n      if (!shown || keyboard || reduce) {\n        y.jump(el.offsetTop);\n        height.jump(el.offsetHeight);\n        animate(opacity, 1, { duration: keyboard || reduce ? 0 : 0.08 });\n      } else {\n        animate(y, el.offsetTop, spring.follow);\n        animate(height, el.offsetHeight, spring.follow);\n      }\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onMove = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(popup, { subtree: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    popup.addEventListener(\"keydown\", onKey, true);\n    popup.addEventListener(\"pointermove\", onMove, true);\n    return () => {\n      observer.disconnect();\n      popup.removeEventListener(\"keydown\", onKey, true);\n      popup.removeEventListener(\"pointermove\", onMove, true);\n    };\n  }, [reduce, y, height, opacity]);\n\n  return <motion.div ref={ref} aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 -z-10 rounded-lg bg-fg/[0.06]\" />;\n}\n\nfunction FolderGlyph() {\n  return (\n    <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M2.5 4.25c0-.4.35-.75.75-.75h3l1.5 1.5h5c.4 0 .75.35.75.75v6.5c0 .4-.35.75-.75.75h-9.5a.75.75 0 0 1-.75-.75z\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/breadcrumbs"
}
