v1.0

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

PropTypeDefaultDescription
items*MenuItem[]—
user*{ name: string | null; email: string; avatarUrl: string | null }—