Topbar
Preview
Basic
Loading…
Preview
Code
ts
import Topbar from '@/components/layout/Topbar';src/components/layout/Topbar.tsx
AI prompt
text
Build an admin app header bar component in React + TypeScript + Tailwind CSS.
The one header row of the app shell: sidebar toggle, breadcrumb trail, then notifications, appearance and account controls. Pages don't render their own heading — the trail's last crumb IS the page title, which keeps the two from ever disagreeing.
## Look
- `<header>`: `relative z-40 h-16 shrink-0 bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-b border-black/10 dark:border-white/10`; inside, `flex items-center justify-between h-full px-6`.
- Left cluster `flex items-center gap-4 flex-1 min-w-0` — the `min-w-0` is what lets the page name truncate instead of pushing the right cluster off screen:
- `lg` and up: collapse toggle, 32px `rounded-lg`, `text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700`, 16px `ChevronLeft` when expanded / `ChevronRight` when collapsed, title "Collapse sidebar" / "Expand sidebar".
- Below `lg`: a hamburger (20px `Menu`, `p-2 rounded-md text-slate-400`), aria-label "Open navigation", which opens the sidebar drawer.
- The breadcrumb trail.
- Right cluster `flex items-center gap-4 shrink-0`: notification bell, appearance button, account menu.
## Parts it composes
Build these alongside (or reuse your own):
- Sidebar state: the two toggles read `collapsed`, `toggleCollapsed` and `setOpen` from a small sidebar context (provider + hook) shared with the sidebar.
- Breadcrumbs, from the current path: a Home icon + home label, "/" separators in slate-300, intermediate segments as plain slate-500 text (not links), and the current page as the page's `<h1>` (`text-base font-semibold text-slate-800 dark:text-slate-100 truncate`). Labels come from an href → name map, falling back to the humanised segment; below `md` only the current page shows.
- Notification bell: a `p-2 rounded-lg` slate-400 icon button with a 16px `Bell` and an unread badge `absolute -top-1 -right-1 min-w-4 h-4 px-1 rounded-full bg-rose-500 text-white text-[10px] font-bold leading-none tabular-nums` (capped at "99+"), aria-label "Notifications, 2 unread". It opens a `w-80` right-aligned dropdown: a header row (indigo bell, bold "Notifications", "N unread" meta, a "Mark all read" link at the right), a `max-h-96` scrolling list of notification cards, and a footer link "See all notifications →" pinned below the list.
- Appearance button: a `SlidersHorizontal` icon button opening a light/dark + density panel.
- Account menu: avatar + chevron trigger opening a panel with name, email, roles and "Sign out".
## API
`labels: Record<string, string>` (href → crumb label), `user: { name, email, avatarUrl, roleNames: string[], departmentCode }`, `notifications`, `unreadCount: number`, `logoutAction: () => Promise<void>` (the sign-out server action).
## Demo
At /settings/members with labels for Settings and Members, user Ada Lovelace (Administrator · ENG), and four notifications — "Grace Hopper approved REQ-4471", "Alan Turing submitted REQ-4470" (both unread), a rejection and a batch release.
## 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 { ChevronLeft, ChevronRight, Menu } from 'lucide-react';
import { useSidebar } from '@/contexts/SidebarContext';
import Breadcrumbs from './Breadcrumbs';
import NotificationBell from '@/components/overlay/NotificationBell';
import ThemeSettings from './ThemeSettings';
import UserDropdown, { type DropdownUser } from './UserDropdown';
import type { HeaderNotification } from '@/components/overlay/NotificationCard';
/**
* The one header row, in marketing-stats' shape: a frosted h-16 bar carrying the
* sidebar toggle, the breadcrumb trail, and the notification/appearance/account
* controls.
*
* Pages don't render their own heading — the trail's last crumb IS the page
* title, which is what keeps the two from ever disagreeing.
*/
export default function Topbar({
labels,
user,
notifications,
unreadCount,
logoutAction,
}: {
labels: Record<string, string>;
user: DropdownUser;
notifications: HeaderNotification[];
unreadCount: number;
logoutAction: () => Promise<void>;
}) {
const { collapsed, toggleCollapsed, setOpen } = useSidebar();
return (
<header className="relative z-40 h-16 shrink-0 bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between h-full px-6">
{/* flex-1 min-w-0 lets the trail's current-page name truncate instead of
pushing the right-hand cluster off screen. */}
<div className="flex items-center gap-4 flex-1 min-w-0">
<button
type="button"
onClick={toggleCollapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className="hidden lg:flex items-center justify-center w-8 h-8 shrink-0 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open navigation"
className="lg:hidden p-2 rounded-md shrink-0 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<Menu className="w-5 h-5" />
</button>
<Breadcrumbs labels={labels} />
</div>
<div className="flex items-center gap-4 shrink-0">
<NotificationBell items={notifications} unread={unreadCount} />
<ThemeSettings />
<UserDropdown user={user} logoutAction={logoutAction} />
</div>
</div>
</header>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
labels* | Record<string, string> | — | |
user* | DropdownUser | — | |
notifications* | HeaderNotification[] | — | |
unreadCount* | number | — | |
logoutAction* | () => Promise<void> | — |