{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "breadcrumb",
  "title": "Breadcrumb",
  "description": "A trail from the root to the current page that folds its middle crumbs into a menu when it runs out of room, measured rather than guessed, with links that underline on hover.",
  "dependencies": [],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/dropdown-menu.json",
    "https://eased.dev/r/eased-floating.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/breadcrumb/breadcrumb.tsx",
      "content": "\"use client\";\n\nimport {\n  Fragment,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuLinkItem,\n  DropdownMenuTrigger,\n} from \"../dropdown-menu/dropdown-menu\";\nimport { floatingTrigger } from \"../_primitives/floating/floating\";\n\nexport type BreadcrumbItem = {\n  /** The visible label. Keep it short; long labels truncate. */\n  label: ReactNode;\n  /** Where the crumb goes. The last item is the current page and is never a link. */\n  href?: string;\n  /** For crumbs that act rather than navigate, e.g. opening a folder in place. */\n  onClick?: () => void;\n  /** A 16px icon before the label, e.g. a workspace logo on the first crumb. */\n  icon?: ReactNode;\n  /** The plain-text name used in the overflow menu and `title` when `label` is not a string. */\n  textValue?: string;\n};\n\nexport type BreadcrumbLinkProps = Omit<ComponentProps<\"a\">, \"href\"> & { href: string };\n\nexport type BreadcrumbProps = Omit<ComponentProps<\"nav\">, \"children\"> & {\n  /** From the root to the current page. The last item is the current page. */\n  items: readonly BreadcrumbItem[];\n  /** `chevron` (default), `slash`, or your own glyph. */\n  separator?: \"chevron\" | \"slash\" | ReactNode;\n  /**\n   * Fold middle crumbs into a \"…\" menu when the row would overflow its container: the first\n   * crumb and as many trailing ones as fit stay. Default true.\n   */\n  collapse?: boolean;\n  /** `sm` is 13px (toolbars, cards); `md` is 14px (page headers). Default \"md\". */\n  size?: \"sm\" | \"md\";\n  /**\n   * Your router's link, e.g. `(props) => <Link {...props} />`. It receives the href, the classes\n   * and the children. Default: a plain `<a>`.\n   */\n  renderLink?: (props: BreadcrumbLinkProps) => ReactElement;\n  /** The landmark's name. Default \"Breadcrumb\". */\n  \"aria-label\"?: string;\n  className?: string;\n};\n\n// The \"…\" trigger is pulled out by its own inset, so its dots sit one gap from each separator.\nconst SIZES = {\n  sm: { text: \"text-ui-sm\", gap: \"gap-1.5\", trigger: \"-mx-1 size-6 rounded-control-xs\" },\n  md: { text: \"text-ui\", gap: \"gap-2\", trigger: \"-mx-1.5 size-7 rounded-control-xs\" },\n} as const;\n\nconst Chevron = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n    <path d=\"M6.5 4l4 4-4 4\" />\n  </svg>\n);\n\nconst Slash = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" aria-hidden>\n    <path d=\"M10 2.5 6 13.5\" />\n  </svg>\n);\n\nconst Dots = () => (\n  <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"currentColor\" aria-hidden>\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);\n\n// The underline is there at rest in a transparent colour and tweens in with the text colour,\n// as Eased's other links do (150ms, standard curve); it never draws on touch.\nconst LINK =\n  \"inline-flex min-w-0 items-center gap-1.5 rounded-control-2xs text-text-muted underline decoration-transparent decoration-1 underline-offset-4 [transition:color_150ms_var(--eased-ease-standard),text-decoration-color_150ms_var(--eased-ease-standard),box-shadow_var(--eased-duration-focus)_var(--eased-ease-standard)] focus-ring hover-fine:text-text hover-fine:decoration-line-strong active:text-text\";\n\nconst textOf = (item: BreadcrumbItem) => item.textValue ?? (typeof item.label === \"string\" || typeof item.label === \"number\" ? String(item.label) : undefined);\n\n/**\n * A trail from the root to the current page. When the row runs out of room, the middle\n * crumbs fold into a \"…\" menu (the Eased Dropdown Menu) so the root and the current page stay\n * readable; the fit is measured, not guessed, so it holds for any label and any container.\n * Links underline on hover in the same 150ms tween as Eased's other links.\n */\nexport function Breadcrumb({\n  items,\n  separator = \"chevron\",\n  collapse = true,\n  size = \"md\",\n  renderLink,\n  \"aria-label\": ariaLabel = \"Breadcrumb\",\n  className = \"\",\n  ...rest\n}: BreadcrumbProps) {\n  const s = SIZES[size];\n  const rootRef = useRef<HTMLElement>(null);\n  const ghostRef = useRef<HTMLOListElement>(null);\n  // How many crumbs after the first are folded into the menu (items 1 … hidden).\n  const [hidden, setHidden] = useState(0);\n  const n = items.length;\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const ghost = ghostRef.current;\n    if (!root || !ghost || !collapse || n < 3) {\n      setHidden(0);\n      return;\n    }\n    const measure = () => {\n      const width = root.clientWidth;\n      const parts = [...ghost.querySelectorAll<HTMLElement>(\"[data-crumb]\")].map((el) => el.getBoundingClientRect().width);\n      const more = ghost.querySelector<HTMLElement>(\"[data-more]\")?.getBoundingClientRect().width ?? 0;\n      const gap = parseFloat(getComputedStyle(ghost).columnGap) || 0;\n      // Each crumb but the last is followed by a separator; the ghost measures them as pairs.\n      const sum = (from: number, to: number) => parts.slice(from, to).reduce((a, b) => a + b + gap, 0);\n      if (sum(0, n) - gap <= width + 0.5) return setHidden(0);\n      // Keep the first crumb, the \"…\" and as many trailing crumbs as fit; at least the last.\n      for (let tail = n - 2; tail >= 1; tail--) {\n        if (sum(0, 1) + more + gap + sum(n - tail, n) - gap <= width + 0.5) return setHidden(n - 1 - tail);\n      }\n      setHidden(n - 2);\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    ro.observe(ghost);\n    // Labels change width when a web font arrives after the first measure.\n    let live = true;\n    document.fonts?.ready.then(() => live && measure());\n    document.fonts?.addEventListener(\"loadingdone\", measure);\n    return () => {\n      live = false;\n      ro.disconnect();\n      document.fonts?.removeEventListener(\"loadingdone\", measure);\n    };\n  }, [collapse, n, items]);\n\n  const sep =\n    separator === \"chevron\" ? <Chevron /> : separator === \"slash\" ? <Slash /> : separator;\n\n  const Separator = () => (\n    <li role=\"presentation\" aria-hidden className=\"flex shrink-0 items-center text-text-muted\">\n      {sep}\n    </li>\n  );\n\n  const crumb = (item: BreadcrumbItem, current: boolean) => {\n    const title = textOf(item);\n    const inner = (\n      <>\n        {item.icon && <span className=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\">{item.icon}</span>}\n        <span className=\"truncate\">{item.label}</span>\n      </>\n    );\n    if (current) {\n      return (\n        <span aria-current=\"page\" title={title} className=\"inline-flex min-w-0 items-center gap-1.5 font-medium text-text\">\n          {inner}\n        </span>\n      );\n    }\n    if (item.href) {\n      const props: BreadcrumbLinkProps = { href: item.href, onClick: item.onClick, title, className: LINK, children: inner };\n      return renderLink ? renderLink(props) : <a {...props} />;\n    }\n    return (\n      <button type=\"button\" onClick={item.onClick} title={title} className={`cursor-pointer ${LINK}`}>\n        {inner}\n      </button>\n    );\n  };\n\n  const folded = items.slice(1, 1 + hidden);\n  const more = (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        aria-label={`${folded.length} more`}\n        render={\n          <button\n            type=\"button\"\n            className={`inline-flex shrink-0 items-center justify-center text-text-muted hover-fine:bg-surface-hover hover-fine:text-text data-popup-open:bg-surface-active data-popup-open:text-text ${floatingTrigger} ${s.trigger}`}\n          />\n        }\n      >\n        <Dots />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent>\n        {folded.map((item, i) =>\n          item.href ? (\n            <DropdownMenuLinkItem key={i} href={item.href} icon={item.icon}>\n              {textOf(item) ?? item.label}\n            </DropdownMenuLinkItem>\n          ) : (\n            <DropdownMenuItem key={i} icon={item.icon} onClick={item.onClick}>\n              {textOf(item) ?? item.label}\n            </DropdownMenuItem>\n          ),\n        )}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n\n  return (\n    <nav ref={rootRef} aria-label={ariaLabel} {...rest} className={`relative min-w-0 ${s.text} ${className}`}>\n      <ol className={`flex min-w-0 items-center ${s.gap}`}>\n        {items.map((item, i) => {\n          if (i >= 1 && i <= hidden) {\n            return i === 1 ? (\n              <Fragment key=\"more\">\n                <li className=\"flex shrink-0\">{more}</li>\n                <Separator />\n              </Fragment>\n            ) : null;\n          }\n          const current = i === n - 1;\n          return (\n            <Fragment key={i}>\n              {/* The current page takes what is left and truncates; ancestors cap at 12rem. */}\n              <li className={`flex items-center ${current ? \"min-w-0\" : \"min-w-0 max-w-48 shrink-0\"}`}>{crumb(item, current)}</li>\n              {!current && <Separator />}\n            </Fragment>\n          );\n        })}\n      </ol>\n\n      {/* The measuring copy: every crumb with its separator, and the \"…\" trigger, unconstrained. */}\n      {collapse && n >= 3 && (\n        <ol ref={ghostRef} aria-hidden inert className={`pointer-events-none invisible absolute top-0 left-0 flex h-0 items-center overflow-hidden whitespace-nowrap ${s.gap}`}>\n          {items.map((item, i) => (\n            <li key={i} data-crumb className={`flex items-center ${s.gap}`}>\n              <span className={`flex items-center gap-1.5 ${i === n - 1 ? \"font-medium\" : \"max-w-48\"}`}>\n                {item.icon && <span className=\"size-4 shrink-0\" />}\n                <span className=\"truncate\">{item.label}</span>\n              </span>\n              {i < n - 1 && <span className=\"flex shrink-0\">{sep}</span>}\n            </li>\n          ))}\n          <li data-more className={`flex shrink-0 ${s.gap}`}>\n            <span className={`shrink-0 ${s.trigger}`} />\n            <span className=\"flex shrink-0\">{sep}</span>\n          </li>\n        </ol>\n      )}\n    </nav>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/breadcrumb/breadcrumb.tsx"
    }
  ],
  "meta": {
    "industries": [
      "saas",
      "devtools",
      "legal",
      "ecommerce",
      "education"
    ]
  },
  "docs": "Import Breadcrumb from components/eased/breadcrumb/breadcrumb. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}