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
| Prop | Type | Default | Description |
|---|---|---|---|
labels* | Record<string, string> | — |