v1.0

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

PropTypeDefaultDescription
labels*Record<string, string>—
user*DropdownUser—
notifications*HeaderNotification[]—
unreadCount*number—
logoutAction*() => Promise<void>—