# Breadcrumb

> A trail of links to the current page that wraps safely, mirrors in right-to-left layouts and expands collapsed segments in place.

Docs: https://hextaui.com/docs/breadcrumb
Markdown: https://hextaui.com/docs/breadcrumb.md

```tsx title="components/examples/breadcrumb/demo.tsx"
import {
  Breadcrumb,
  BreadcrumbCollapse,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbDemo() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbCollapse>
          <BreadcrumbItem>
            <BreadcrumbLink href="#">Workspace</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbLink href="#">Design system</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
        </BreadcrumbCollapse>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Components</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/breadcrumb.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/breadcrumb.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { IconChevronRight, IconDots } from "@tabler/icons-react"
import { cn } from "cn"

import { duration, easeSpring } from "@/lib/motion"

type BreadcrumbCollapseContextValue = {
  group: string
  open: boolean
  settled: boolean
}

const BreadcrumbCollapseContext =
  React.createContext<BreadcrumbCollapseContextValue | null>(null)

function collapseAttributes(
  context: BreadcrumbCollapseContextValue | null,
  collapsed: boolean
) {
  if (!context) {
    return {}
  }

  return {
    "data-breadcrumb-group": context.group,
    "data-collapsed": collapsed && context.settled ? "" : undefined,
  } as Record<string, string | undefined>
}

function Breadcrumb({
  className,
  render,
  ...props
}: useRender.ComponentProps<"nav">) {
  return useRender({
    defaultTagName: "nav",
    render,
    props: mergeProps<"nav">(
      { "aria-label": "Breadcrumb", className: cn("min-w-0", className) },
      props,
      { "data-slot": "breadcrumb" } as React.ComponentProps<"nav">
    ),
  })
}

function BreadcrumbList({
  className,
  render,
  ...props
}: useRender.ComponentProps<"ol">) {
  return useRender({
    defaultTagName: "ol",
    render,
    props: mergeProps<"ol">(
      {
        className: cn(
          "flex min-w-0 flex-wrap items-center gap-1.5 text-sm wrap-anywhere text-muted-foreground",
          className
        ),
      },
      props,
      { "data-slot": "breadcrumb-list" } as React.ComponentProps<"ol">
    ),
  })
}

function BreadcrumbItem({
  className,
  render,
  ...props
}: useRender.ComponentProps<"li">) {
  const collapse = React.useContext(BreadcrumbCollapseContext)

  return useRender({
    defaultTagName: "li",
    render,
    props: mergeProps<"li">(
      {
        className: cn(
          "inline-flex min-w-0 items-center gap-1 data-collapsed:hidden",
          className
        ),
      },
      props,
      {
        "data-slot": "breadcrumb-item",
        ...collapseAttributes(collapse, collapse ? !collapse.open : false),
      } as React.ComponentProps<"li">
    ),
  })
}

function BreadcrumbLink({
  className,
  render,
  ...props
}: useRender.ComponentProps<"a">) {
  return useRender({
    defaultTagName: "a",
    render,
    props: mergeProps<"a">(
      {
        className: cn(
          "relative -mx-1 inline-flex min-w-0 items-center gap-1 rounded-sm px-1 transition-[color,box-shadow] duration-150 ease-out-quint outline-none hover:text-foreground focus-visible:z-10 focus-visible:text-foreground focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4",
          className
        ),
      },
      props,
      { "data-slot": "breadcrumb-link" } as React.ComponentProps<"a">
    ),
  })
}

function BreadcrumbPage({
  className,
  render,
  ...props
}: useRender.ComponentProps<"span">) {
  return useRender({
    defaultTagName: "span",
    render,
    props: mergeProps<"span">(
      {
        "aria-current": "page",
        className: cn(
          "inline-flex min-w-0 items-center gap-1 font-normal text-foreground [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4",
          className
        ),
      },
      props,
      { "data-slot": "breadcrumb-page" } as React.ComponentProps<"span">
    ),
  })
}

function BreadcrumbSeparator({
  children,
  className,
  render,
  ...props
}: useRender.ComponentProps<"li">) {
  const collapse = React.useContext(BreadcrumbCollapseContext)

  return useRender({
    defaultTagName: "li",
    render,
    props: mergeProps<"li">(
      {
        role: "presentation",
        "aria-hidden": true,
        children: children ?? <IconChevronRight className="rtl:-scale-x-100" />,
        className: cn(
          "inline-flex shrink-0 items-center text-muted-foreground/60 select-none [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-3.5 [&[data-collapsed]:has(+[data-collapsed])]:hidden",
          className
        ),
      },
      props,
      {
        "data-slot": "breadcrumb-separator",
        ...collapseAttributes(collapse, collapse ? !collapse.open : false),
      } as React.ComponentProps<"li">
    ),
  })
}

function BreadcrumbEllipsis({
  children,
  className,
  render,
  ...props
}: useRender.ComponentProps<"span">) {
  const interactive = render !== undefined || props.onClick !== undefined

  return useRender({
    defaultTagName: "span",
    render,
    props: mergeProps<"span">(
      {
        ...(interactive
          ? {}
          : { role: "presentation", "aria-hidden": true as const }),
        children: children ?? (
          <>
            <IconDots aria-hidden />
            <span className="sr-only">More</span>
          </>
        ),
        className: cn(
          "relative inline-flex size-5 shrink-0 items-center justify-center rounded-sm outline-none focus-visible:outline-hidden [&:is(a,button)]:size-6 [&:is(a,button)]:cursor-pointer [&:is(a,button)]:transition-[color,background-color,box-shadow] [&:is(a,button)]:duration-150 [&:is(a,button)]:ease-out-quint [&:is(a,button)]:after:absolute [&:is(a,button)]:after:-inset-x-1 [&:is(a,button)]:after:-inset-y-2.5 [&:is(a,button)]:hover:bg-muted [&:is(a,button)]:hover:text-foreground [&:is(a,button)]:focus-visible:z-10 [&:is(a,button)]:focus-visible:text-foreground [&:is(a,button)]:focus-visible:ring-3 [&:is(a,button)]:focus-visible:ring-focus-ring [&:is(a,button)]:aria-expanded:bg-muted [&:is(a,button)]:aria-expanded:text-foreground [&>svg]:pointer-events-none [&>svg:not([class*='size-'])]:size-4",
          className
        ),
      },
      props,
      { "data-slot": "breadcrumb-ellipsis" } as React.ComponentProps<"span">
    ),
  })
}

const focusableSelector =
  'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'

function groupElements(anchor: HTMLElement, group: string) {
  const list = anchor.parentElement

  if (!list) {
    return []
  }

  return Array.from(list.children).filter(
    (node): node is HTMLElement =>
      node instanceof HTMLElement && node.dataset.breadcrumbGroup === group
  )
}

function togglesWithCollapse(element: HTMLElement, group: string) {
  if (element.dataset.slot !== "breadcrumb-separator") {
    return true
  }

  const next = element.nextElementSibling

  return next instanceof HTMLElement && next.dataset.breadcrumbGroup === group
}

function clearAnimationStyles(element: HTMLElement) {
  element.style.overflow = ""
  element.style.whiteSpace = ""
  element.style.minWidth = ""
}

type BreadcrumbCollapseProps = {
  children?: React.ReactNode
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
  label?: string
}

function BreadcrumbCollapse({
  children,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  label = "Show full path",
}: BreadcrumbCollapseProps) {
  const group = React.useId()
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen
  const [previousOpen, setPreviousOpen] = React.useState(open)
  const [settled, setSettled] = React.useState(true)
  const triggerItemRef = React.useRef<HTMLLIElement>(null)
  const animationsRef = React.useRef(new Map<HTMLElement, Animation>())
  const runRef = React.useRef(0)
  const restoreFocusRef = React.useRef(false)

  if (open !== previousOpen) {
    setPreviousOpen(open)
    setSettled(false)
  }

  React.useLayoutEffect(() => {
    const triggerItem = triggerItemRef.current

    if (settled || !triggerItem) {
      return
    }

    const run = ++runRef.current
    const animations = animationsRef.current
    const elements = [triggerItem, ...groupElements(triggerItem, group)]
    const toggled = elements.filter(
      (element) =>
        element === triggerItem || togglesWithCollapse(element, group)
    )

    if (toggled.some((element) => element.contains(document.activeElement))) {
      restoreFocusRef.current = true
    }

    if (typeof triggerItem.animate !== "function") {
      setSettled(true)
      return
    }

    const reduceMotion =
      typeof window.matchMedia !== "function" ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    const list = triggerItem.parentElement
    const gap = list ? parseFloat(getComputedStyle(list).columnGap) || 0 : 0

    const starts = new Map<HTMLElement, number | null>()

    for (const element of toggled) {
      const running = animations.get(element)
      starts.set(
        element,
        running ? element.getBoundingClientRect().width : null
      )
      running?.cancel()
      animations.delete(element)
    }

    const pending = toggled.map((element) => {
      const entering = element === triggerItem ? !open : open
      const natural = element.getBoundingClientRect().width
      const start = starts.get(element) ?? (entering ? 0 : natural)
      const end = entering ? natural : 0
      const progress = natural > 0 ? start / natural : entering ? 0 : 1

      element.style.overflow = "hidden"
      element.style.whiteSpace = "nowrap"
      element.style.minWidth = "0px"

      const animation = element.animate(
        [
          {
            width: `${start}px`,
            marginInlineEnd: `${-gap * (1 - progress)}px`,
            opacity: progress,
          },
          {
            width: `${end}px`,
            marginInlineEnd: entering ? "0px" : `${-gap}px`,
            opacity: entering ? 1 : 0,
          },
        ],
        {
          duration: reduceMotion ? 0 : duration.morph,
          easing: easeSpring,
          fill: "forwards",
        }
      )

      animations.set(element, animation)

      return animation.finished
    })

    Promise.all(pending).then(
      () => {
        if (runRef.current === run) {
          setSettled(true)
        }
      },
      () => {}
    )
  }, [group, open, settled])

  React.useLayoutEffect(() => {
    if (!settled) {
      return
    }

    const animations = animationsRef.current

    for (const [element, animation] of animations) {
      animation.cancel()
      clearAnimationStyles(element)
    }

    animations.clear()

    const triggerItem = triggerItemRef.current

    if (!restoreFocusRef.current || !triggerItem) {
      return
    }

    restoreFocusRef.current = false

    const target = open
      ? groupElements(triggerItem, group)
          .map((element) =>
            element.matches(focusableSelector)
              ? element
              : element.querySelector<HTMLElement>(focusableSelector)
          )
          .find((element) => element?.checkVisibility?.() ?? true)
      : triggerItem.querySelector<HTMLElement>(focusableSelector)

    target?.focus()
  }, [group, open, settled])

  React.useEffect(() => {
    const animations = animationsRef.current

    return () => {
      for (const [element, animation] of animations) {
        animation.cancel()
        clearAnimationStyles(element)
      }

      animations.clear()
    }
  }, [])

  const context = React.useMemo(
    () => ({ group, open, settled }),
    [group, open, settled]
  )

  return (
    <BreadcrumbCollapseContext.Provider value={context}>
      <li
        ref={triggerItemRef}
        data-slot="breadcrumb-collapse"
        data-collapsed={open && settled ? "" : undefined}
        className="inline-flex shrink-0 items-center data-collapsed:hidden"
      >
        <BreadcrumbEllipsis
          render={<button type="button" aria-label={label} />}
          tabIndex={open ? -1 : undefined}
          onClick={() => {
            if (openProp === undefined) {
              setUncontrolledOpen(true)
            }
            onOpenChange?.(true)
          }}
        />
      </li>
      {children}
    </BreadcrumbCollapseContext.Provider>
  )
}

export {
  Breadcrumb,
  BreadcrumbList,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbPage,
  BreadcrumbSeparator,
  BreadcrumbEllipsis,
  BreadcrumbCollapse,
}
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
```

