Sidebar
A docked, collapsible navigation panel and the flex layout that holds it beside your main content.
Overview
Recent activity
- Grace Hoppermerged “Billing rewrite”12m ago
- Alan Turingcommented on INV-2041h ago
- Ada Lovelaceinvited 3 teammates3h ago
Usage#
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "neelam-ui";
<SidebarProvider>
<Sidebar>
<SidebarHeader>Acme</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton href="/" isActive>Overview</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex-1">
<SidebarTrigger />
</main>
</SidebarProvider>SidebarProvider renders the shared flex row itself, rather than leaving it to
you. Unlike Dialog — whose provider holds only
context, because the content portals into the top layer — a docked sidebar and
its main content are ordinary flex siblings that need a wrapper.
Collapsing#
open and defaultOpen control it, and useSidebar() exposes the state plus a
setter and toggle for a control SidebarTrigger does not cover.
defaultOpen is true: unlike a dialog, a docked sidebar is normally visible
from the start.
Collapsing animates the <aside>'s own width. Its children sit in an inner
<div> held at a constant width instead of collapsing with it, so header
and nav text does not visibly reflow and wrap mid-animation as the outer width
crosses toward zero.
Collapsed means inert, not just invisible
A collapsed sidebar is marked inert, so it is unreachable by keyboard and
removed from the accessibility tree while still present for the closing
transition. Without that, Tab would walk through invisible links — the same
problem Dialog and Drawer solve by inerting the page behind them.
Collapsing to an icon rail#
By default (collapsible="offcanvas"), closing collapses the panel all the way
to a hidden, inert 0-width panel — the behaviour above. Pass
collapsible="icon" and it collapses to a slim, still-visible-and-interactive
rail instead:
Overview
<Sidebar collapsible="icon">…</Sidebar>Because the rail stays interactive, it's no longer inert while collapsed —
unlike the offcanvas default, there's nothing invisible here for Tab to skip.
Each SidebarMenuButton's label isn't dropped, either: it's kept sr-only (so
the link's accessible name is unchanged) and resurfaces as a
Tooltip on hover or focus, wrapping the <a>
itself via TooltipTrigger's asChild rather than an extra focusable wrapper
around it. SidebarGroupLabel renders nothing at all while collapsed — a rail
that narrow has no room for a text heading, and unlike a menu button there's no
icon standing in for it to attach a tooltip to instead.
iconWidth (default "4rem") sets the rail's width, the same way width sets
the open one. It's deliberately roomier than a single icon looks like it needs
— SidebarContent's own p-3 padding still applies inside the rail, so a
tighter width leaves the icon's h-8 w-8 box no space to actually center in:
<Sidebar collapsible="icon" iconWidth="4.5rem" />Header and footer content is yours to adapt
Unlike a menu button's icon+label shape, SidebarHeader/SidebarFooter
content has no single common shape this component could restyle for you. The
demo above reads open from useSidebar() — the same hook a custom trigger
would use — to hide its header/footer text while collapsed.
Width#
width is a CSS length (default "16rem") applied as an inline style, not a
Tailwind w-* class. That is deliberate: this library hit a real bug in
Resizable where a component's own default class
silently beat a caller's override of the same property, because which of two
equal-specificity classes wins is decided by their order in the generated
stylesheet, not by anything in the className string. An inline style has no
such ambiguity.
So change the width with the width prop rather than by fighting a class:
<Sidebar width="20rem" />Marking the current page#
SidebarMenuButton renders a native <a> and takes isActive, which sets
aria-current="page" alongside the highlight. Pass icon for a leading glyph —
it is placed and hidden from assistive tech for you.
Keyboard#
| Key | Behaviour |
|---|---|
| Tab | Moves through the sidebar's links in order. With the default collapsible="offcanvas", a collapsed sidebar is inert and skipped entirely; with collapsible="icon" it stays reachable, and Tab shows each link's Tooltip label as it's focused. |
| Enter | Follows the focused link. Native anchor behaviour. |
| EnterSpace | Toggles the sidebar when focus is on SidebarTrigger. |
There is no arrow-key roving. A sidebar is a list of ordinary links inside a landmark, not a composite widget, so each link is its own tab stop and browser find-in-page works normally.
Accessibility#
Sidebarrenders a native<aside>, whose implicitcomplementarylandmark needs norole— the same reasoningBreadcrumbrenders a plain<nav>.SidebarMenuis a real<ul>of<li>s, so assistive tech announces how many items there are and which one you are on.- The current page carries
aria-current="page", not just a colour. SidebarTriggeris an icon-only button with a defaultaria-label="Toggle sidebar"— override it if that does not fit.- With the default
collapsible="offcanvas", the collapsed panel isinert, so it never becomes a set of invisible tab stops. Withcollapsible="icon", it deliberately isn't — the rail is real, visible content, so it stays in the accessibility tree, and each link's accessible name is unchanged (its label stayssr-only, not removed) even though the rail is too narrow to show it.
Two landmarks, not one
<aside> marks the sidebar; give your main region a real <main>, as the
usage snippet does. Landmark navigation is how many screen reader users move
around an app, and it only works if both halves are marked.
API reference#
SidebarProvider#
| Prop | Type | Default |
|---|---|---|
defaultOpenInitial open state when uncontrolled. Defaults to `true` — unlike `Dialog`, a docked sidebar is normally visible from the start. | boolean | true |
onOpenChangeCalled whenever the open state changes, whether from `SidebarTrigger` or `useSidebar()`. | ((open: boolean) => void) | — |
openControls the open state. Omit to let the sidebar manage its own state. | boolean | — |
Sidebar#
| Prop | Type | Default |
|---|---|---|
collapsibleHow the panel behaves while closed. `"offcanvas"` (the default) is the original behavior: collapse all the way to a hidden `0`-width panel, `inert` and out of the accessibility tree. `"icon"` collapses to a slim, still-visible-and-interactive icon rail instead — see `iconWidth`, and `SidebarMenuButton`/`SidebarGroupLabel` for how their content adapts to it. | enum | offcanvas |
iconWidthThe panel's width while collapsed with `collapsible="icon"`. Ignored (and unused) for `"offcanvas"`, which always collapses to `0`. Defaults to `"4rem"` — deliberately roomier than it might look needed for a single `h-8 w-8` icon: `SidebarContent`'s own `p-3` padding still applies inside the rail, and the default leaves real breathing room either side of the icon rather than a bare, exact fit. | string | 4rem |
sideWhich edge the sidebar is docked to. Defaults to `"left"`. | enum | left |
widthThe sidebar's width while open, as a CSS length. Defaults to `"16rem"`. | string | 16rem |
SidebarMenuButton#
| Prop | Type | Default |
|---|---|---|
iconAn icon shown before the label. | ReactNode | — |
isActiveMarks this as the current page: sets `aria-current="page"` and highlights it. | boolean | false |