Sidebar
Preview
Basic
Loading…
Preview
Code
ts
import Sidebar from '@/components/layout/Sidebar';src/components/layout/Sidebar.tsx
AI prompt
text
Build a collapsible admin sidebar navigation component in React + TypeScript + Tailwind CSS.
A frosted panel over a nested menu tree (any depth): full width on desktop, collapsible to an icon rail that widens on hover, and an off-canvas drawer below the `lg` breakpoint.
## Look
- Panel: `fixed inset-y-0 left-0 z-50 flex flex-col overflow-y-auto` with `bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-r border-black/10 dark:border-white/10`; at `lg` it becomes `static` in the page's flex row. Thin 5px scrollbar (slate-300 thumb, dark slate-700). `transition-all duration-300 ease-in-out`.
- Width: `lg:w-60` expanded (sized to the widest nested label plus the row chrome), `lg:w-20` as a rail; the mobile drawer is always `w-64`.
- Top: an `h-16` row with a bottom border (slate-200 / dark slate-700), `px-6` (rail: `px-4`, centred): the signed-in user's 36px round avatar (photo, or white semibold initials on an indigo-600 circle) then their name, or email if no name, in `ml-3 text-sm font-bold text-slate-900 dark:text-white`, truncating. In the rail the name fades to `opacity-0 w-0`.
- Nav: `mt-4 space-y-1`, `px-4` (rail `px-2`).
- Row: `flex items-center w-full px-3 py-3 text-sm font-medium rounded-lg`, a 20px icon then the label (`ml-3 flex-1 truncate`); the rail centres the icon. Child rows are indented `ml-6` and stacked in `mt-1 space-y-1`.
- Inactive: `text-slate-700 dark:text-slate-300`, icon slate-400, hover `bg-slate-100 dark:bg-slate-700/50`.
- Active — the row's own href OR any descendant matches the route, so a group lights up when you are inside it: `bg-indigo-50 text-indigo-600 dark:bg-indigo-900/20 dark:text-indigo-400`, icon indigo too.
- A group row (children, no href) ends with a 16px `ChevronDown` in slate-400 that rotates 180° when open.
## Behaviour
- Active matching is segment-aware: `pathname === href || pathname.startsWith(href + '/')`, so '/requests' never lights up for '/requests-archive'.
- On every route change, each group containing the current route is added to the expanded set; groups the user closed by hand stay closed until they navigate into them. Clicking a group row toggles it. Expansion is keyed by item id, since two items at different depths may share a name. An item with both an href and children shows its children while it is active.
- Rail (collapsed and not hovered): labels hide. A leaf's label turns into a hover tooltip to the right (`absolute left-full ml-2 px-2 py-1 rounded bg-slate-900 text-white text-xs shadow-lg z-50`), so the icon-only rail stays navigable. A group cannot expand in place, so on hover its children open in a flyout instead: `absolute left-full top-0 ml-2 w-48 z-50`, opaque, rounded-lg, bordered, shadow, `p-2`, the group name as a `text-xs font-semibold` header over the child rows.
- Hover-expand: hovering the collapsed rail widens it to full width temporarily. It does NOT change the stored preference; when the pointer leaves it is a rail again.
- Below `lg`: the panel sits at `-translate-x-full` until opened; while open, a `fixed inset-0 z-40 bg-slate-600/50` backdrop closes it on click, and clicking any link closes it.
- Icons are stored as lucide icon NAMES (strings) and resolved through an explicit name → component map with a neutral fallback (`KeyRound`), never a namespace import of the whole icon set.
## State
Keep sidebar state in a small React context with a provider and a `useSidebar()` hook, shared with the header's toggle buttons: `collapsed` + `toggleCollapsed()` (persisted to localStorage, read after mount — it is a workspace preference), `open` + `setOpen()` for the mobile drawer (deliberately NOT persisted — a drawer that reopens itself on the next load is a bug), and `expanded: Set<number>` with `toggleExpanded(id)` and `setExpanded(ids)` (merges into the set).
## API
- `items: MenuItem[]`, `MenuItem = { id: number; name: string; href?: string; icon: string; children: MenuItem[] }` — an already permission-filtered tree; filtering belongs on the server, not here.
- `user: { name: string | null; email: string; avatarUrl: string | null }`.
- Reads the current path from the router (e.g. `usePathname()`); links are the router's `Link`.
## Demo
Dashboard (LayoutDashboard, "/"); a Reports group (Layers) with Revenue and Usage; a Settings group (Settings) with Members, Roles and one item whose icon name is unknown, showing the fallback. User Ada Lovelace, inside a 26rem-tall bordered frame.
## 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 { useEffect, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { MenuItem } from '@/lib/menu';
import { cn } from '@/lib/cn';
import { useSidebar } from '@/contexts/SidebarContext';
import Avatar from './Avatar';
import MenuIcon from './MenuIcon';
/**
* The nav, in marketing-stats' shape: a frosted panel that is a rail at
* `lg:w-20` when collapsed and expands on hover, off-canvas below `lg`.
*
* Two behaviours worth knowing:
* * Hover-expand is temporary and doesn't touch the stored preference — the
* collapsed rail is still "collapsed" after the pointer leaves.
* * While collapsed AND not hover-expanded, a group can't expand in place
* (there is no room for labels), so its children open in a popover instead
* of becoming unreachable.
*
* It receives an ALREADY permission-filtered tree. The filtering is
* `buildMenuTree` on the server, never here — a client-side filter is a
* suggestion, not a boundary.
*/
/** Segment-aware, so '/requests' never lights up for '/requests-archive'. */
const matches = (pathname: string, href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
function isActiveTree(item: MenuItem, pathname: string): boolean {
if (item.href && matches(pathname, item.href)) return true;
return item.children.some((c) => isActiveTree(c, pathname));
}
/** Ids of every group containing the current route — expanded on navigation. */
function activeGroupIds(items: MenuItem[], pathname: string, acc: number[] = []): number[] {
for (const item of items) {
if (item.children.length > 0 && isActiveTree(item, pathname)) {
acc.push(item.id);
activeGroupIds(item.children, pathname, acc);
}
}
return acc;
}
function Row({
item,
pathname,
level,
collapsed,
}: {
item: MenuItem;
pathname: string;
level: number;
collapsed: boolean;
}) {
const { expanded, toggleExpanded, setOpen } = useSidebar();
const [hovering, setHovering] = useState(false);
const hasChildren = item.children.length > 0;
const isGroup = !item.href && hasChildren;
const active = isActiveTree(item, pathname);
const isOpen = expanded.has(item.id);
const rowClasses = cn(
'flex items-center w-full text-sm font-medium rounded-lg transition-colors cursor-pointer group',
collapsed ? 'px-3 py-3 justify-center' : 'px-3 py-3',
level > 0 && !collapsed && 'ml-6',
active
? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/20 dark:text-indigo-400'
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700/50',
);
const icon = (
<MenuIcon
name={item.icon}
className={cn(
'w-5 h-5 shrink-0 transition-colors',
active ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-400',
)}
/>
);
const label = (
<span
className={cn(
'whitespace-nowrap transition-all duration-300',
collapsed && !hasChildren
? // Collapsed leaf: the label becomes a hover tooltip rather than
// disappearing, so an icon-only rail is still navigable.
'absolute left-full ml-2 px-2 py-1 rounded bg-slate-900 text-white text-xs shadow-lg invisible group-hover:visible z-50'
: collapsed
? 'opacity-0 w-0'
: 'opacity-100 w-auto ml-3 flex-1 truncate',
)}
>
{item.name}
</span>
);
const children = (
<div className="mt-1 space-y-1">
{item.children.map((child) => (
<Row key={child.id} item={child} pathname={pathname} level={level + 1} collapsed={false} />
))}
</div>
);
return (
<div
className="relative"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{item.href ? (
<Link
href={item.href}
// Closing the off-canvas drawer on navigation. On desktop `open` is
// unused, so this is harmless there.
onClick={() => setOpen(false)}
className={rowClasses}
>
{icon}
{label}
</Link>
) : (
<div onClick={() => toggleExpanded(item.id)} className={rowClasses}>
{icon}
{label}
{hasChildren && !collapsed && (
<ChevronDown
className={cn(
'w-4 h-4 text-slate-400 shrink-0 transition-transform',
isOpen && 'rotate-180',
)}
/>
)}
</div>
)}
{hasChildren && !collapsed && (isOpen || (!isGroup && active)) && children}
{hasChildren && collapsed && hovering && (
<div className="absolute left-full top-0 ml-2 w-48 z-50 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-lg p-2 space-y-1">
<div className="px-3 py-1.5 text-xs font-semibold text-slate-900 dark:text-white">
{item.name}
</div>
{children}
</div>
)}
</div>
);
}
export default function Sidebar({
items,
user,
}: {
items: MenuItem[];
user: { name: string | null; email: string; avatarUrl: string | null };
}) {
const pathname = usePathname();
const { collapsed, open, setOpen, setExpanded } = useSidebar();
const [hoverExpanded, setHoverExpanded] = useState(false);
// Open whichever group contains the current route. Groups the user closed by
// hand stay closed until they navigate into them.
useEffect(() => {
setExpanded(activeGroupIds(items, pathname));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
const isEffectivelyCollapsed = collapsed && !hoverExpanded;
return (
<>
{open && (
<div className="fixed inset-0 bg-slate-600/50 z-40 lg:hidden" onClick={() => setOpen(false)} />
)}
<div
onMouseEnter={() => collapsed && setHoverExpanded(true)}
onMouseLeave={() => setHoverExpanded(false)}
className={cn(
'fixed inset-y-0 left-0 z-50 flex flex-col overflow-y-auto custom-scrollbar',
'bg-white/70 dark:bg-slate-900/70 backdrop-blur-xl border-r border-black/10 dark:border-white/10',
'transition-all duration-300 ease-in-out lg:static lg:translate-x-0',
isEffectivelyCollapsed && 'overflow-x-hidden',
open ? 'translate-x-0' : '-translate-x-full',
// w-60 rather than a round w-64: sized to the widest nested label
// ("Permissions" under Settings) plus the row's chrome. The mobile
// panel keeps w-64 — it overlays content, so a narrower one buys
// nothing there.
isEffectivelyCollapsed ? 'w-64 lg:w-20' : 'w-64 lg:w-60',
)}
>
<div
className={cn(
'flex items-center h-16 shrink-0 border-b border-slate-200 dark:border-slate-700',
isEffectivelyCollapsed ? 'px-4 justify-center' : 'px-6',
)}
>
<Avatar name={user.name} email={user.email} avatarUrl={user.avatarUrl} size="md" />
<span
className={cn(
'ml-3 text-sm font-bold text-slate-900 dark:text-white whitespace-nowrap truncate transition-all duration-300',
isEffectivelyCollapsed ? 'opacity-0 w-0' : 'opacity-100',
)}
>
{user.name || user.email}
</span>
</div>
<nav className={cn('mt-4 space-y-1 flex-1', isEffectivelyCollapsed ? 'px-2' : 'px-4')}>
{items.map((item) => (
<Row
key={item.id}
item={item}
pathname={pathname}
level={0}
collapsed={isEffectivelyCollapsed}
/>
))}
</nav>
</div>
</>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | MenuItem[] | — | |
user* | { name: string | null; email: string; avatarUrl: string | null } | — |