```tsx
<Breadcrumb>
  <BreadcrumbList>
    <BreadcrumbItem>
      <BreadcrumbLink href="/">Home</BreadcrumbLink>
    </BreadcrumbItem>
    <BreadcrumbSeparator />
    <BreadcrumbItem>
      <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
    </BreadcrumbItem>
  </BreadcrumbList>
</Breadcrumb>
```

## Composition

```text
Breadcrumb
└── BreadcrumbList
    ├── BreadcrumbItem
    │   └── BreadcrumbLink
    ├── BreadcrumbSeparator
    ├── BreadcrumbCollapse
    │   ├── BreadcrumbItem
    │   └── BreadcrumbSeparator
    └── BreadcrumbItem
        └── BreadcrumbPage
```

## Examples

### Basic

```tsx title="components/examples/breadcrumb/basic.tsx"
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbBasic() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Components</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### With icons

Icons sit inside links and the current page. Give an icon-only link an `aria-label`.

```tsx title="components/examples/breadcrumb/with-icons.tsx"
import { IconFolder, IconHome } from "@tabler/icons-react"

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbWithIcons() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="#" aria-label="Home">
            <IconHome />
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbLink href="#">
            <IconFolder />
            Projects
          </BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>
            <IconFolder />
            hextaui
          </BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### Custom separator

