{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"breadcrumb","type":"registry:component","title":"Breadcrumb","description":"Composable breadcrumb trail with a hover underline that slides in from the left, links that render through your router via a render prop, and a data-driven items mode whose maxItems collapse folds middle crumbs into an ellipsis that either expands them inline with a spring layout reflow or opens a small keyboard-navigable menu.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/breadcrumb.tsx","type":"registry:component","target":"@components/motion/breadcrumb.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/breadcrumb\n\nimport { ChevronRight, Ellipsis } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT_CSS, SPRING_LAYOUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------------ */\n/* Composable primitives                                                     */\n/* ------------------------------------------------------------------------ */\n\nexport type BreadcrumbDataItem = {\n  label: ReactNode;\n  href?: string;\n  icon?: ReactNode;\n  /** Render the link through your router, e.g. `(p) => <Link {...p} />`. */\n  render?: (props: BreadcrumbLinkRenderProps) => ReactElement;\n};\n\ntype BreadcrumbNavProps = ComponentPropsWithoutRef<\"nav\">;\n\nexport type BreadcrumbProps = BreadcrumbNavProps & {\n  /** Data-driven mode. Omit to compose `BreadcrumbList` children yourself. */\n  items?: BreadcrumbDataItem[];\n  /** Collapse middle items into \"…\" when there are more than this many. */\n  maxItems?: number;\n  /** \"expand\" reveals hidden items inline; \"menu\" lists them in a dropdown. */\n  collapse?: \"expand\" | \"menu\";\n  separator?: ReactNode;\n};\n\nexport function Breadcrumb({\n  items,\n  maxItems = 4,\n  collapse = \"expand\",\n  separator,\n  className,\n  children,\n  ...props\n}: BreadcrumbProps) {\n  return (\n    <nav aria-label=\"Breadcrumb\" className={cn(\"min-w-0\", className)} {...props}>\n      {items ? (\n        <BreadcrumbTrail\n          items={items}\n          maxItems={maxItems}\n          collapse={collapse}\n          separator={separator}\n        />\n      ) : (\n        children\n      )}\n    </nav>\n  );\n}\n\nexport function BreadcrumbList({ className, ...props }: ComponentPropsWithoutRef<\"ol\">) {\n  return (\n    <ol\n      className={cn(\n        \"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function BreadcrumbItem({ className, ...props }: ComponentPropsWithoutRef<\"li\">) {\n  return <li className={cn(\"inline-flex min-w-0 items-center gap-1.5\", className)} {...props} />;\n}\n\nexport type BreadcrumbLinkRenderProps = {\n  href: string;\n  className: string;\n  children: ReactNode;\n};\n\nexport type BreadcrumbLinkProps = Omit<ComponentPropsWithoutRef<\"a\">, \"href\" | \"children\"> & {\n  href: string;\n  children: ReactNode;\n  /** Swap the `<a>` for a router link: `render={(p) => <Link {...p} />}`. */\n  render?: (props: BreadcrumbLinkRenderProps) => ReactElement;\n};\n\nconst linkClass =\n  \"group/crumb relative inline-flex min-w-0 items-center gap-1.5 rounded-sm text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\";\n\nfunction LinkContent({ children }: { children: ReactNode }) {\n  return (\n    <>\n      {children}\n      {/* Underline slides in from the left and leaves to the right: the origin\n          flips on hover, so scaleX grows and shrinks in the travel direction. */}\n      <span\n        aria-hidden\n        style={{ transitionTimingFunction: EASE_OUT_CSS }}\n        className=\"pointer-events-none absolute inset-x-0 -bottom-0.5 h-px origin-right scale-x-0 bg-current transition-transform duration-300 group-hover/crumb:origin-left group-hover/crumb:scale-x-100 group-focus-visible/crumb:origin-left group-focus-visible/crumb:scale-x-100 motion-reduce:transition-none\"\n      />\n    </>\n  );\n}\n\nexport function BreadcrumbLink({\n  href,\n  render,\n  className,\n  children,\n  ...props\n}: BreadcrumbLinkProps) {\n  const merged = cn(linkClass, className);\n  if (render) {\n    return render({ href, className: merged, children: <LinkContent>{children}</LinkContent> });\n  }\n  return (\n    <a href={href} className={merged} {...props}>\n      <LinkContent>{children}</LinkContent>\n    </a>\n  );\n}\n\nexport function BreadcrumbPage({ className, ...props }: ComponentPropsWithoutRef<\"span\">) {\n  return (\n    <span\n      aria-current=\"page\"\n      className={cn(\"inline-flex min-w-0 items-center gap-1.5 truncate font-medium text-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function BreadcrumbSeparator({\n  className,\n  children,\n  ...props\n}: ComponentPropsWithoutRef<\"li\">) {\n  return (\n    <li\n      aria-hidden=\"true\"\n      className={cn(\"inline-flex items-center text-muted-foreground/60 [&>svg]:size-3.5\", className)}\n      {...props}\n    >\n      {children ?? <ChevronRight />}\n    </li>\n  );\n}\n\nexport function BreadcrumbEllipsis({ className, ...props }: ComponentProps<\"button\">) {\n  return (\n    <button\n      type=\"button\"\n      className={cn(\n        \"inline-flex h-6 w-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n        className,\n      )}\n      {...props}\n    >\n      <Ellipsis className=\"size-4\" aria-hidden />\n    </button>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* Data-driven trail                                                         */\n/* ------------------------------------------------------------------------ */\n\nfunction CrumbBody({ item, current }: { item: BreadcrumbDataItem; current: boolean }) {\n  const content = (\n    <>\n      {item.icon ? (\n        <span aria-hidden className=\"inline-flex shrink-0 [&>svg]:size-3.5\">\n          {item.icon}\n        </span>\n      ) : null}\n      <span className=\"max-w-[12rem] truncate\">{item.label}</span>\n    </>\n  );\n  if (current || !item.href) return <BreadcrumbPage aria-current={current ? \"page\" : undefined}>{content}</BreadcrumbPage>;\n  return (\n    <BreadcrumbLink href={item.href} render={item.render}>\n      {content}\n    </BreadcrumbLink>\n  );\n}\n\ntype Entry =\n  | { kind: \"item\"; item: BreadcrumbDataItem; index: number }\n  | { kind: \"ellipsis\"; hidden: { item: BreadcrumbDataItem; index: number }[] };\n\nfunction BreadcrumbTrail({\n  items,\n  maxItems,\n  collapse,\n  separator,\n}: {\n  items: BreadcrumbDataItem[];\n  maxItems: number;\n  collapse: \"expand\" | \"menu\";\n  separator?: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const [expanded, setExpanded] = useState(false);\n  const firstRevealed = useRef<HTMLLIElement>(null);\n  const focusRevealed = useRef(false);\n\n  const indexed = items.map((item, index) => ({ item, index }));\n  const shouldCollapse = !expanded && items.length > Math.max(maxItems, 2);\n  const tail = Math.max(1, maxItems - 2);\n  const hidden = shouldCollapse ? indexed.slice(1, items.length - tail) : [];\n\n  const entries: Entry[] = shouldCollapse\n    ? [\n        { kind: \"item\", ...indexed[0] },\n        { kind: \"ellipsis\", hidden },\n        ...indexed.slice(items.length - tail).map((e) => ({ kind: \"item\" as const, ...e })),\n      ]\n    : indexed.map((e) => ({ kind: \"item\" as const, ...e }));\n\n  // The \"…\" button unmounts on expand; hand focus to the first revealed link\n  // so keyboard users are not dropped back on <body>.\n  useEffect(() => {\n    if (expanded && focusRevealed.current) {\n      focusRevealed.current = false;\n      firstRevealed.current?.querySelector<HTMLElement>(\"a,[tabindex]\")?.focus();\n    }\n  }, [expanded]);\n\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  return (\n    <LayoutGroup>\n      {/* relative: popLayout takes exiting crumbs out of flow, absolutely. */}\n      <BreadcrumbList className=\"relative\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {entries.flatMap((entry, i) => {\n            const key = entry.kind === \"item\" ? `item-${entry.index}` : \"ellipsis\";\n            const last = entry.kind === \"item\" && entry.index === items.length - 1;\n            const isFirstRevealed = expanded && entry.kind === \"item\" && entry.index === 1;\n            // Flat keyed <li>s (no Fragment) so AnimatePresence tracks each\n            // separator and crumb on its own and plays their exits.\n            const nodes: ReactElement[] = [];\n            if (i > 0) {\n              nodes.push(\n                <motion.li\n                  key={`sep-${key}`}\n                  layout=\"position\"\n                  aria-hidden=\"true\"\n                  transition={layoutTransition}\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  className=\"inline-flex items-center text-muted-foreground/60 [&>svg]:size-3.5\"\n                >\n                  {separator ?? <ChevronRight />}\n                </motion.li>,\n              );\n            }\n            nodes.push(\n                <motion.li\n                  key={key}\n                  ref={isFirstRevealed ? firstRevealed : undefined}\n                  layout=\"position\"\n                  transition={layoutTransition}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, x: -6, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  className=\"inline-flex min-w-0 items-center gap-1.5\"\n                >\n                  {entry.kind === \"item\" ? (\n                    <CrumbBody item={entry.item} current={last} />\n                  ) : collapse === \"menu\" ? (\n                    <CollapsedMenu hidden={entry.hidden} />\n                  ) : (\n                    <BreadcrumbEllipsis\n                      aria-label={`Show ${entry.hidden.length} more`}\n                      onClick={() => {\n                        focusRevealed.current = true;\n                        setExpanded(true);\n                      }}\n                    />\n                  )}\n                </motion.li>,\n            );\n            return nodes;\n          })}\n        </AnimatePresence>\n      </BreadcrumbList>\n    </LayoutGroup>\n  );\n}\n\nfunction CollapsedMenu({ hidden }: { hidden: { item: BreadcrumbDataItem; index: number }[] }) {\n  const reduce = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const menuId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const listRef = useRef<HTMLUListElement>(null);\n\n  useEffect(() => {\n    if (!open) return;\n    const onPointer = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointer);\n    listRef.current?.querySelector<HTMLElement>(\"a\")?.focus();\n    return () => document.removeEventListener(\"pointerdown\", onPointer);\n  }, [open]);\n\n  const close = (refocus: boolean) => {\n    setOpen(false);\n    if (refocus) triggerRef.current?.focus();\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!open) return;\n    const links = Array.from(listRef.current?.querySelectorAll<HTMLElement>(\"a\") ?? []);\n    const at = links.indexOf(document.activeElement as HTMLElement);\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      close(true);\n    } else if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      links[(at + 1) % links.length]?.focus();\n    } else if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      links[(at - 1 + links.length) % links.length]?.focus();\n    } else if (event.key === \"Tab\") {\n      setOpen(false);\n    }\n  };\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: delegates Escape/arrow keys for the popup's links\n    <div ref={rootRef} className=\"relative\" onKeyDown={onKeyDown}>\n      <BreadcrumbEllipsis\n        ref={triggerRef}\n        aria-label={`Show ${hidden.length} more`}\n        aria-expanded={open}\n        aria-controls={open ? menuId : undefined}\n        onClick={() => setOpen((o) => !o)}\n        className={cn(open && \"bg-muted text-foreground\")}\n      />\n      <AnimatePresence>\n        {open ? (\n          <motion.div\n            id={menuId}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -4 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -2, transition: { duration: 0.12 } }}\n            transition={SPRING_PANEL}\n            className=\"absolute left-0 top-full z-50 mt-2 min-w-44 origin-top-left rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-lg\"\n          >\n            <ul ref={listRef} className=\"flex flex-col\">\n              {hidden.map(({ item, index }) => (\n                <li key={index}>\n                  {item.href ? (\n                    (item.render ?? defaultMenuLink)({\n                      href: item.href,\n                      className:\n                        \"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:bg-muted focus-visible:text-foreground [&_svg]:size-3.5\",\n                      children: (\n                        <>\n                          {item.icon ? <span aria-hidden className=\"inline-flex\">{item.icon}</span> : null}\n                          <span className=\"truncate\">{item.label}</span>\n                        </>\n                      ),\n                    })\n                  ) : (\n                    <span className=\"flex items-center gap-2 px-2.5 py-1.5 text-sm text-muted-foreground\">\n                      {item.label}\n                    </span>\n                  )}\n                </li>\n              ))}\n            </ul>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction defaultMenuLink({ href, className, children }: BreadcrumbLinkRenderProps) {\n  return (\n    <a href={href} className={className}>\n      {children}\n    </a>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}