Skip to content
UI Kit
Accent color

Breadcrumbs for React

Where a page sits, as a trail, a collapsed trail with a menu or a contained bar. On phones a back link.

One of 6 components in Navigation elements

2 variants

Where a page sits, at a glance

Breadcrumbs show where a page sits in the site and offer a way one level up. This component renders a nav with an ordered list, marks the current page with aria-current and keeps the separators out of what screen readers announce.

Separators shows the trail with a chevron, a slash or a dot, for shops, docs and blogs. Collapsed and contained shortens deep trails to the first and last items with a menu for the middle, and puts the trail into a contained bar under a header. On phones every variant turns into a single back link to the parent, which is what people tap there.

Start the trail at the home page and end it with the current page as plain text. Use the same names as the navigation. Add BreadcrumbList data next to it, search engines show the trail instead of the bare address.

Variants

Details

What Breadcrumbs brings along, read from the registry and the source.

Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/breadcrumbs.tsx
Brings along
utilskiticondropdown-menu
npm packages
None
Languages
English and German, 3 texts of its own
Studio
Adjust in code

Props

The BreadcrumbsProps interface from components/ui/breadcrumbs.tsx, with the comments from the code.

  • itemsrequired
    Crumb[]
  • separatoroptional
    'chevron' | 'slash' | 'dot'
  • variantoptional
    'default' | 'contained'

    contained: on a quiet pill-shaped surface

  • maxItemsoptional
    number

    Collapse the middle into a menu when there are more items

  • backOnMobileoptional
    boolean

    On phones show only a back link to the parent page. Default true.

Built-in texts

Breadcrumbs writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 3 texts
labelEnglish BreadcrumbGerman Brotkrumen
moreEnglish Show hidden pagesGerman Ausgeblendete Seiten zeigen
backEnglish Back toGerman Zurück zu

Accessibility

Read from the source of the component.

  • Breadcrumbs handles the keys Home itself.
  • Sets aria-current and aria-label, so screen readers announce purpose and state.
  • Uses the role presentation.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 133 lines
components/ui/breadcrumbs.tsx
'use client';