Pass any element as `children` of `<BreadcrumbSeparator />` to replace the chevron.

```tsx title="components/examples/breadcrumb/custom-separator.tsx"
import { IconPointFilled } from "@tabler/icons-react"

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbCustomSeparator() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator>
          <IconPointFilled className="size-2" />
        </BreadcrumbSeparator>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Components</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator>
          <IconPointFilled className="size-2" />
        </BreadcrumbSeparator>
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### Dropdown

Render a `<BreadcrumbLink />` as the trigger of a `<DropdownMenu />` to offer sibling pages.

```tsx title="components/examples/breadcrumb/dropdown.tsx"
import { IconChevronDown } from "@tabler/icons-react"

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"

export function BreadcrumbDropdown() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <DropdownMenu>
            <DropdownMenuTrigger
              render={<BreadcrumbLink render={<button type="button" />} />}
            >
              Components
              <IconChevronDown />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="start">
              <DropdownMenuGroup>
                <DropdownMenuItem>Documentation</DropdownMenuItem>
                <DropdownMenuItem>Themes</DropdownMenuItem>
                <DropdownMenuItem>GitHub</DropdownMenuItem>
              </DropdownMenuGroup>
            </DropdownMenuContent>
          </DropdownMenu>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### Collapsed

Wrap middle segments in `<BreadcrumbCollapse />`. They hide behind an ellipsis and slide open in place, and focus moves to the first revealed link. Pass `open` and `onOpenChange` to control it.

