Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
NavigationUpdatedFreeWCAG 2.2 AA

Breadcrumbs

Hierarchical path navigation — collapses long trails.

Preview

Live preview
@oshon-ai/components
Canonical trail
Long trail — auto-collapse via maxItems
Auto-collapse with interactive `…` indicator
Click the `…` indicator to expand the full trail.
Custom separator (slash)
Five-size axis
size=xs
size=s
size=m
size=l
size=mobile

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add breadcrumbs

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Breadcrumbs } from '@oshon-ai/components';

export default function Example() {
  return <Breadcrumbs />;
}

Canonical — 3-crumb trail with auto-current

Last item (Q1 Sales) is auto-current — rendered as a static <span> with aria-current="page".

tsx
<div style={stack}>
      <h3 style={heading}>
        Last item (<code>Q1 Sales</code>) is auto-current — rendered as
        a static <code>&lt;span&gt;</code> with{' '}
        <code>aria-current="page"</code>.
      </h3>
      <Breadcrumbs items={CANONICAL_TRAIL} />
    </div>

Size matrix — typography density across xs/s/m/l/mobile

size="xs"

size="s"

size="m"

size="l"

size="mobile"

tsx
<div style={{ ...stack, gap: '1.25rem' }}>
      {SIZES.map((size) => (
        <section
          key={size}
          style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}
        >
          <p style={caption}>size=&quot;{size}&quot;</p>
          <Breadcrumbs size={size} items={CANONICAL_TRAIL} />
        </section>
      ))}
    </div>

With a leading

Per-item icon slot — typically used at the root.

tsx
<div style={stack}>
      <h3 style={heading}>
        Per-item <code>icon</code> slot — typically used at the root.
      </h3>
      <Breadcrumbs
        items={[
          { label: 'Home', href: '/', icon: <HomeIcon /> },
          { label: 'Settings', href: '/settings' },
          { label: 'Profile' },
        ]}
      />
    </div>

Custom separator — slash instead of chevron

Pass any ReactNode as separator.

tsx
<div style={stack}>
      <h3 style={heading}>
        Pass any ReactNode as <code>separator</code>.
      </h3>
      <Breadcrumbs
        separator={<span aria-hidden="true">/</span>}
        items={CANONICAL_TRAIL}
      />
    </div>

Auto-collapse — middle items collapse into `…` above maxItems

7-crumb trail with maxItems=4. Root + last 2 stay visible; middle 4 collapse into a static indicator (count is announced via aria-label).

tsx
<div style={stack}>
      <h3 style={heading}>
        7-crumb trail with <code>maxItems=4</code>. Root + last 2 stay
        visible; middle 4 collapse into a static <code>…</code>{' '}
        indicator (count is announced via <code>aria-label</code>).
      </h3>
      <Breadcrumbs items={DEEP_TRAIL} maxItems={4} />
    </div>

Auto-collapse + onCollapsedClick (consumer popover wiring)

Wire onCollapsedClick to receive the array of hidden crumbs. Drop a Popover-anchored Menuwith the labels for full keyboard navigation.

Last click revealed:

tsx
{
    function Demo() {
      const [lastClick, setLastClick] = useState<string[]>([]);
      return (
        <div style={stack}>
          <h3 style={heading}>
            Wire <code>onCollapsedClick</code> to receive the array of
            hidden crumbs. Drop a Popover-anchored <code>Menu</code>
            with the labels for full keyboard navigation.
          </h3>
          <p style={caption}>
            Last click revealed: <code>{lastClick.length ? lastClick.map((l) => l).join(', ') : '—'}</code>
          </p>
          <Breadcrumbs
            items={DEEP_TRAIL}
            maxItems={4}
            onCollapsedClick={(hidden) =>
              setLastClick(hidden.map((h) => h.label))
            }
          />
        </div>
      );
    }
    return <Demo />;
  }

Pin board has only an onClick (no href) — renders as a <button> that could trigger an overlay / drawer / picker.

Pin-board opened: 0 ×

tsx
{
    function Demo() {
      const [opened, setOpened] = useState(0);
      return (
        <div style={stack}>
          <h3 style={heading}>
            <code>Pin board</code> has only an <code>onClick</code>{' '}
            (no href) — renders as a <code>&lt;button&gt;</code> that
            could trigger an overlay / drawer / picker.
          </h3>
          <p style={caption}>Pin-board opened: {opened} ×</p>
          <Breadcrumbs
            items={[
              { label: 'Workspace', href: '/' },
              { label: 'Pin board', onClick: () => setOpened((n) => n + 1) },
              { label: 'Card' },
            ]}
          />
        </div>
      );
    }
    return <Demo />;
  }

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Breadcrumbs
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

