v1.0

Breadcrumbs

Preview

Basic

Loading…

Preview

Code

ts
import Breadcrumbs from '@/components/layout/Breadcrumbs';

src/components/layout/Breadcrumbs.tsx

AI prompt

text
Build a path-derived breadcrumb trail component in React + TypeScript + Tailwind CSS.

It lives in the app's top bar and is the page's only `<h1>`: the last crumb is the page title.

## Look
- `<nav aria-label="Breadcrumb" class="min-w-0">` → `<ol class="flex items-center gap-2 min-w-0 text-xs">`.
- First item: a link to "/", `flex items-center gap-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200`, a 14px lucide `Home` icon + the home label (`labels['/']`, else "Dashboard").
- Separators: a "/" in `text-slate-300 dark:text-slate-600`.
- Intermediate crumbs: plain `text-slate-500 dark:text-slate-400` text, NOT links — a grouping parent like "Settings" often has no page of its own, and a link to a 404 is worse than no link.
- Current page: `<h1 class="text-base font-semibold text-slate-800 dark:text-slate-100 truncate">`, in an `li` with `min-w-0` so a long title truncates.

## Behaviour
- Read the pathname from the router, split into segments; each crumb's href is the cumulative path.
- Label lookup, in order: `labels[href]` (the same href → name map the sidebar nav is built from, so the trail can never disagree with the nav) → a small static map for routes with no nav entry ('/profile' "My Profile", '/notifications' "Notifications", '/change-password' "Change Password", '/settings' "Settings") → the humanised segment (URI-decoded, split on "-", each word capitalised).
- Responsive: below `md` only the current page shows — home, intermediates and the separator before the current crumb are hidden. At "/" there is no current crumb and the home link shows at every width.

## API
`labels: Record<string, string>` — href → label.

## Demo
At /settings/members with `{ '/settings': 'Settings', '/settings/members': 'Members' }`: "⌂ Dashboard / Settings / **Members**".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: bonus-adjustment (96S2), verbatim. */

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Home } from 'lucide-react';

/**
 * The trail in the topbar, and the page's only <h1>.
 *
 * Labels come from the same `menus` table the sidebar renders from (passed down
 * as an href -> name map), so the trail can never disagree with the nav. Routes
 * with no menu row — /profile, /change-password — fall back to STATIC_LABELS
 * and then to a humanised segment.
 *
 * Intermediate segments render as plain text, not links: a grouping parent like
 * "Settings" has no page of its own, and a link to a 404 is worse than no link.
 */

const STATIC_LABELS: Record<string, string> = {
  '/profile': 'My Profile',
  // Reached from the bell's "See all", not from the sidebar — so it has no
  // menu row and needs its label here.
  '/notifications': 'Notifications',
  '/change-password': 'Change Password',
  '/settings': 'Settings',
};

const humanize = (segment: string) =>
  segment
    .split('-')
    .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
    .join(' ');

export default function Breadcrumbs({ labels }: { labels: Record<string, string> }) {
  const pathname = usePathname();
  const segments = pathname.split('/').filter(Boolean);

  const crumbs = segments.map((segment, i) => {
    const href = '/' + segments.slice(0, i + 1).join('/');
    return {
      href,
      label: labels[href] ?? STATIC_LABELS[href] ?? humanize(decodeURIComponent(segment)),
    };
  });

  const current = crumbs.at(-1) ?? null;
  const intermediates = crumbs.slice(0, -1);

  return (
    <nav aria-label="Breadcrumb" className="min-w-0">
      <ol className="flex items-center gap-2 min-w-0 text-xs">
        <li className={current ? 'hidden md:block' : 'min-w-0'}>
          <Link
            href="/"
            className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
          >
            <Home className="w-3.5 h-3.5" />
            {labels['/'] ?? 'Dashboard'}
          </Link>
        </li>
        {intermediates.map((crumb) => (
          <li key={crumb.href} className="hidden md:flex items-center gap-2">
            <span className="text-slate-300 dark:text-slate-600">/</span>
            <span className="text-slate-500 dark:text-slate-400">{crumb.label}</span>
          </li>
        ))}
        {current && (
          <li className="min-w-0 flex items-center gap-2">
            <span className="hidden md:inline text-slate-300 dark:text-slate-600">/</span>
            <h1 className="text-base font-semibold text-slate-800 dark:text-slate-100 truncate">
              {current.label}
            </h1>
          </li>
        )}
      </ol>
    </nav>
  );
}

Props

PropTypeDefaultDescription
labels*Record<string, string>—