GitHub ↗

Examples

Loading preview...

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.