items

Required array of `BreadcrumbItem` — `{ label, href?, onClick?, icon?, current? }`. Order is root → current page. Each item resolves to a link, button, or static label by precedence: `current: true` ⇒ static current; `href` ⇒ `<a href>`; `onClick` ⇒ `<button>`; else static dimmed.

separator

Optional ReactNode rendered between every pair of crumbs. Default: a 12 px chevron-right SVG using `currentColor`. Pass `<span>/</span>`, `→`, or any other glyph to swap.

item.icon

Optional per-item leading glyph — typically a 12 px SVG. Sized via the size-axis CSS custom property and given a 6 px trailing gap. Use for the canonical Home icon at index 0.

Keyboard

Renders a `<nav aria-label="Breadcrumb">` landmark wrapping an `<ol>` of crumbs. Tab order follows DOM order: leading items → collapsed `…` indicator (when present + click-handled) → trailing items. The current-page crumb is a non-interactive `<span>` carrying `aria-current="page"`. Separators are `<li role="presentation" aria-hidden="true">` so screen readers walk the crumbs as a clean ordered list. All interactive crumbs render their native focus ring (2 px primary-500 outline at 2 px offset).

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-30

Do / Don't

✓ Do

Basic 3-crumb trail
<Breadcrumbs
  items={[
    { label: 'Home', href: '/' },
    { label: 'Reports', href: '/reports' },
    { label: 'Q1 Sales' },
  ]}
/>
With a leading home icon
<Breadcrumbs
  items={[
    { label: 'Home', href: '/', icon: <HomeIcon /> },
    { label: 'Settings', href: '/settings' },
    { label: 'Profile' },
  ]}
/>
Auto-collapse + click-to-expand into a popover menu
<Breadcrumbs
  items={deepTrail}
  maxItems={4}
  onCollapsedClick={(hidden, e) => openMenu(e.currentTarget, hidden)}
/>
Slash separator + custom-rendered intermediate item
<Breadcrumbs
  separator={<span aria-hidden="true">/</span>}
  items={[
    { label: 'Workspace', href: '/' },
    { label: 'Pin board', onClick: () => modal.open() },
    { label: 'Card' },
  ]}
/>

✗ Don't

Using Breadcrumbs as a primary nav
<Breadcrumbs items={[{ label: 'Home' }, { label: 'About' }, { label: 'Contact' }]} />

Breadcrumbs are *page-context* — they describe the user's location in a hierarchy. Top-level site sections belong in a `Tabs` / `GlobalToolbar` nav, not in a crumb trail.

Empty items array
<Breadcrumbs items={[]} />

A breadcrumb trail with zero crumbs has no semantic meaning. Conditionally render the component instead: `items.length > 0 && <Breadcrumbs items={items} />`.

Linking the current page to itself
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Reports', href: '/reports' }]} />

The last item is the current page; rendering it as a self-link confuses assistive tech (the user is already there) and breaks the `aria-current="page"` semantics. Drop the `href` from the trailing crumb so the component renders it as a static `<span>`.

Forcing visual width via `style.width`
<Breadcrumbs style={{ width: 800 }} items={items} />

The crumb trail is fluid — it wraps onto multiple rows when the parent narrows. Pinning a width breaks the responsive flow and the auto-collapse heuristic. Constrain the parent container instead.

Design rationale

A breadcrumb trail is one of the best-tested usability primitives in web UI; this implementation follows the WAI-ARIA Breadcrumb Pattern verbatim (`<nav aria-label="Breadcrumb"><ol>`, `aria-current="page"` on the trailing crumb, `aria-hidden` on separators) so screen-reader behavior is canonical without bespoke ARIA wiring. The auto-collapse middle-cluster pattern is borrowed from Material UI / Carbon — keeping the root + parent + current visible while hiding the depths produces a stable visual width across deep trees, and exposing `onCollapsedClick` lets consumers wire a Popover (Phase 4 primitive) without forcing one on every page. The five-size axis flips three CSS custom properties so a single declaration cascades to every nested element — no per-size class needed.