Skip to content

App Sidebar

App shell with a sticky left sidebar: workspace header, grouped nav with yellow active state and counters, usage meter, user footer. Becomes a top bar + slide-over drawer below lg.

brutalist/layout/app-sidebar
Open ↗

Source

"use client";

import { useEffect, useId, useState, type ReactNode } from "react";
import {
  BarChart3,
  Bell,
  BookOpen,
  FileText,
  LayoutGrid,
  Menu,
  Plug,
  Settings,
  Users,
  X,
  type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";

export interface AppSidebarItem {
  label: string;
  href: string;
  icon: LucideIcon;
  /** Small counter on the right (e.g. unread items). */
  count?: number;
}

export interface AppSidebarSection {
  title?: string;
  items: AppSidebarItem[];
}

export interface AppSidebarProps {
  workspace?: { name: string; plan: string };
  sections?: AppSidebarSection[];
  /** href of the current page (gets aria-current="page"). */
  activeHref?: string;
  user?: { name: string; email: string };
  /** Promo/usage block at the bottom of the sidebar. */
  footer?: ReactNode;
  /** Page content rendered next to the sidebar. */
  children?: ReactNode;
  className?: string;
}

const DEFAULT_SECTIONS: AppSidebarSection[] = [
  {
    items: [
      { label: "Overview", href: "#overview", icon: LayoutGrid },
      { label: "Releases", href: "#releases", icon: FileText, count: 3 },
      { label: "Announcements", href: "#announcements", icon: Bell },
      { label: "Analytics", href: "#analytics", icon: BarChart3 },
    ],
  },
  {
    title: "Workspace",
    items: [
      { label: "Members", href: "#members", icon: Users },
      { label: "Integrations", href: "#integrations", icon: Plug },
      { label: "Settings", href: "#settings", icon: Settings },
    ],
  },
  {
    title: "Help",
    items: [{ label: "Documentation", href: "#docs", icon: BookOpen }],
  },
];

function initials(name: string): string {
  return name
    .split(" ")
    .map((p) => p[0])
    .join("")
    .slice(0, 2)
    .toUpperCase();
}

export function AppSidebar({
  workspace = { name: "Acme Inc.", plan: "Team plan" },
  sections = DEFAULT_SECTIONS,
  activeHref = "#releases",
  user = { name: "Maya Okafor", email: "maya@acme.dev" },
  footer,
  children,
  className,
}: AppSidebarProps) {
  const [open, setOpen] = useState(false);
  const drawerId = useId();

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  const nav = (
    <nav aria-label="Sidebar" className="flex flex-1 flex-col gap-6 overflow-y-auto px-3 py-5">
      {sections.map((section, i) => (
        <div key={section.title ?? i}>
          {section.title && (
            <p className="px-3 pb-2 font-da-mono text-[11px] font-bold tracking-da-label text-da-muted-fg uppercase">{section.title}</p>
          )}
          <ul className="flex flex-col gap-1">
            {section.items.map((item) => {
              const Icon = item.icon;
              const active = item.href === activeHref;
              return (
                <li key={item.href}>
                  <a
                    href={item.href}
                    aria-current={active ? "page" : undefined}
                    onClick={() => setOpen(false)}
                    className={cn(
                      "da-focus da-transition flex items-center gap-3 border-[length:var(--da-border-width)] px-3 py-2 font-bold",
                      active
                        ? "border-da-border bg-da-primary text-da-primary-fg shadow-da-sm"
                        : "border-transparent hover:border-da-border hover:bg-da-surface",
                    )}
                  >
                    <Icon aria-hidden className="size-5 shrink-0" strokeWidth={2.25} />
                    <span className="flex-1 truncate">{item.label}</span>
                    {item.count !== undefined && (
                      <span className="da-stroke bg-da-fg px-1.5 font-da-mono text-[11px] text-da-bg">
                        {item.count}
                        <span className="sr-only"> new</span>
                      </span>
                    )}
                  </a>
                </li>
              );
            })}
          </ul>
        </div>
      ))}
    </nav>
  );

  const panel = (
    <>
      <div className="da-stroke-b flex items-center gap-3 px-4 py-4">
        <span aria-hidden className="da-stroke grid size-10 shrink-0 place-items-center bg-da-accent font-da-display text-lg text-da-accent-fg">
          {workspace.name[0]}
        </span>
        <div className="min-w-0">
          <p className="truncate font-bold">{workspace.name}</p>
          <p className="font-da-mono text-[11px] tracking-da-label text-da-muted-fg uppercase">{workspace.plan}</p>
        </div>
      </div>
      {nav}
      {footer ?? (
        <div className="px-3 pb-3">
          <div className="da-stroke bg-da-secondary p-4 text-da-secondary-fg">
            <p className="font-da-mono text-[11px] font-bold tracking-da-label uppercase">Usage · September</p>
            <p className="mt-1 font-bold">7 of 10 projects</p>
            <div className="da-stroke mt-3 h-3 bg-da-surface" role="progressbar" aria-valuenow={70} aria-valuemin={0} aria-valuemax={100} aria-label="Projects used">
              <div className="h-full w-[70%] bg-da-primary" />
            </div>
          </div>
        </div>
      )}
      <div className="da-stroke-t flex items-center gap-3 px-4 py-3">
        <span aria-hidden className="da-stroke grid size-9 shrink-0 place-items-center rounded-full bg-da-surface font-da-mono text-xs font-bold text-da-surface-fg">
          {initials(user.name)}
        </span>
        <div className="min-w-0 text-sm">
          <p className="truncate font-bold">{user.name}</p>
          <p className="truncate text-da-muted-fg">{user.email}</p>
        </div>
      </div>
    </>
  );

  return (
    <div className={cn("flex min-h-full bg-da-bg text-da-fg", className)}>
      {/* Desktop */}
      <aside className="da-stroke-r sticky top-0 hidden h-dvh w-64 shrink-0 flex-col bg-da-surface-2 lg:flex">{panel}</aside>

      {/* Mobile drawer */}
      {open && (
        <div className="fixed inset-0 z-50 lg:hidden">
          <button type="button" aria-label="Close menu" onClick={() => setOpen(false)} className="absolute inset-0 bg-da-overlay" />
          <aside id={drawerId} className="da-stroke-r relative flex h-full w-72 max-w-[85vw] flex-col bg-da-surface-2 shadow-da-lg">
            <button
              type="button"
              aria-label="Close menu"
              onClick={() => setOpen(false)}
              className="da-focus da-stroke absolute top-4 -right-14 grid size-10 place-items-center bg-da-surface text-da-surface-fg"
            >
              <X aria-hidden className="size-5" />
            </button>
            {panel}
          </aside>
        </div>
      )}

      <div className="flex min-w-0 flex-1 flex-col">
        <div className="da-stroke-b flex items-center gap-3 bg-da-surface-2 px-4 py-3 lg:hidden">
          <button
            type="button"
            aria-label="Open menu"
            aria-expanded={open}
            aria-controls={drawerId}
            onClick={() => setOpen(true)}
            className="da-focus da-stroke grid size-10 place-items-center bg-da-surface text-da-surface-fg shadow-da-sm"
          >
            <Menu aria-hidden className="size-5" />
          </button>
          <p className="font-bold">{workspace.name}</p>
        </div>
        <main className="flex-1">{children}</main>
      </div>
    </div>
  );
}

export default AppSidebar;

modules/brutalist/layout/app-sidebar/index.tsx

Props

PropTypeDefaultDescription
childrenReactNode—Page content, rendered in <main> next to the sidebar.
workspace{ name: string; plan: string }Acme Inc. / Team planWorkspace block at the top.
sectionsAppSidebarSection[]—{ title?, items: { label, href, icon: LucideIcon, count? }[] }[].
activeHrefstring"#releases"href of the current page (aria-current=page). With Next.js pass usePathname().
user{ name: string; email: string }—User block at the bottom.
footerReactNode—Replaces the default usage meter.
classNamestring—Classes on the root.

Other sidebar variants in Brutalist

Sidebar in other art directions

App Sidebar

Floating frosted sidebar inset from the viewport edges: gradient-orb brand, pill nav items with an ink active state and gradient badges, workspace group, gradient promo card and user chip. Becomes a floating pill top bar + glass drawer below lg.

GlassSidebar

Sidebar Adaptive

Adaptive navigation: a full glass sidebar with brand, gradient action button, labelled links and a usage meter on desktop; a floating bottom tab bar with a raised gradient “+” on mobile.

GlassSidebar

Sidebar Channels

Chat-app style double sidebar: a column of gradient workspace squircles plus a glass channel panel with collapsible sections, private/live icons, unread counts and LIVE meeting badges; drawer on mobile.

GlassSidebar

Sidebar Floating

Floating glass icon rail detached from the viewport edges: gradient logo orb, gradient active item, unread badges, glass tooltips on hover/focus, settings and avatar at the bottom; a floating bottom bar on mobile.

GlassSidebar

App Sidebar

Linear-style app shell: 240px translucent sidebar with workspace switcher, search, "New" button with shortcut, grouped 32px nav rows with counters, team list with color squares and settings at the bottom. Slide-in drawer below lg.

MinimalSidebar

Sidebar Inset

Inset app shell: the sidebar sits on the tinted page background while content lives in a raised rounded panel. Workspace header, collapsible nested sections with guide line, support/settings and user card; drawer on mobile.

MinimalSidebar

Sidebar Rail

Sidebar that collapses between a 224px labelled list and a 56px icon rail (⌘B or bottom button) with tooltips on hover/focus and corner badges; icon-only on mobile. Controlled or uncontrolled.

MinimalSidebar

Sidebar Tree

Docs / settings sidebar with a filter box and a nested collapsible tree auto-expanded to the active page (highlighted in indigo); stacks above content on mobile.

MinimalSidebar

App Sidebar

Typographic app sidebar: hairline right border, logo + site switcher, mono group labels, plain text links with right-aligned mono counts; the active link is ink with a signal dot; drawer on mobile.

Mono CleanSidebar

Sidebar Icons

Narrow icon rail (56px) of square hairline cells; the active cell is filled ink; labels appear as mono tooltips to the right on hover/focus; turns into a bottom bar on mobile.

Mono CleanSidebar

Sidebar Ink

Ink sidebar (stays near-black in dark mode): giant numbered text links “01 Overview”, the active one full white with an arrow, a storage meter as a hairline bar at the bottom; drawer on mobile.

Mono CleanSidebar

Sidebar Tree

CMS page tree: nested pages drawn with hairline guide lines and +/− toggles (aria-expanded), mono “Pages” header with an add button; active page ink with signal dot; drawer on mobile.

Mono CleanSidebar

App Sidebar

Classic enterprise app shell: white bordered sidebar with brand, entity switcher, grouped nav (uppercase group labels, count badges, blue active state), settings and user footer; off-canvas drawer on mobile.

Neo CorporateSidebar

Sidebar Collapsible

Sidebar that collapses from 240px to a 64px icon rail with a toggle button (aria-expanded); collapsed items keep tooltips (title) and sr-only labels. Always a rail on small screens.

Neo CorporateSidebar

Sidebar Dual

Two-level navigation: a narrow icon rail of top-level sections (tooltips, aria-pressed) and a secondary panel listing the selected section’s pages; the panel hides below md.

Neo CorporateSidebar

Sidebar Navy

Dark navy sidebar (fg-colored in light mode, surface in dark mode — dark in both): brand, search field, nav with white-tint active state and badges, and a cash-balance card at the bottom; drawer on mobile.

Neo CorporateSidebar

App Sidebar

Calm app shell: cream sidebar with leaf logo, pill nav items (sage pill for the active page), a small “this year” footprint card with a sprout, and settings at the bottom; drawer on mobile.

Organic SoftSidebar

Sidebar Floating

Floating sidebar: a rounded cream panel inset from the page edges with a soft shadow, italic serif group labels, a sage “New entry” pill and sage-tinted active items; drawer on mobile.

Organic SoftSidebar

Sidebar Garden

Deep sage sidebar (dark sage tint in dark mode) with cream text, a translucent active pill, count bubbles, rolling hills illustrated along the bottom and the user’s name on the hill; drawer on mobile.

Organic SoftSidebar

Sidebar Rail

Slim round icon rail: circular icon links on a sand column, the active one a sage circle with a leaf-shaped marker; labels appear as soft tooltips on hover/focus; becomes a bottom tab bar on mobile.

Organic SoftSidebar

App Sidebar

Friendly app shell: warm sidebar with emoji workspace chip, big periwinkle “New task” pill, search field, nav with peach count bubbles and a colored-dot board list; slide-in drawer on mobile.

Soft FlatSidebar

Sidebar Boards

Board navigator: starred favorites, then collapsible emoji spaces holding emoji boards with count bubbles; active board in a white pill; dashed “New space” button.

Soft FlatSidebar

Sidebar Card

Floating white sidebar card with generous radius on a tinted page: two-dot logo, nav with lavender active pill, butter “tip of the day” card and user row; horizontal nav on mobile.

Soft FlatSidebar

Sidebar Rail

Slim rounded icon rail: round icons whose active item fills with its own pastel color, dark pill tooltips, emoji avatar at the bottom; floating bottom bar on mobile.

Soft FlatSidebar