Skip to content
cennge-ui
GitHubv0.3.1, changelog

Sidebar

The column of places beside every page of an app: collapses to icons on desktop, opens as a drawer on phones.

Code
  • srcThe components
  • e2ePlaywright specs
  • playgroundFixtures the specs open
  • siteThese docs
  • CHANGELOG.mdEvery release
  1. The panel button at the top collapses the column to icons; rest the pointer on an icon for its name.
  2. Narrower than 768 pixels, the same spot holds a menu button: the column opens as a drawer and closes once you choose a page.
  3. Escape closes the drawer and puts focus back on the menu button.
Live: click it, use the keys.
Import
import { SidebarLayout, Sidebar, SidebarNav, SidebarItem, SidebarTrigger } from "cennge-ui";
Size
4.2 KB gzip for this import, its dependencies aside
Pattern
A named <aside> holding a <nav>; on phones a modal dialog
Keyboard
Enter Esc
State
Collapsed: isCollapsed and onCollapsedChange, or defaultCollapsed; the drawer through useSidebar()
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Breakpoint
A column from 768px (Tailwind's md), a drawer below it
Width
16rem, or 4rem collapsed to icons
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: sidebar

When to use#

A Sidebar is the frame of an app: the list of places that stays beside every page. On desktop it is a column that stays in view while the page scrolls and collapses to its icons; on phones the same children open in a drawer from the left. These docs are built on it.

SidebarLayout puts the Sidebar next to your content and holds the state. Inside the Sidebar go SidebarHeader, SidebarNav with SidebarGroups of SidebarItems, and SidebarFooter; SidebarTrigger goes in your page's header.

Similar#

  • NavigationMenu is the links bar at the top of a site, its links in panels; a Sidebar keeps an app's sections in view.
  • Drawer slides any content in from any edge; the Sidebar's phone drawer is one, and it closes by itself once a page is chosen.
  • Tabs switch panels inside one page; a Sidebar moves between pages.

asChild puts the item's look, icon and count inside your own link, here Next.js Link. Set isActive from the current path: the item is highlighted and read as the current page. On phones a click closes the drawer even though Link cancels the click to navigate by itself; a Ctrl- or Cmd-click that opens a new tab leaves it open.

Sidebar

An app sidebar that becomes a drawer on phones.

next-links.tsx
import Link from "next/link";
import { Sidebar, SidebarGroup, SidebarItem, SidebarLayout, SidebarNav, SidebarTrigger } from "cennge-ui";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d={d} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

const guides = [
  { href: "/docs/installation/", title: "Installation", summary: "npm install, two CSS imports and one @source line.", icon: "M12 4v11m0 0-4.5-4.5M12 15l4.5-4.5M5 19.5h14" },
  { href: "/docs/theming/", title: "Theming", summary: "Tokens, surfaces, colour roles, the accent presets and your own colours.", icon: "M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.5-1.5 0-1.2-1-1.5-1-2.5s.8-1.5 2-1.5H17a4 4 0 0 0 4-4c0-4.7-4-8.5-9-8.5Z" },
  { href: "/docs/dark-mode/", title: "Dark mode", summary: "ThemeScript, useTheme and ThemeSwitch: the stored theme before the first paint.", icon: "M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" },
  { href: "/docs/forms/", title: "Forms", summary: "Controlled and uncontrolled fields, server errors under their fields.", icon: "M5 4.5h14v15H5zM9 9h6m-6 4h6m-6 4h3" },
];

const components = [
  { href: "/docs/sidebar/", title: "Sidebar", summary: "An app sidebar that becomes a drawer on phones.", icon: "M4.5 4.5h15v15h-15zM10 4.5v15" },
  { href: "/docs/tabs/", title: "Tabs", summary: "Panels behind a row of tabs.", icon: "M3.5 19.5v-12h6v3h11v9z" },
];

/** In an app, pass usePathname() from next/navigation. */
export default function DocsShell({ pathname = "/docs/sidebar/" }: { pathname?: string }) {
  const current = [...guides, ...components].find((page) => page.href === pathname);
  return (
    <div className="h-80 w-full overflow-hidden rounded-large border border-border bg-background">
      <SidebarLayout className="h-full min-h-0">
        <Sidebar label="Guides" className="h-full w-56">
          <SidebarNav label="Guides">
            <SidebarGroup label="Getting started">
              {guides.map((page) => (
                <SidebarItem key={page.href} asChild isActive={page.href === pathname} icon={<Icon d={page.icon} />}>
                  <Link href={page.href}>{page.title}</Link>
                </SidebarItem>
              ))}
            </SidebarGroup>
            <SidebarGroup label="Components">
              {components.map((page) => (
                <SidebarItem key={page.href} asChild isActive={page.href === pathname} icon={<Icon d={page.icon} />}>
                  <Link href={page.href}>{page.title}</Link>
                </SidebarItem>
              ))}
            </SidebarGroup>
          </SidebarNav>
        </Sidebar>
        <div className="flex min-w-0 flex-1 flex-col">
          <div className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3">
            <SidebarTrigger />
            <span className="text-sm font-semibold">{current?.title}</span>
          </div>
          <p className="p-5 text-sm text-muted">{current?.summary}</p>
        </div>
      </SidebarLayout>
    </div>
  );
}

Remember the collapsed width#

Collapsing is uncontrolled unless you pass isCollapsed. Save it from onCollapsedChange, here in a cookie, read the cookie on the server and pass it back as defaultCollapsed, so the first paint already has the right width. Collapsed, each item shows its icon and its name in a tooltip, and screen readers still hear the name and the count.

payload-cache

FastAPI cache service.

remember-collapsed.tsx
import { Sidebar, SidebarHeader, SidebarItem, SidebarLayout, SidebarNav, SidebarTrigger } from "cennge-ui";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d={d} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

const repository = "https://github.com/Cennge/payload-cache";

const links = [
  { href: repository, label: "Code", icon: "m8 7-5 5 5 5m8-10 5 5-5 5" },
  { href: `${repository}/issues`, label: "Issues", icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-8.5v.01" },
  { href: `${repository}/pulls`, label: "Pull requests", icon: "M6 3v12m0 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm12 6V9a4 4 0 0 0-4-4h-3" },
  { href: `${repository}/actions`, label: "Actions", icon: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm-2-12.5v7l5.5-3.5L10 8.5Z" },
];

function saveCollapsed(isCollapsed: boolean) {
  document.cookie = `sidebar-collapsed=${isCollapsed}; path=/; max-age=31536000; samesite=lax`;
}

/** Read the sidebar-collapsed cookie on the server and pass it in. */
export default function RepositoryShell({ defaultCollapsed = false }: { defaultCollapsed?: boolean }) {
  return (
    <div className="h-72 w-full overflow-hidden rounded-large border border-border bg-background">
      <SidebarLayout className="h-full min-h-0" defaultCollapsed={defaultCollapsed} onCollapsedChange={saveCollapsed}>
        <Sidebar label="payload-cache" className="h-full w-56">
          <SidebarHeader className="px-4">
            <span className="truncate text-sm font-bold group-data-[collapsed]/sidebar:sr-only">Cennge</span>
          </SidebarHeader>
          <SidebarNav label="payload-cache">
            {links.map((link) => (
              <SidebarItem key={link.label} href={link.href} icon={<Icon d={link.icon} />}>
                {link.label}
              </SidebarItem>
            ))}
          </SidebarNav>
        </Sidebar>
        <div className="flex min-w-0 flex-1 flex-col">
          <div className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3">
            <SidebarTrigger />
            <span className="text-sm font-semibold">payload-cache</span>
          </div>
          <p className="p-5 text-sm text-muted">FastAPI cache service.</p>
        </div>
      </SidebarLayout>
    </div>
  );
}

An item without href is a button. On phones any item closes the drawer when clicked, which suits a page but not a setting: keepDrawerOpen keeps it open so the change can be seen. This one switches the theme of these docs.

ai-niche-scout

Finds niches worth building for.

footer-theme.tsx
import { Sidebar, SidebarFooter, SidebarItem, SidebarLayout, SidebarNav, SidebarTrigger, useTheme } from "cennge-ui";

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
      <path d={d} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

const repository = "https://github.com/Cennge/ai-niche-scout";
const moon = "M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z";
const sun = "M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0-13v1.5m0 15V21M3 12h1.5m15 0H21M5.6 5.6l1.1 1.1m10.6 10.6 1.1 1.1m0-12.8-1.1 1.1M6.7 17.3l-1.1 1.1";

export default function ThemeInFooter() {
  const { resolvedTheme, setTheme } = useTheme();
  const isDark = resolvedTheme !== "light";

  return (
    <div className="h-64 w-full overflow-hidden rounded-large border border-border bg-background">
      <SidebarLayout className="h-full min-h-0">
        <Sidebar label="ai-niche-scout" className="h-full w-56">
          <SidebarNav label="ai-niche-scout">
            <SidebarItem href={repository} icon={<Icon d="m8 7-5 5 5 5m8-10 5 5-5 5" />}>
              Code
            </SidebarItem>
            <SidebarItem href={`${repository}/issues`} icon={<Icon d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-8.5v.01" />}>
              Issues
            </SidebarItem>
          </SidebarNav>
          <SidebarFooter>
            {/* A setting, not a page: on phones the drawer stays open to show the change. */}
            <SidebarItem keepDrawerOpen icon={<Icon d={isDark ? sun : moon} />} onClick={() => setTheme(isDark ? "light" : "dark")}>
              {isDark ? "Light theme" : "Dark theme"}
            </SidebarItem>
          </SidebarFooter>
        </Sidebar>
        <div className="flex min-w-0 flex-1 flex-col">
          <div className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3">
            <SidebarTrigger />
            <span className="text-sm font-semibold">ai-niche-scout</span>
          </div>
          <p className="p-5 text-sm text-muted">Finds niches worth building for.</p>
        </div>
      </SidebarLayout>
    </div>
  );
}

When the page changes some other way, from a search result for instance, close the drawer with setMobileOpen(false) from useSidebar().

Accessibility#

The column is an <aside> named by the Sidebar's label, and SidebarNav is a <nav> named by its own label ("Main" unless you say otherwise): give both names that tell them from the other navigation on the page. The phone drawer is a modal dialog with the Sidebar's name; Escape and its close button close it and put focus back on the menu button.

Server rendering#

SidebarTrigger is two buttons switched by CSS at the breakpoint, a menu button for phones and a collapse button for desktop, so each has its name and aria-expanded right in the server's HTML, before any script runs.

Props#

SidebarLayout#

PropTypeDefault
isCollapsed
booleanDesktop: the sidebar shows icons only. Persist it (e.g. in a cookie) through onCollapsedChange.
–
defaultCollapsed
boolean
–
onCollapsedChange
(isCollapsed: boolean) => void
–

Also takes every attribute of <div>.

PropTypeDefault
label
stringNames the sidebar and the phone drawer for screen readers.
"Sidebar"

SidebarNav#

PropTypeDefault
label
string
"Main"

SidebarGroup#

PropTypeDefault
label
React.ReactNode
–

Also takes every attribute of <div>.

SidebarItem#

PropTypeDefault
isActive
booleanThe current page: highlighted and announced as such.
–
icon
React.ReactNode
–
endContent
React.ReactNodeShown at the end, e.g. a count. Collapsed, only screen readers get it.
–
asChild
booleanRender your own link, e.g. Next.js <Link>, with the item's styles and behaviour.
–
keepDrawerOpen
booleanOn phones, keep the drawer open after a click, e.g. for a theme switch.
–

SidebarTrigger#

PropTypeDefault
labels
Partial<{ open: string; collapse: string; expand: string }>
{ open: "Open navigation", collapse: "Collapse sidebar", expand: "Expand sidebar" }

Also takes every attribute of <button>, except children, which mean something else here.