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.