```tsx title="components/examples/breadcrumb/controlled.tsx"
"use client"

import * as React from "react"

import {
  Breadcrumb,
  BreadcrumbCollapse,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"

export function BreadcrumbControlled() {
  const [open, setOpen] = React.useState(false)

  return (
    <div className="flex flex-col items-center gap-4">
      <Breadcrumb>
        <BreadcrumbList>
          <BreadcrumbItem>
            <BreadcrumbLink href="#">Home</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbCollapse open={open} onOpenChange={setOpen}>
            <BreadcrumbItem>
              <BreadcrumbLink href="#">Documents</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbLink href="#">2026</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
          </BreadcrumbCollapse>
          <BreadcrumbItem>
            <BreadcrumbPage>Invoices</BreadcrumbPage>
          </BreadcrumbItem>
        </BreadcrumbList>
      </Breadcrumb>
      <Button variant="outline" size="sm" onClick={() => setOpen(!open)}>
        {open ? "Collapse" : "Expand"}
      </Button>
    </div>
  )
}
```

### Ellipsis

A static `<BreadcrumbEllipsis />` marks skipped levels. It is hidden from assistive tech unless you make it interactive.

```tsx title="components/examples/breadcrumb/ellipsis.tsx"
import {
  Breadcrumb,
  BreadcrumbEllipsis,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbEllipsisDemo() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink href="#">Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbEllipsis />
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### Link component

Use the `render` prop to swap the anchor for your router's link, like Next.js `<Link />`.

```tsx title="components/examples/breadcrumb/link-component.tsx"
import Link from "next/link"

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbLinkComponent() {
  return (
    <Breadcrumb>
      <BreadcrumbList>
        <BreadcrumbItem>
          <BreadcrumbLink render={<Link href="/" />}>Home</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbLink render={<Link href="/docs" />}>Docs</BreadcrumbLink>
        </BreadcrumbItem>
        <BreadcrumbSeparator />
        <BreadcrumbItem>
          <BreadcrumbPage>Breadcrumb</BreadcrumbPage>
        </BreadcrumbItem>
      </BreadcrumbList>
    </Breadcrumb>
  )
}
```

### Long content

Unbroken names wrap instead of overflowing, and segments with a max width truncate.

```tsx title="components/examples/breadcrumb/long-content.tsx"
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbLongContent() {
  return (
    <div className="flex w-72 flex-col gap-4">
      <Breadcrumb>
        <BreadcrumbList>
          <BreadcrumbItem>
            <BreadcrumbLink href="#">Home</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbLink href="#">
              averyveryverylongunbrokenfoldernamethatshouldneverescape
            </BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbPage>
              An unusually long page title that wraps onto a second line
            </BreadcrumbPage>
          </BreadcrumbItem>
        </BreadcrumbList>
      </Breadcrumb>
      <Breadcrumb>
        <BreadcrumbList>
          <BreadcrumbItem>
            <BreadcrumbLink href="#">
              <span className="max-w-24 truncate">
                Customer success playbooks
              </span>
            </BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbItem>
            <BreadcrumbPage>
              <span className="max-w-32 truncate">
                Onboarding checklist for enterprise accounts
              </span>
            </BreadcrumbPage>
          </BreadcrumbItem>
        </BreadcrumbList>
      </Breadcrumb>
    </div>
  )
}
```

### Right to left

Separators flip and the collapse animates from the correct side.

```tsx title="components/examples/breadcrumb/rtl.tsx"
import {
  Breadcrumb,
  BreadcrumbCollapse,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"

export function BreadcrumbRtl() {
  return (
    <div dir="rtl">
      <Breadcrumb>
        <BreadcrumbList>
          <BreadcrumbItem>
            <BreadcrumbLink href="#">الرئيسية</BreadcrumbLink>
          </BreadcrumbItem>
          <BreadcrumbSeparator />
          <BreadcrumbCollapse label="عرض المسار الكامل">
            <BreadcrumbItem>
              <BreadcrumbLink href="#">المكونات</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbLink href="#">التنقل</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
          </BreadcrumbCollapse>
          <BreadcrumbItem>
            <BreadcrumbPage>مسار التنقل</BreadcrumbPage>
          </BreadcrumbItem>
        </BreadcrumbList>
      </Breadcrumb>
    </div>
  )
}
```

## Keyboard

Links and the collapse button are regular tab stops. Separators and a static ellipsis are skipped.

| Key | Action |
| --- | --- |
| `Tab` `Shift + Tab` | Moves between links and the collapse button. |
| `Enter` | Follows the focused link. |
| `Enter` `Space` | On the collapse button, reveals the hidden segments. Once they finish sliding open, focus moves to the first revealed link. |

## API reference

Every part accepts the attributes of the element it renders, plus a `render` prop to swap that element.

### Breadcrumb

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<nav>` | Labelled “Breadcrumb” for assistive tech. |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb"` | The <nav> landmark. |

### BreadcrumbList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<ol>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-list"` | The ordered list. Items wrap onto new lines when space runs out. |

### BreadcrumbItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<li>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-item"` | Target items in CSS. |
| `data-collapsed` | Present while the item is hidden inside a closed BreadcrumbCollapse. |
| `data-breadcrumb-group` | Present on items inside a BreadcrumbCollapse. Shared by every part of the same group. |

### BreadcrumbLink

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `href` | `string` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<a>` | Pass your router’s link component here. |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-link"` | Target links in CSS. |

### BreadcrumbPage

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<span>` | Marked with aria-current="page". |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-page"` | The current page, with aria-current="page". |

### BreadcrumbSeparator

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | `<IconChevronRight />` | Mirrors automatically in right-to-left layouts. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<li>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-separator"` | Presentational and hidden from assistive tech. |
| `data-collapsed` | Present while the separator is hidden inside a closed BreadcrumbCollapse. |
| `data-breadcrumb-group` | Present on separators inside a BreadcrumbCollapse. |

### BreadcrumbEllipsis

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | `<IconDots />` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<span>` | Hidden from assistive tech unless render or onClick makes it interactive. |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-ellipsis"` | Target the ellipsis in CSS. |

### BreadcrumbCollapse

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | The items and separators to hide behind the ellipsis. |
| `open` | `boolean` | – |  |
| `defaultOpen` | `boolean` | `false` |  |
| `onOpenChange` | `(open: boolean) => void` | – |  |
| `label` | `string` | `"Show full path"` | Accessible name of the ellipsis button. |

| Attribute | Description |
| --- | --- |
| `data-slot="breadcrumb-collapse"` | The <li> that holds the ellipsis button. |
| `data-collapsed` | Present on the ellipsis item once the group has opened, which hides it. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
