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 (collapsedstate !== '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 (truecompact).
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: childrenicon › 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.