Skip to Content
ComponentsSidebar

Sidebar

An application sidebar: a collapsible navigation column, with a phone form and a desktop form.

Live Examples

Live Example
☀️
Copy
View Code
import { SidebarProvider, Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuBadge, SidebarInset, SidebarTrigger, SidebarRail, SidebarSeparator } from '@jarllyng/nostromo' export default function SidebarExample() { const [current, setCurrent] = React.useState('Home') const items = ['Home', 'Inbox', 'Projects', 'Settings'] return ( <div className="h-80 overflow-hidden rounded-lg border border-border"> <SidebarProvider className="min-h-full">...

Sub-items and a loading state

Live Example
☀️
Copy
View Code
import { SidebarProvider, Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton, SidebarMenuSkeleton, SidebarInset, SidebarTrigger } from '@jarllyng/nostromo' export default function SidebarNested() { const [loading, setLoading] = React.useState(true) return ( <div className="h-80 overflow-hidden rounded-lg border border-border"> <SidebarProvider className="min-h-full"> <Sidebar>...

Import

import { SidebarProvider, Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarInset, SidebarTrigger, } from "@jarllyng/nostromo";

Parts

PartWhat it is
SidebarProviderWraps the whole layout. Owns the state and the shortcut
SidebarThe column, or the sheet on a phone
SidebarInsetThe page beside it. A <main>, so it is a landmark
SidebarTriggerThe toggle button. Put it in the inset, not in the sidebar
SidebarRailThe strip along the edge that also toggles. Pointer only
SidebarHeaderTop of the column
SidebarFooterBottom of the column, pushed down
SidebarContentThe scrolling middle
SidebarSeparatorA divider, inset from the edges
SidebarGroupA section of the navigation
SidebarGroupLabelIts heading. Fades out when collapsed to icons
SidebarGroupActionA button in the group’s top-right corner
SidebarGroupContentWrapper for the group’s body
SidebarMenuA <ul>, so it is announced with a length
SidebarMenuItemAn <li>
SidebarMenuButtonThe row you click. Takes isActive and tooltip
SidebarMenuActionA second button on a row, optionally on hover
SidebarMenuBadgeA count on the right of a row
SidebarMenuSkeletonA placeholder row while the navigation loads
SidebarMenuSubA nested list under a row
SidebarMenuSubItemAn item in it
SidebarMenuSubButtonAn <a> in it. Takes isActive
SidebarInputA search field sized for the column
useSidebarThe state, for a part of your own
useIsMobileThe breakpoint hook Sidebar branches on

Props

SidebarProvider

PropTypeDefaultDescription
defaultOpenbooleantrueStarting state. Pass the cookie here on the server
openboolean-Controlled state
onOpenChange(open: boolean) => void-Fires on every toggle
keyboardShortcutbooleantrueInstall ⌘B / Ctrl+B

Sidebar

PropTypeDefaultDescription
side"left" | "right""left"Which edge it sits on
variant"sidebar" | "floating" | "inset""sidebar"Flush, detached, or inset page
collapsible"offcanvas" | "icon" | "none""offcanvas"What collapsing does
titlestring"Sidebar"Accessible name for the phone sheet
descriptionstring"Site navigation"Description for the phone sheet

SidebarMenuButton

PropTypeDefaultDescription
isActivebooleanfalseCurrent page. Sets aria-current="page"
tooltipstring-Label shown only while collapsed to icons
asChildbooleanfalseRender your own element, a router link
variant"default" | "outline""default"Look
size"default" | "sm" | "lg""default"Row height

The phone form is a different tree

Below 768px a Sidebar is a Sheet: a modal panel over the page. Above it, a fixed column beside the page. Those are different elements with different semantics, so the branch happens in JS rather than in CSS.

Two things follow from that.

Put SidebarTrigger in the inset, not in the sidebar. On a phone the sidebar is a closed dialog, and everything inside it is unrendered - a trigger in there could never be reached to open it.

The open state is two states. open is the desktop column, openMobile is the sheet. toggleSidebar picks whichever applies, and useSidebar exposes both.

Collapsing the sidebar writes sidebar_state. That is what stops it flashing open on every navigation in a server-rendered app - but only if the server passes it back as defaultOpen. Nothing in the component can do that for you.

In a Next.js layout:

import { cookies } from "next/headers"; export default async function Layout({ children }) { const store = await cookies(); const defaultOpen = store.get("sidebar_state")?.value !== "false"; return ( <SidebarProvider defaultOpen={defaultOpen}>{children}</SidebarProvider> ); }

Left alone, the cookie is written and never read, and the sidebar starts expanded every time.

Colours are their own token family

bg-sidebar, text-sidebar-foreground, border-sidebar-border, bg-sidebar-accent, text-sidebar-accent-foreground and ring-sidebar-ring, so a sidebar can be a different surface from a card.

Every theme defaults them to that theme’s own card, accent, border and ring, so nothing looks different until you override them:

[data-theme="nostromo"] { --nostromo-color-sidebar: 240 6% 10%; --nostromo-color-sidebar-foreground: 0 0% 98%; --nostromo-color-sidebar-border: 240 4% 20%; }

Values are HSL channels without the hsl(), same as every other token.

Keyboard

⌘B on a Mac, Ctrl+B elsewhere, toggles the sidebar. Pass keyboardShortcut={false} if the key is already taken in your app.

SidebarTrigger carries aria-expanded, so a screen reader user knows whether pressing it opens or closes. SidebarRail does the same thing with a pointer and is deliberately outside the tab order: two tab stops for one action is worse than one.

Last updated on