Layout
Preview
Basic
Loading…
Preview
Code
ts
import Layout from '@/components/layout/Layout';src/components/layout/Layout.tsx
AI prompt
text
Build a responsive admin app layout component in React + TypeScript + Tailwind CSS (v4, with the container-queries syntax `@container` / `@3xl:`). One component, one file, no context provider needed. Icons from lucide-react.
## Structure
- Root: full-height flex row (height prop, default `100dvh`), `overflow-hidden`, a soft diagonal gradient ground — light: slate-100 → slate-200 → slate-300; dark: slate-900 → slate-950 → black.
- Left: a sidebar. Right: a column of header over content.
- `<main>` is the ONLY scroll container (`min-h-0 flex-1 overflow-auto`). Never nest another scroller — absolutely-positioned dropdowns inside would get clipped.
## Sidebar
- Frosted panel: `bg-white/85 dark:bg-slate-900/85 backdrop-blur-xl`, hairline right border (`black/10`, dark `white/10`).
- Top: brand row the same height as the header — a 32px rounded-lg square filled with the accent colour holding the logo (or the brand's initials), then the product name in bold.
- Below: nav sections, each with an optional heading (10px, bold, uppercase, wide tracking, slate-400). Rows are 13px medium text, rounded-lg, an 18px icon, then the label, then an optional count badge (accent-filled pill, 10px, tabular numbers).
- Active row: soft accent tint background + accent text and icon. Hover: slate-100 (dark: slate-800/70).
- Groups: a row with a chevron that expands its children, indented under the parent with a thin left border. The group containing the active route opens automatically.
- Only the nav list scrolls; the brand stays put.
- Optional footer slot pinned to the bottom.
## Collapse to an icon rail
- A header button (chevron left/right) collapses the sidebar from 15rem to a 4.5rem icon rail: labels become screen-reader only, section headings become a thin divider, and badges become an accent dot on the icon.
- Hovering or focusing the collapsed rail widens it back to full width OVER the page with a large shadow (the rail keeps a fixed footprint in the row, the panel is absolutely positioned) so the content never reflows under the pointer.
- Option `hoverExpand={false}`: no widening — links show a tooltip on hover, groups open as a flyout panel beside the rail (portalled, with a short close delay so the pointer can cross the gap).
- Animate width with a 300ms ease-in-out transition.
## Narrow widths
- Decide by the COMPONENT's width, not the viewport: a container query at 48rem (`@3xl`), plus a ResizeObserver for the logic that must know it. The layout works inside a preview pane or split view.
- Below 48rem the sidebar becomes an off-canvas drawer: slides in from the left with a shadow, a dimmed, slightly blurred backdrop, a close button, and Escape closes it. The header shows a hamburger button instead of the collapse button.
## Header
- Frosted bar: `bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl`, bottom hairline border, height set by density (48 / 56 / 64px).
- Left to right: drawer/collapse button, breadcrumbs, header menus, a search slot, an actions slot (e.g. notification bell), theme settings button, user menu.
- Breadcrumbs are derived from the nav tree and the active route (optional home crumb with a house icon). The current page is bold and truncates; on narrow widths only the current page is shown.
- Header menus: links or dropdowns (panel with icon, label and a one-line description per item); hidden below 56rem.
- Icon buttons: 32px, rounded-lg, slate-500, hover slate-100, accent-coloured focus ring.
## Theme settings panel (from the header)
- A dropdown panel with: Mode (Light / Dark / System, segmented control with icons), Accent (six swatches: indigo, violet, sky, emerald, amber, rose — the selected one shows a check), Density (compact / standard / comfortable), Content (fluid or boxed at max-w-5xl, centred), Sidebar (expanded / collapsed), and a Reset link.
- The accent reaches the markup as CSS variables on the root (`--accent` = Tailwind 600 step, `--accent-dark` = 400 step for dark mode, `--accent-soft` = 50 step), so one setting recolours every active row, badge and focus ring.
- Density changes header height, row padding and content padding.
- `themeScope`: "document" toggles the `dark` class on <html>; "shell" applies it to the layout root only (for previews).
## User menu (far right)
- Trigger: avatar (image or initials) + chevron. Panel: avatar, name, email, role in accent colour, then menu items with icons, optional dividers, and a red "Sign out" item at the bottom when `onSignOut` is passed.
## Behaviour & API
- Props: `brand {name, logo?, href?}`, `nav` (sections of items: `{id, label, icon?, href?, badge?, children?}`), `activeHref`, `onNavigate(href)` (to route through a client router; without it links are plain anchors), `breadcrumbs` override, `home`, `headerMenus`, `search`, `actions`, `user`, `userMenu`, `onSignOut`, `appearance` / `onAppearanceChange` / `defaultAppearance`, `collapsed` / `onCollapsedChange` / `defaultCollapsed`, `hoverExpand`, `storageKey`, `themeScope`, `sidebarFooter`, `height`, `className`, `children`.
- Appearance and collapsed state are controlled when passed with their callbacks, otherwise kept internally and persisted to localStorage under `storageKey` — read after mount, never during render, so server and first client render agree.
- Every popover (header menus, theme panel, user menu) closes on outside click and on Escape.
## Quality bar
- Every colour has a `dark:` pair. Visible focus rings on everything interactive. Correct ARIA: `aria-current="page"` on the active link, `aria-expanded` on toggles, `role="menu"` / `role="radiogroup"` where they apply, `aria-label` on icon-only buttons.
- Text truncates instead of pushing controls off the edge (`min-w-0` on flex children).
- Include a small demo: a helpdesk app with Dashboard, Tickets (with a badge and child routes), Reports and Settings, a notification bell in the actions slot, and a user named Avery Stone.
## 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';
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type MouseEvent,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Home,
LogOut,
Menu,
Monitor,
Moon,
SlidersHorizontal,
Sun,
X,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
/* ------------------------------------------------------------------ types --- */
/** A sidebar row: a link (`href`), or a group of rows (`children`). */
export type ShellNavItem = {
id: string;
label: string;
icon?: ReactNode;
href?: string;
/** A count beside the label; a dot on the icon when the rail is collapsed. */
badge?: number;
children?: ShellNavItem[];
};
/** Sidebar rows under an optional heading. */
export type ShellNavSection = { label?: string; items: ShellNavItem[] };
/** A header menu: a link, or a dropdown of links. */
export type ShellHeaderMenu = {
id: string;
label: string;
icon?: ReactNode;
href?: string;
items?: Array<{ label: string; href?: string; icon?: ReactNode; description?: string; onSelect?: () => void }>;
};
export type ShellUser = { name: string; email: string; avatarUrl?: string | null; role?: string };
export type ShellUserMenuItem = {
label: string;
icon?: ReactNode;
href?: string;
onSelect?: () => void;
/** Draw a divider above this item. */
divider?: boolean;
};
export type ShellCrumb = { label: string; href?: string };
export type ShellAccent = keyof typeof ACCENTS;
/** Everything the theme settings panel changes. */
export type ShellAppearance = {
mode: 'light' | 'dark' | 'system';
accent: ShellAccent;
density: 'compact' | 'standard' | 'comfortable';
content: 'fluid' | 'boxed';
};
export const DEFAULT_APPEARANCE: ShellAppearance = {
mode: 'light',
accent: 'indigo',
density: 'standard',
content: 'fluid',
};
/* ------------------------------------------------------------- constants --- */
/** Light ink, dark ink and the light tint of each accent — Tailwind's 600, 400 and 50 steps. */
const ACCENTS = {
indigo: ['#4f46e5', '#818cf8', '#eef2ff'],
violet: ['#7c3aed', '#a78bfa', '#f5f3ff'],
sky: ['#0284c7', '#38bdf8', '#f0f9ff'],
emerald: ['#059669', '#34d399', '#ecfdf5'],
amber: ['#d97706', '#fbbf24', '#fffbeb'],
rose: ['#e11d48', '#fb7185', '#fff1f2'],
} as const;
const DENSITY = {
compact: { header: 'h-12', row: 'py-1.5', main: 'px-3 py-3 @3xl:px-4' },
standard: { header: 'h-14', row: 'py-2', main: 'px-3 py-4 @3xl:px-6' },
comfortable: { header: 'h-16', row: 'py-2.5', main: 'px-4 py-6 @3xl:px-8' },
} as const;
// The accent reaches the markup as CSS variables set on the root, so one
// setting recolours every active row, badge and focus ring without a class per
// colour. Dark mode reads the lighter step, which holds contrast on slate-900.
const ACCENT_INK = 'text-[var(--sh-a)] dark:text-[var(--sh-a-dark)]';
const ACCENT_FILL = 'bg-[var(--sh-a)] dark:bg-[var(--sh-a-dark)]';
const ACCENT_SOFT = 'bg-[var(--sh-a-soft)] dark:bg-[color-mix(in_srgb,var(--sh-a-dark)_16%,transparent)]';
const FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--sh-a)] dark:focus-visible:ring-[var(--sh-a-dark)]';
// No display utility here: callers pick `inline-flex` or `hidden @3xl:inline-flex`,
// and two display classes on one element fight, since `cn()` does not merge.
const ICON_BTN = cn(
'h-8 w-8 shrink-0 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200',
FOCUS,
);
const MENU_ITEM =
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-xs text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800';
const RAIL = '@3xl:w-[4.5rem]';
const WIDE = '@3xl:w-60';
/* ---------------------------------------------------------------- helpers --- */
/** Segment-aware, so `/reports` never lights up for `/reports-archive`. */
const matches = (active: string | undefined, href: string | undefined) =>
!!active && !!href && (active === href || (href !== '/' && active.startsWith(`${href}/`)));
const isActiveTree = (item: ShellNavItem, active?: string): boolean =>
matches(active, item.href) || (item.children ?? []).some((c) => isActiveTree(c, active));
/** The rows from the top of the nav down to the active one — the breadcrumb trail. */
function trailFor(sections: ShellNavSection[], active?: string): ShellCrumb[] {
let best: ShellNavItem[] = [];
const walk = (items: ShellNavItem[], path: ShellNavItem[]) => {
for (const item of items) {
const here = [...path, item];
// The LONGEST matching href wins: on /tickets/42, "Tickets" and not "Home".
if (matches(active, item.href) && (item.href?.length ?? 0) > (best.at(-1)?.href?.length ?? -1)) best = here;
if (item.children) walk(item.children, here);
}
};
for (const s of sections) walk(s.items, []);
return best.map((i) => ({ label: i.label, href: i.href }));
}
/** Every group containing the active row — opened when the route changes. */
function activeGroups(sections: ShellNavSection[], active?: string): string[] {
const out: string[] = [];
const walk = (items: ShellNavItem[]) => {
for (const item of items) {
if (item.children?.length && isActiveTree(item, active)) {
out.push(item.id);
walk(item.children);
}
}
};
for (const s of sections) walk(s.items);
return out;
}
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]!.toUpperCase())
.join('');
function UserAvatar({ user, size = 8 }: { user: ShellUser; size?: 8 | 10 }) {
const box = size === 8 ? 'h-8 w-8 text-[11px]' : 'h-10 w-10 text-xs';
return user.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.avatarUrl} alt="" className={cn(box, 'shrink-0 rounded-full object-cover')} />
) : (
<span aria-hidden className={cn(box, ACCENT_FILL, 'inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white dark:text-slate-900')}>
{initials(user.name || user.email)}
</span>
);
}
const REDUCED_DARK = '(prefers-color-scheme: dark)';
const subscribeScheme = (cb: () => void) => {
const mq = window.matchMedia(REDUCED_DARK);
mq.addEventListener('change', cb);
return () => mq.removeEventListener('change', cb);
};
/* ------------------------------------------------------------------ shell --- */
/**
* THE WHOLE APP FRAME — sidebar, header and content as one component, in the
* shape of ticket-management's signed-in layout: a frosted sidebar beside a
* column of header over content, on a soft gradient ground. Pass the nav and
* the user; everything else has a working default.
*
* sidebar sections of links and nested groups with badges; collapses to
* an icon rail that widens OVER the page on hover or focus
* (so the content never reflows under the pointer), with a
* flyout per group when `hoverExpand` is off; off-canvas with a
* backdrop when the shell is narrow
* header sidebar toggle, breadcrumbs derived from the nav, header
* menus with dropdowns, a slot for search and actions, theme
* settings, the user menu
* content the one scroll container, fluid or boxed
* theming light, dark or system; six accents; three densities; fluid or
* boxed content — from the header's settings panel
*
* ── Narrow is decided by the SHELL's width ──────────────────────────────────
*
* The desktop-or-drawer switch is a container query on the shell (`@3xl`), not
* a viewport breakpoint, so a shell inside a preview pane, a split view or a
* docs page is laid out for the room it actually has.
*
* ── State ───────────────────────────────────────────────────────────────────
*
* `appearance` and `collapsed` are controlled when passed with their change
* callbacks, and otherwise kept here — in `localStorage` too, under
* `storageKey`, read after mount so the server render never disagrees with the
* first client one. An app that renders on the server and wants the rail at
* the right width on the FIRST paint reads a cookie and passes it as
* `defaultCollapsed`, which is what ticket-management does.
*
* `themeScope="document"` (the default) puts the `dark` class on <html>, as an
* app wants; `"shell"` puts it on the shell alone, for a preview inside a page
* with its own theme. Links are plain anchors; pass `onNavigate` to route them
* through a client router instead.
*/
export default function Layout({
brand,
nav,
activeHref,
onNavigate,
breadcrumbs,
home,
headerMenus = [],
search,
actions,
user,
userMenu = [],
onSignOut,
appearance: appearanceProp,
onAppearanceChange,
defaultAppearance,
collapsed: collapsedProp,
onCollapsedChange,
defaultCollapsed = false,
hoverExpand = true,
storageKey,
themeScope = 'document',
sidebarFooter,
height = '100dvh',
className,
children,
}: {
brand: { name: string; logo?: ReactNode; href?: string };
nav: ShellNavSection[];
/** The current route; lights its row, opens its group and builds the breadcrumbs. */
activeHref?: string;
/** Route a link click yourself (a client router). Without it links are plain navigations. */
onNavigate?: (href: string) => void;
/** Override the trail derived from the nav. */
breadcrumbs?: ShellCrumb[];
/** A first crumb with a home icon, before the derived trail. */
home?: ShellCrumb;
headerMenus?: ShellHeaderMenu[];
search?: ReactNode;
/** Header controls before the settings button — a notification bell, a help link. */
actions?: ReactNode;
user: ShellUser;
userMenu?: ShellUserMenuItem[];
/** Adds "Sign out" to the user menu. */
onSignOut?: () => void;
appearance?: ShellAppearance;
onAppearanceChange?: (next: ShellAppearance) => void;
defaultAppearance?: Partial<ShellAppearance>;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
defaultCollapsed?: boolean;
/** A collapsed rail widens while the pointer or focus is in it. Off: groups open as flyouts. */
hoverExpand?: boolean;
/** Keep uncontrolled appearance and collapse in `localStorage` under this key. */
storageKey?: string;
themeScope?: 'document' | 'shell';
sidebarFooter?: ReactNode;
/** The shell's height. The content column scrolls inside it. */
height?: string | number;
className?: string;
children: ReactNode;
}) {
const [innerAppearance, setInnerAppearance] = useState<ShellAppearance>({ ...DEFAULT_APPEARANCE, ...defaultAppearance });
const [innerCollapsed, setInnerCollapsed] = useState(defaultCollapsed);
const appearance = appearanceProp ?? innerAppearance;
const collapsed = collapsedProp ?? innerCollapsed;
const [mobileOpen, setMobileOpen] = useState(false);
const [peek, setPeek] = useState(false);
const [narrow, setNarrow] = useState(false);
const loaded = useRef(false);
const rootRef = useRef<HTMLDivElement>(null);
// Flyouts and tooltips from the rail render here: the sidebar clips its
// overflow, and the body would lose the shell's `dark` class and accent
// variables. The root has no transform, so `fixed` inside it is still
// relative to the viewport and its `overflow-hidden` does not clip it.
const [layer, setLayer] = useState<HTMLDivElement | null>(null);
// Narrow means the drawer layout — the same line as the `@3xl` container
// query (48rem), measured, because the rail's labels are markup, not CSS.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
setNarrow(entry.contentRect.width < 48 * rem);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const systemDark = useSyncExternalStore(subscribeScheme, () => window.matchMedia(REDUCED_DARK).matches, () => false);
const dark = appearance.mode === 'dark' || (appearance.mode === 'system' && systemDark);
const save = (next: { appearance?: ShellAppearance; collapsed?: boolean }) => {
if (!storageKey) return;
try {
const prev = JSON.parse(window.localStorage.getItem(storageKey) ?? '{}');
window.localStorage.setItem(storageKey, JSON.stringify({ ...prev, ...next }));
} catch {
// Storage is a convenience; losing it loses nothing live.
}
};
// Stored preferences, once, after mount — never during render, which the
// server has no `localStorage` for.
useEffect(() => {
if (!storageKey || loaded.current) return;
loaded.current = true;
try {
const saved = JSON.parse(window.localStorage.getItem(storageKey) ?? 'null') as {
appearance?: Partial<ShellAppearance>;
collapsed?: boolean;
} | null;
if (saved?.appearance && !appearanceProp) setInnerAppearance((a) => ({ ...a, ...saved.appearance }));
if (typeof saved?.collapsed === 'boolean' && collapsedProp === undefined) setInnerCollapsed(saved.collapsed);
} catch {
// Corrupt or blocked storage: the defaults stand.
}
}, [storageKey]); // eslint-disable-line react-hooks/exhaustive-deps
const setAppearance = (patch: Partial<ShellAppearance>) => {
const next = { ...appearance, ...patch };
onAppearanceChange?.(next);
if (!appearanceProp) {
setInnerAppearance(next);
save({ appearance: next });
}
};
const setCollapsed = (next: boolean) => {
onCollapsedChange?.(next);
if (collapsedProp === undefined) {
setInnerCollapsed(next);
save({ collapsed: next });
}
setPeek(false);
};
// Document scope: the app's theme is the page's theme.
useEffect(() => {
if (themeScope !== 'document') return;
document.documentElement.classList.toggle('dark', dark);
}, [dark, themeScope]);
// The drawer is a modal on a narrow shell: Escape closes it.
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setMobileOpen(false);
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [mobileOpen]);
const go = (href: string | undefined, e?: MouseEvent) => {
setMobileOpen(false);
if (!href || !onNavigate) return;
e?.preventDefault();
onNavigate(href);
};
const [a, aDark, aSoft] = ACCENTS[appearance.accent] ?? ACCENTS.indigo;
const density = DENSITY[appearance.density] ?? DENSITY.standard;
const trail = [...(home ? [home] : []), ...(breadcrumbs ?? trailFor(nav, activeHref))];
// A collapsed rail exists only on a wide shell; the narrow drawer always has labels.
const rail = collapsed && !peek && !narrow;
const openIds = activeGroups(nav, activeHref);
return (
<div
ref={rootRef}
className={cn(
'@container relative flex overflow-hidden bg-gradient-to-br from-slate-100 via-slate-200 to-slate-300 text-slate-800 dark:from-slate-900 dark:via-slate-950 dark:to-black dark:text-slate-100',
themeScope === 'shell' && dark && 'dark',
className,
)}
style={{ height, '--sh-a': a, '--sh-a-dark': aDark, '--sh-a-soft': aSoft } as CSSProperties}
>
<div ref={setLayer} />
{/* Narrow shell: the drawer's backdrop — over the header (z-40), under the drawer (z-50). */}
{mobileOpen && (
<div aria-hidden className="absolute inset-0 z-[45] bg-slate-900/40 backdrop-blur-[1px] @3xl:hidden" onClick={() => setMobileOpen(false)} />
)}
{/* The rail's footprint in the row. The panel itself is absolute over
it, so widening on hover overlays the page instead of reflowing it. */}
<div className={cn('relative w-0 shrink-0 transition-[width] duration-300', collapsed ? RAIL : WIDE)}>
<aside
aria-label="Main navigation"
onMouseEnter={() => collapsed && hoverExpand && setPeek(true)}
onMouseLeave={() => setPeek(false)}
onFocus={() => collapsed && hoverExpand && setPeek(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setPeek(false);
}}
className={cn(
'absolute inset-y-0 left-0 z-50 flex w-64 flex-col overflow-hidden border-r border-black/10 bg-white/85 backdrop-blur-xl transition-[width,transform,box-shadow] duration-300 ease-in-out dark:border-white/10 dark:bg-slate-900/85',
mobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full',
'@3xl:translate-x-0',
rail ? RAIL : WIDE,
collapsed && peek && '@3xl:shadow-2xl',
)}
>
<div className={cn('flex shrink-0 items-center gap-3 border-b border-slate-200 dark:border-slate-700/70', density.header, rail ? 'justify-center px-2' : 'px-4')}>
<a
href={brand.href ?? '#'}
onClick={(e) => (brand.href ? go(brand.href, e) : e.preventDefault())}
className={cn('flex min-w-0 items-center gap-2.5 rounded-lg', FOCUS)}
>
<span className={cn(ACCENT_FILL, 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-white dark:text-slate-900 [&>svg]:h-4 [&>svg]:w-4')}>
{brand.logo ?? <span className="text-xs font-bold">{initials(brand.name)}</span>}
</span>
<span className={cn('truncate text-sm font-bold text-slate-900 transition-opacity duration-200 dark:text-white', rail && 'sr-only')}>{brand.name}</span>
</a>
<button type="button" onClick={() => setMobileOpen(false)} aria-label="Close navigation" className={cn(ICON_BTN, 'ml-auto inline-flex @3xl:hidden')}>
<X className="h-4 w-4" />
</button>
</div>
{/* The scroll lives here, not on the panel, so the brand stays put
and a long nav scrolls under it. */}
<nav className={cn('custom-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-4 pt-3', rail ? 'px-2' : 'px-3')}>
{nav.map((section, si) => (
<div key={section.label ?? si} className={cn(si > 0 && 'mt-4')}>
{section.label &&
(rail ? (
<div aria-hidden className="mx-3 mb-2 border-t border-slate-200 dark:border-slate-700/70" />
) : (
<p className="mb-1.5 px-3 text-[10px] font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">{section.label}</p>
))}
<ul className="space-y-0.5">
{section.items.map((item) => (
<NavRow
key={item.id}
item={item}
level={0}
rail={rail}
flyouts={collapsed && !hoverExpand}
activeHref={activeHref}
rowPad={density.row}
go={go}
openIds={openIds}
layer={layer}
/>
))}
</ul>
</div>
))}
</nav>
{sidebarFooter && !rail && <div className="shrink-0 border-t border-slate-200 p-3 dark:border-slate-700/70">{sidebarFooter}</div>}
</aside>
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header
className={cn(
'relative z-40 flex shrink-0 items-center gap-2 border-b border-black/10 bg-white/70 px-3 backdrop-blur-xl dark:border-white/10 dark:bg-slate-900/70 @3xl:gap-3 @3xl:px-5',
density.header,
)}
>
<button type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation" className={cn(ICON_BTN, 'inline-flex @3xl:hidden')}>
<Menu className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={cn(ICON_BTN, 'hidden @3xl:inline-flex')}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
<Crumbs trail={trail} go={go} homeIcon={!!home} />
{headerMenus.length > 0 && (
<nav aria-label="Header" className="hidden items-center gap-0.5 @4xl:flex">
{headerMenus.map((m) => (
<HeaderMenu key={m.id} menu={m} active={matches(activeHref, m.href)} go={go} />
))}
</nav>
)}
{search}
{actions}
<ThemePanel appearance={appearance} set={setAppearance} collapsed={collapsed} setCollapsed={setCollapsed} />
<UserMenu user={user} items={userMenu} onSignOut={onSignOut} go={go} />
</header>
<main className="custom-scrollbar min-h-0 flex-1 overflow-auto">
<div className={cn(density.main, appearance.content === 'boxed' && 'mx-auto w-full max-w-5xl')}>{children}</div>
</main>
</div>
</div>
);
}
/* --------------------------------------------------------------- nav row --- */
function NavRow({
item,
level,
rail,
flyouts,
activeHref,
rowPad,
go,
openIds,
layer,
}: {
item: ShellNavItem;
level: number;
rail: boolean;
flyouts: boolean;
activeHref?: string;
rowPad: string;
go: (href: string | undefined, e?: MouseEvent) => void;
openIds: string[];
layer: HTMLElement | null;
}) {
const group = !!item.children?.length;
const liRef = useRef<HTMLLIElement>(null);
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const floating = rail && flyouts;
// The pointer crosses an 8px gap between the rail and a flyout; closing on a
// short timer, cancelled by entering the flyout, keeps it from vanishing mid-way.
const show = () => {
if (!floating) return;
window.clearTimeout(closeTimer.current);
const r = liRef.current?.getBoundingClientRect();
if (r) setAt({ left: r.right + 8, top: r.top });
};
const hide = () => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setAt(null), 120);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const active = isActiveTree(item, activeHref);
const [open, setOpen] = useState(() => openIds.includes(item.id));
// Navigating into a group opens it; a group closed by hand stays closed
// until the route moves into it again.
const containsActive = openIds.includes(item.id);
useEffect(() => {
if (containsActive) setOpen(true);
}, [containsActive, activeHref]);
const row = cn(
'group relative flex w-full items-center rounded-lg text-[13px] font-medium transition-colors',
rowPad,
rail ? 'justify-center px-2' : 'gap-3 px-3',
FOCUS,
active && !group
? cn(ACCENT_SOFT, ACCENT_INK)
: active
? 'text-slate-900 dark:text-white'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800/70 dark:hover:text-white',
);
const icon = item.icon ? (
<span className={cn('relative inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center [&>svg]:h-[18px] [&>svg]:w-[18px]', active ? ACCENT_INK : 'text-slate-400 group-hover:text-slate-500 dark:text-slate-500')}>
{item.icon}
{/* A rail has no room for the number; a dot says "something is waiting". */}
{rail && (item.badge ?? 0) > 0 && (
<span className={cn(ACCENT_FILL, 'absolute -right-1 -top-1 h-2 w-2 rounded-full ring-2 ring-white dark:ring-slate-900')} />
)}
</span>
) : (
// An icon-less child still lines up under its parent's label.
!rail && level > 0 && <span aria-hidden className="w-[18px] shrink-0" />
);
const label = rail ? (
<span className="sr-only">{item.label}</span>
) : (
<span className="min-w-0 flex-1 truncate text-left">{item.label}</span>
);
const badge = !rail && (item.badge ?? 0) > 0 && (
<span className={cn(ACCENT_FILL, 'shrink-0 rounded-full px-1.5 text-[10px] font-semibold leading-4 tabular-nums text-white dark:text-slate-900')}>
{item.badge! > 99 ? '99+' : item.badge}
</span>
);
const children = group && (
<ul className={cn('mt-0.5 space-y-0.5', !rail && 'ml-[1.35rem] border-l border-slate-200 pl-2 dark:border-slate-700/70')}>
{item.children!.map((c) => (
<NavRow key={c.id} item={c} level={level + 1} rail={false} flyouts={false} activeHref={activeHref} rowPad={rowPad} go={go} openIds={openIds} layer={layer} />
))}
</ul>
);
return (
<li
ref={liRef}
className="relative"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) hide();
}}
>
{group ? (
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} title={rail ? item.label : undefined} className={row}>
{icon}
{label}
{badge}
{!rail && <ChevronDown aria-hidden className={cn('h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform', open && 'rotate-180')} />}
</button>
) : (
<a
href={item.href ?? '#'}
onClick={(e) => go(item.href, e)}
aria-current={matches(activeHref, item.href) ? 'page' : undefined}
title={rail ? item.label : undefined}
className={row}
>
{icon}
{label}
{badge}
</a>
)}
{group && !rail && open && children}
{/* Collapsed, with no hover-widen: a link gets a tooltip naming it, and a
group's rows open beside the rail rather than becoming unreachable. */}
{floating &&
at &&
layer &&
createPortal(
group ? (
<div
data-overlay="menu"
onMouseEnter={show}
onMouseLeave={hide}
style={{ left: at.left, top: at.top }}
className="panel panel-solid fixed z-[200] w-52 p-2 animate-fade-in"
>
<p className="px-3 pb-1.5 pt-1 text-[11px] font-semibold text-slate-900 dark:text-white">{item.label}</p>
{children}
</div>
) : (
<span
role="tooltip"
style={{ left: at.left, top: at.top + 6 }}
className="pointer-events-none fixed z-[200] whitespace-nowrap rounded-md bg-slate-900 px-2 py-1 text-[11px] font-medium text-white shadow-lg dark:bg-slate-700"
>
{item.label}
</span>
),
layer,
)}
</li>
);
}
/* ------------------------------------------------------------ breadcrumbs --- */
function Crumbs({ trail, go, homeIcon }: { trail: ShellCrumb[]; go: (href: string | undefined, e?: MouseEvent) => void; homeIcon: boolean }) {
return (
// `flex-1 min-w-0` lets the page name truncate instead of pushing the
// header's controls off the edge.
<nav aria-label="Breadcrumb" className="min-w-0 flex-1">
<ol className="flex min-w-0 items-center gap-1.5 text-xs">
{trail.map((c, i) => {
const last = i === trail.length - 1;
return (
<li
key={`${c.label}-${i}`}
// Only the page itself survives a narrow header; the trail above it drops out first.
className={cn('flex min-w-0 items-center gap-1.5', !last && 'hidden @2xl:flex')}
>
{i > 0 && <ChevronRight aria-hidden className="h-3 w-3 shrink-0 text-slate-400" />}
{last ? (
<span aria-current="page" className="truncate text-sm font-semibold text-slate-900 dark:text-white">
{c.label}
</span>
) : c.href ? (
<a href={c.href} onClick={(e) => go(c.href, e)} className={cn('flex items-center gap-1 truncate rounded text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100', FOCUS)}>
{i === 0 && homeIcon && <Home aria-hidden className="h-3.5 w-3.5" />}
{c.label}
</a>
) : (
<span className="truncate text-slate-500 dark:text-slate-400">{c.label}</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
/* ------------------------------------------------------------ header menu --- */
function HeaderMenu({ menu, active, go }: { menu: ShellHeaderMenu; active: boolean; go: (href: string | undefined, e?: MouseEvent) => void }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useDismiss(ref, open, () => setOpen(false));
const trigger = cn(
'inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors [&>svg]:h-3.5 [&>svg]:w-3.5',
FOCUS,
active || open
? cn(ACCENT_SOFT, ACCENT_INK)
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white',
);
if (!menu.items?.length) {
return (
<a href={menu.href ?? '#'} onClick={(e) => go(menu.href, e)} aria-current={active ? 'page' : undefined} className={trigger}>
{menu.icon}
{menu.label}
</a>
);
}
return (
<div ref={ref} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} aria-haspopup="menu" className={trigger}>
{menu.icon}
{menu.label}
<ChevronDown aria-hidden className={cn('transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div role="menu" className="panel panel-solid absolute right-0 top-full z-50 mt-2 w-64 p-1.5">
{menu.items.map((it) => (
<a
key={it.label}
role="menuitem"
href={it.href ?? '#'}
onClick={(e) => {
setOpen(false);
if (it.onSelect) {
e.preventDefault();
it.onSelect();
} else go(it.href, e);
}}
className={cn(MENU_ITEM, 'items-start')}
>
{it.icon && <span className="mt-0.5 shrink-0 text-slate-400 [&>svg]:h-4 [&>svg]:w-4">{it.icon}</span>}
<span className="min-w-0">
<span className="block font-medium">{it.label}</span>
{it.description && <span className="block text-[11px] text-slate-500 dark:text-slate-400">{it.description}</span>}
</span>
</a>
))}
</div>
)}
</div>
);
}
/* --------------------------------------------------------- theme settings --- */
function Segmented<T extends string>({
value,
options,
onChange,
label,
}: {
value: T;
options: Array<{ value: T; label: string; icon?: ReactNode }>;
onChange: (v: T) => void;
label: string;
}) {
return (
<div role="radiogroup" aria-label={label} className="grid gap-0.5 rounded-lg bg-slate-100 p-0.5 dark:bg-slate-800" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
{options.map((o) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'inline-flex items-center justify-center gap-1 truncate rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors [&>svg]:h-3.5 [&>svg]:w-3.5',
FOCUS,
value === o.value
? 'bg-white text-slate-800 shadow-sm dark:bg-slate-700 dark:text-white'
: 'text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100',
)}
>
{o.icon}
{o.label}
</button>
))}
</div>
);
}
function ThemePanel({
appearance,
set,
collapsed,
setCollapsed,
}: {
appearance: ShellAppearance;
set: (patch: Partial<ShellAppearance>) => void;
collapsed: boolean;
setCollapsed: (c: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useDismiss(ref, open, () => setOpen(false));
const heading = 'mb-1.5 block text-[11px] font-semibold text-slate-600 dark:text-slate-300';
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label="Theme settings"
title="Theme settings"
className={cn(ICON_BTN, 'inline-flex', open && 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200')}
>
<SlidersHorizontal className="h-4 w-4" />
</button>
{open && (
<div role="dialog" aria-label="Theme settings" className="panel panel-solid absolute right-0 top-full z-50 mt-2 w-72 space-y-3.5 p-3.5">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">Theme settings</p>
<button
type="button"
onClick={() => {
set(DEFAULT_APPEARANCE);
setCollapsed(false);
}}
className="text-[11px] font-medium text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
>
Reset
</button>
</div>
<div>
<span className={heading}>Mode</span>
<Segmented
label="Mode"
value={appearance.mode}
onChange={(mode) => set({ mode })}
options={[
{ value: 'light', label: 'Light', icon: <Sun aria-hidden /> },
{ value: 'dark', label: 'Dark', icon: <Moon aria-hidden /> },
{ value: 'system', label: 'System', icon: <Monitor aria-hidden /> },
]}
/>
</div>
<div>
<span className={heading}>Accent</span>
<div role="radiogroup" aria-label="Accent" className="flex flex-wrap gap-2">
{(Object.keys(ACCENTS) as ShellAccent[]).map((name) => {
const on = appearance.accent === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={on}
aria-label={name}
title={name[0].toUpperCase() + name.slice(1)}
onClick={() => set({ accent: name })}
style={{ background: ACCENTS[name][0] }}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-full text-white ring-offset-2 ring-offset-white transition-shadow focus-visible:outline-none dark:ring-offset-slate-800',
on ? 'ring-2 ring-slate-400 dark:ring-slate-500' : 'hover:ring-2 hover:ring-slate-200 focus-visible:ring-2 focus-visible:ring-slate-400 dark:hover:ring-slate-600',
)}
>
{on && <Check aria-hidden className="h-3.5 w-3.5" strokeWidth={3} />}
</button>
);
})}
</div>
</div>
<div>
<span className={heading}>Density</span>
<Segmented
label="Density"
value={appearance.density}
onChange={(density) => set({ density })}
options={[
{ value: 'compact', label: 'Compact' },
{ value: 'standard', label: 'Standard' },
{ value: 'comfortable', label: 'Roomy' },
]}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className={heading}>Content</span>
<Segmented
label="Content width"
value={appearance.content}
onChange={(content) => set({ content })}
options={[
{ value: 'fluid', label: 'Fluid' },
{ value: 'boxed', label: 'Boxed' },
]}
/>
</div>
<div>
<span className={heading}>Sidebar</span>
<Segmented
label="Sidebar"
value={collapsed ? 'rail' : 'full'}
onChange={(v) => setCollapsed(v === 'rail')}
options={[
{ value: 'full', label: 'Full' },
{ value: 'rail', label: 'Rail' },
]}
/>
</div>
</div>
</div>
)}
</div>
);
}
/* -------------------------------------------------------------- user menu --- */
function UserMenu({
user,
items,
onSignOut,
go,
}: {
user: ShellUser;
items: ShellUserMenuItem[];
onSignOut?: () => void;
go: (href: string | undefined, e?: MouseEvent) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useDismiss(ref, open, () => setOpen(false));
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-haspopup="menu"
aria-label={`Account: ${user.name}`}
className={cn('flex items-center gap-1.5 rounded-lg p-1 pr-1.5 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800', FOCUS, open && 'bg-slate-100 dark:bg-slate-800')}
>
<UserAvatar user={user} />
<ChevronDown aria-hidden className={cn('h-3.5 w-3.5 text-slate-400 transition-transform', open && 'rotate-180')} />
</button>
{open && (
<div role="menu" className="panel panel-solid absolute right-0 top-full z-50 mt-2 w-64 p-1.5">
<div className="mb-1 flex items-center gap-3 border-b border-slate-100 px-2 pb-2.5 pt-1.5 dark:border-slate-700/70">
<UserAvatar user={user} size={10} />
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-800 dark:text-slate-100">{user.name}</p>
<p className="truncate text-[11px] text-slate-500 dark:text-slate-400">{user.email}</p>
{user.role && <p className={cn('mt-0.5 truncate text-[10px] font-medium', ACCENT_INK)}>{user.role}</p>}
</div>
</div>
{items.map((it) => (
<div key={it.label}>
{it.divider && <div className="my-1 border-t border-slate-100 dark:border-slate-700/70" />}
<a
role="menuitem"
href={it.href ?? '#'}
onClick={(e) => {
setOpen(false);
if (it.onSelect) {
e.preventDefault();
it.onSelect();
} else go(it.href, e);
}}
className={MENU_ITEM}
>
{it.icon && <span className="shrink-0 text-slate-400 [&>svg]:h-3.5 [&>svg]:w-3.5">{it.icon}</span>}
{it.label}
</a>
</div>
))}
{onSignOut && (
<>
<div className="my-1 border-t border-slate-100 dark:border-slate-700/70" />
<button
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
onSignOut();
}}
className={cn(MENU_ITEM, 'text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950/40')}
>
<LogOut aria-hidden className="h-3.5 w-3.5" />
Sign out
</button>
</>
)}
</div>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
brand* | { name: string; logo?: ReactNode; href?: string } | — | |
nav* | ShellNavSection[] | — | |
user* | ShellUser | — | |
children* | ReactNode | — | |
activeHref | string | — | The current route; lights its row, opens its group and builds the breadcrumbs. |
onNavigate | (href: string) => void | — | Route a link click yourself (a client router). Without it links are plain navigations. |
breadcrumbs | ShellCrumb[] | — | Override the trail derived from the nav. |
home | ShellCrumb | — | A first crumb with a home icon, before the derived trail. |
headerMenus | ShellHeaderMenu[] | [] | |
search | ReactNode | — | |
actions | ReactNode | — | Header controls before the settings button — a notification bell, a help link. |
userMenu | ShellUserMenuItem[] | [] | |
onSignOut | () => void | — | Adds "Sign out" to the user menu. |
appearance | ShellAppearance | — | |
onAppearanceChange | (next: ShellAppearance) => void | — | |
defaultAppearance | Partial<ShellAppearance> | — | |
collapsed | boolean | — | |
onCollapsedChange | (collapsed: boolean) => void | — | |
defaultCollapsed | boolean | false | |
hoverExpand | boolean | true | A collapsed rail widens while the pointer or focus is in it. Off: groups open as flyouts. |
storageKey | string | — | Keep uncontrolled appearance and collapse in `localStorage` under this key. |
themeScope | 'document' | 'shell' | 'document' | |
sidebarFooter | ReactNode | — | |
height | string | number | '100dvh' | The shell's height. The content column scrolls inside it. |
className | string | — |