import { useRef } from 'react';
import { cn, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { DropdownMenu } from '@/components/ui/dropdown-menu';

const TEXTS = { en: { label: 'Breadcrumb', more: 'Show hidden pages', back: 'Back to' }, de: { label: 'Brotkrumen', more: 'Ausgeblendete Seiten zeigen', back: 'Zurück zu' } };

/* The trail lays itself out from the start, one step after another, when it scrolls into view */
const STYLES =
  '.kitfx-bc-in>li,.kitfx-bc-in>.kitfx-bc-back{animation:kitfx-bc-in .55s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 45ms)}' +
  '@keyframes kitfx-bc-in{from{opacity:0;transform:translateX(-8px)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-bc-in>li,.kitfx-bc-in>.kitfx-bc-back{animation:none}}';

/* A line that grows from the start under a link on hover and focus */
const UNDERLINE =
  'relative after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:rounded-full after:bg-current after:opacity-60 after:transition-transform after:duration-300 after:ease-[var(--ease-kit)] hover:after:scale-x-100 focus-visible:after:scale-x-100 motion-reduce:after:transition-none';

export interface Crumb {
  label: string;
  href?: string;
  icon?: IconName;
}

export interface BreadcrumbsProps {
  items: Crumb[];
  separator?: 'chevron' | 'slash' | 'dot';
  /** contained: on a quiet pill-shaped surface */
  variant?: 'default' | 'contained';
  /** Collapse the middle into a menu when there are more items */
  maxItems?: number;
  /** On phones show only a back link to the parent page. Default true. */
  backOnMobile?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

function Sep({ kind }: { kind: NonNullable<BreadcrumbsProps['separator']> }) {
  if (kind === 'slash') return <span aria-hidden className="select-none text-border-strong">/</span>;
  if (kind === 'dot') return <span aria-hidden className="size-[3px] rounded-full bg-border-strong" />;
  return <Icon name="chevron-right" size={14} className="text-subtle-foreground" />;
}

/**
 * Where the page sits in the site. The last item is the current page (aria-current).
 *   <Breadcrumbs items={[{ label: 'Home', href: '/', icon: 'home' }, { label: 'Docs', href: '/docs' }, { label: 'Tabs' }]} />
 */
export function Breadcrumbs({ items, separator = 'chevron', variant = 'default', maxItems, backOnMobile = true, labels, className }: BreadcrumbsProps) {
  const t = useTexts(TEXTS, labels);
  const collapse = maxItems !== undefined && items.length > maxItems && maxItems >= 3;
  const head = collapse ? items.slice(0, 1) : items;
  const hidden = collapse ? items.slice(1, items.length - (maxItems! - 2)) : [];
  const tail = collapse ? items.slice(items.length - (maxItems! - 2)) : [];
  const parent = items.length > 1 ? items[items.length - 2] : undefined;
  const [ref, inView] = useInView<HTMLElement>();
  const waited = useRef(false);
  if (!inView) waited.current = true;
  const entering = inView && waited.current;
  let order = 0;

  const crumb = (c: Crumb, last: boolean) => (
    <li key={c.label} className="flex min-w-0 items-center" style={{ '--i': order++ } as React.CSSProperties}>
      {last || !c.href ? (
        <span aria-current={last ? 'page' : undefined} className={cn('flex min-w-0 items-center gap-1.5 truncate', last ? 'font-medium text-foreground' : 'text-muted-foreground')}>
          {c.icon && <Icon name={c.icon} size={15} />}
          <span className="truncate">{c.label}</span>
        </span>
      ) : (
        <a href={c.href} className={cn('flex min-w-0 items-center gap-1.5 rounded-md text-muted-foreground transition-colors hover:text-foreground', UNDERLINE)}>
          {c.icon && <Icon name={c.icon} size={15} />}
          <span className={cn('truncate', c.icon && c === items[0] && 'max-sm:sr-only')}>{c.label}</span>
        </a>
      )}
    </li>
  );
  const sep = (k: string) => (
    <li key={k} role="presentation" aria-hidden className="flex items-center" style={{ '--i': order++ } as React.CSSProperties}>
      <Sep kind={separator} />
    </li>
  );

  const list: React.ReactNode[] = [];
  head.forEach((c, i) => {
    if (i) list.push(sep(`s${i}`));
    list.push(crumb(c, !collapse && i === items.length - 1));
  });
  if (collapse) {
    list.push(sep('sh'));
    list.push(
      <li key="more" className="flex items-center" style={{ '--i': order++ } as React.CSSProperties}>
        <DropdownMenu
          items={hidden.map((h) => ({ label: h.label, icon: h.icon, href: h.href }))}
          trigger={
            <button type="button" aria-label={t.more} className="group/more grid h-7 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground">
              <Icon name="more-horizontal" size={16} className="transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-hover/more:scale-110 group-aria-expanded/more:scale-90 motion-reduce:transition-none" />
            </button>
          }
        />
      </li>,
    );
    tail.forEach((c, i) => {
      list.push(sep(`st${i}`));
      list.push(crumb(c, i === tail.length - 1));
    });
  }

  return (
    <nav ref={ref} aria-label={t.label} className={cn('min-w-0 text-sm', className)}>
      <style href="kitfx-bc" precedence="default">
        {STYLES}
      </style>
      <ol
        className={cn(
          'flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1',
          !inView && 'opacity-0',
          entering && 'kitfx-bc-in',
          variant === 'contained' && 'inline-flex rounded-full border border-border bg-surface px-3.5 py-1.5 shadow-[var(--highlight)]',
          backOnMobile && parent && 'max-sm:hidden',
        )}
      >
        {list}
      </ol>
      {backOnMobile && parent && (
        <a href={parent.href} className={cn('group/back inline-flex min-h-11 items-center gap-1.5 font-medium text-muted-foreground transition-[color,opacity,translate] duration-500 ease-[var(--ease-kit)] hover:text-foreground sm:hidden', !inView && '-translate-x-2 opacity-0')}>
          <Icon name="chevron-left" size={16} className="transition-transform duration-200 ease-[var(--ease-kit)] group-hover/back:-translate-x-0.5 motion-reduce:transition-none" />
          <span className="sr-only">{t.back} </span>
          {parent.label}
        </a>
      )}
    </nav>
  );
}

Frequent questions

How do I install Breadcrumbs?

With aiw add @ai-website/breadcrumbs. The command puts components/ui/breadcrumbs.tsx into your project, the code is yours from then on. It brings along icon and dropdown-menu. It needs no further npm packages.

Which variants does Breadcrumbs have?

2 variants, Separators and Collapsed and contained.

Can I translate the texts of Breadcrumbs?

Yes. Breadcrumbs carries 3 texts of its own in English and German, such as “Breadcrumb”, “Show hidden pages” and “Back to”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Breadcrumbs?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Breadcrumbs with aiw_components and install it with aiw_add @ai-website/breadcrumbs. Name the variant in your prompt, for example variant="contained", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard