sidenav
A persistent, collapsible side navigation rail, with nested items and a responsive mobile drawer.
A headless side navigation that lives in your layout. On desktop it is a persistent rail with three states, expanded, compact (icon rail), and hidden (off-canvas). Below a breakpoint it becomes a modal overlay drawer. Items can nest: nested children expand inline when the rail is expanded, and open as anchored flyouts on the compact rail.
Links are polymorphic (as) for router integration; put each label in a
<span data-sidenav-label> so it hides cleanly on the icon rail. For a standalone
modal off-canvas sheet, use drawer instead.
Installation
pnpm add @fluixi-ui/sidenav
Usage
import {
Sidenav, SidenavHeader, SidenavNav, SidenavItem, SidenavLink,
SidenavGroup, SidenavGroupLabel, SidenavSub, SidenavSubTrigger,
SidenavSubContent, SidenavFooter, SidenavTrigger,
} from '@fluixi-ui/sidenav';
<Sidenav>
<SidenavHeader>Acme</SidenavHeader>
<SidenavNav>
<SidenavGroup>
<SidenavGroupLabel>Workspace</SidenavGroupLabel>
<SidenavItem>
<SidenavLink href="/" active>
<HomeIcon /> <span data-sidenav-label>Home</span>
</SidenavLink>
</SidenavItem>
{/* Nested items, inline when expanded, a hover flyout when compact. */}
<SidenavSub active={inCatalog()}>
<SidenavSubTrigger>
<TagIcon /> <span data-sidenav-label>Catalog</span>
</SidenavSubTrigger>
<SidenavSubContent>
<SidenavItem>
<SidenavLink href="/products"><span data-sidenav-label>Products</span></SidenavLink>
</SidenavItem>
<SidenavItem>
<SidenavLink href="/categories"><span data-sidenav-label>Categories</span></SidenavLink>
</SidenavItem>
</SidenavSubContent>
</SidenavSub>
</SidenavGroup>
</SidenavNav>
<SidenavFooter>
<SidenavTrigger />
</SidenavFooter>
</Sidenav>
Three-state rail
The rail's display is driven by state:
expanded, full width, labels visible.compact, icon rail; labels hidden and nested items open as hover flyouts.hidden, fully off-canvas (zero width).
const [state, setState] = createSignal<SidenavState>('expanded');
<Sidenav state={state()} onStateChange={setState}>...</Sidenav>
The boolean collapsed / defaultCollapsed / onCollapsedChange API still works as a
shorthand (collapsed ⇔ state !== 'expanded'), so existing code keeps running.
The rail exposes its live width as --flx-ui-sidenav-width. Because it's an in-flow
flex/grid item, a shell laid out beside it adapts automatically as the width
transitions, no JS needed.
What hides on the compact rail
On the compact (icon) rail each row collapses to its leading icon. An element hides if any of the following is true. Pick whichever fits your markup:
- It's a
[data-sidenav-label](or[data-sidenav-group-label]), the recommended label wrapper. - It carries
data-sidenav-hide-compact, an explicit marker for any element (a brand blurb, a badge, a keyboard hint...). - It's a non-leading child of a
SidenavLink/SidenavSubTrigger, so plain, unmarked label text collapses automatically as long as the icon comes first.
Only direct rail rows are affected: a sub's flyout is portalled outside the rail, so nested items keep their full labels in the popup.
<SidenavLink href="/orders">
<OrdersIcon /> <span data-sidenav-label>Orders</span>
<span data-sidenav-hide-compact class="badge">12</span>
</SidenavLink>
Responsive drawer
Below mobileBreakpoint (default 768px) the rail renders as a modal overlay drawer,
backdrop, slide-in, focus trap, scroll lock, and dismiss on outside-click / Escape. The
open state is controlled via open / onOpenChange.
The panel is only in the DOM while open, so the control that opens it lives in your
app chrome (e.g. a topbar hamburger). SidenavTrigger, placed inside the rail, closes
the drawer on mobile and toggles compact/expanded on desktop.
const [open, setOpen] = createSignal(false);
// In your topbar:
<button onClick={() => setOpen(true)} aria-label="Open menu"><MenuIcon /></button>
<Sidenav open={open()} onOpenChange={setOpen} mobileBreakpoint={768}>...</Sidenav>
Nested items
Wrap a SidenavSubTrigger and a SidenavSubContent in a SidenavSub. When the rail is
expanded (or inside the mobile drawer) the content expands inline (animated); on the
compact rail the trigger opens an anchored flyout on hover. Subs nest recursively, a
SidenavSubContent may contain further SidenavSubs. Set active on the SidenavSub
(or SidenavSubTrigger) to keep the branch highlighted for the current route.
Props (Sidenav)
| Prop | Type | Default | Description |
|---|---|---|---|
state |
'expanded' | 'compact' | 'hidden' |
, | Controlled display state. |
defaultState |
same | 'expanded' |
Initial uncontrolled state. |
onStateChange |
(s) => void |
, | Called when the state changes. |
collapsed |
boolean |
, | Back-compat shorthand for state (true≙compact). |
defaultCollapsed |
boolean |
false |
Initial uncontrolled collapse. |
onCollapsedChange |
(c: boolean) => void |
, | Called when collapse changes. |
mobileBreakpoint |
number |
768 |
Max viewport width (px) at which the rail becomes an overlay drawer. 0 disables. |
open |
boolean |
, | Controlled open state of the mobile drawer. |
defaultOpen |
boolean |
false |
Initial uncontrolled open state. |
onOpenChange |
(o: boolean) => void |
, | Called when the drawer opens/closes. |
side |
'left' | 'right' |
'left' |
Which side the rail sits on. |
collapsible |
boolean |
true |
When false, SidenavTrigger renders nothing on desktop (the rail can't be collapsed via the UI; it still shows as the mobile close button). |
label |
string |
'Sidebar' |
Accessible name for the landmark. |
The collapse trigger
SidenavTrigger toggles compact/expanded on desktop and closes the drawer on mobile.
With no content it renders a built-in chevron; supply your own icon via the icon
prop or children (precedence: children › icon › chevron). To offer no
desktop toggle at all, set collapsible={false} on Sidenav, the trigger then
renders only inside the mobile drawer, as a close button.
// custom icon
<SidenavTrigger icon={<PanelLeftIcon />} />
// no desktop toggle (mobile close still works)
<Sidenav collapsible={false}> ... </Sidenav>
Parts
| Part | Description |
|---|---|
Sidenav |
The <nav> rail. Owns state + mobile open; data-state, data-collapsed, data-side, data-mobile. |
SidenavHeader / SidenavFooter |
Top and bottom regions. |
SidenavNav |
The <ul> list of items. |
SidenavItem |
An <li> wrapping a link. |
SidenavLink |
A navigable link; active sets aria-current="page". Pass as for a router link. |
SidenavGroup / SidenavGroupLabel |
A titled section; the label hides on the icon rail. |
SidenavSub |
A nav item with nested children; open/defaultOpen/onOpenChange, active. |
SidenavSubTrigger |
The parent row (reuses link styling + a chevron); aria-expanded. |
SidenavSubContent |
The nested children, inline list when expanded, portalled flyout when compact. |
SidenavTrigger |
Toggles compact/expanded on desktop; closes the drawer on mobile. |
Data attributes
| Selector | When |
|---|---|
[data-sidenav][data-state] |
expanded / compact / hidden. |
[data-sidenav][data-collapsed] |
Present when not expanded. |
[data-sidenav][data-mobile] |
The rail is rendered as the mobile overlay. |
[data-sidenav-sub][data-open] |
The nested branch is open. |
[data-sidenav-sub-trigger][data-open] |
Its flyout/section is open (rotates the chevron). |
[data-sidenav-sub-content][data-flyout] |
The nested content is the compact flyout panel. |
[data-sidenav-overlay] / [data-sidenav-backdrop] |
The mobile overlay + its dimmed backdrop. |
[data-sidenav-label] |
Text you set, hidden on the compact rail. |
[data-sidenav-hide-compact] |
Opt-in marker: any element carrying it hides on the compact rail. |
The rail also publishes --flx-ui-sidenav-width for a shell to read.
Part of the Fluixi UI component library. Made with ☕ by the Fluixi team.