Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
NavigationUpdatedFreeWCAG 2.2 AA

Side Nav

Collapsed rail (56) + expanded panel (240) — 3-tier hierarchy with L2/L3 cascade.

Preview

Live preview
@oshon-ai/components
Collapsed rail (56 px) — Storybook Default story
Expanded panel (240 px) — 3-tier hierarchy with L2 / L3 cascade

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add sidenav

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { SideNav } from '@oshon-ai/components';

export default function Example() {
  return <SideNav />;
}

Default

tsx
<div style={{ height: '520px', display: 'flex' }}>
      <SideNav size="m" collapsed={true} />
    </div>

Size matrix

tsx
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
      <div key="m" data-story-size="m">
        <div style={{ height: '520px', display: 'flex' }}>
          <SideNav size="m" collapsed={true} />
        </div>
      </div>
    </div>

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
activeIdstringControlled active item id. Omit to self-host.
brandingNavBrandingBranding for the expanded-panel header.
collapsedbooleanControlled collapse state. Omit to self-host (default collapsed=true).
footerSlotReactNodeRendered at the bottom of the expanded panel above the copyright.
headerSlotReactNodeRendered before the item list inside the expanded panel — e.g. a product switcher, a search field, or a team picker.
itemsreadonly NavItem[]Navigation tree. Default = DEFAULT_NAV_ITEMS (11-row Procure fixture).
legalTextReactNodeCopyright / legal text under the footer.
maxDepthenum3Cap tree depth. Children below this depth are pruned. 1–3.
messagesNavMessagesi18n. Every visible string overrides via this map.
onCollapseChange((next: boolean) => void)Fires on toggle — both the chevron button AND Escape-on-flyout.
onNavigate((item: NavItem, event?: MouseEvent<Element, MouseEvent> | KeyboardEvent<Element>) => void)Fires whenever a leaf is selected. Caller owns routing.
sizeenumm@deprecated SideNav is fixed-dimension per the Figma authoring (NavCollapsed 8854:47707 = 56 px rail; NavExpanded 3079:30910 = 240 px panel). The prop is retained as a typed constant so pre-existing call sites stay valid, but the only accepted value is `'m'`. Other sizes were removed because app-shell navigation needs deterministic widths so layouts above (page header, content grid, modal positioning) stay aligned.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<SideNav
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

headerSlot

Rendered inside the expanded panel, above the divider. Use for a product switcher, team picker, search field, or account dropdown. Hidden on the rail and during drill-down. i18n: caller-owned (the slot is a React node).

footerSlot

Rendered above the legal text at the bottom of the expanded panel. Typical content: a primary CTA button, a support-chat trigger, or a "Copy org id" row. Hidden on the rail.

legalText

Copyright / legal string below the footer. Rendered in --oshon-color-neutral-600 at 10/12. Accept either a plain string or a React node (multi-line copy, trademark mark, link).

Keyboard

Every row is a <button> in the tab order. Tab / Shift+Tab moves focus. Enter or Space activates a leaf and fires onNavigate; on an L1 with children it toggles the inline expansion in the panel, or opens the flyout on the rail. Arrow-Down / Up moves between sibling rows. Arrow-Right opens a child surface (flyout on the rail, inline expansion in the panel); Arrow-Left collapses. Escape closes any open flyout / cascade, or exits a drill-down. Focus ring uses --oshon-focus-ring-color. The rail is wrapped in an <aside aria-label> landmark; `aria-current="page"` marks the active leaf; `aria-expanded` + `aria-controls` tie the expand / collapse button to the panel id; `aria-haspopup="menu"` is set on rail rows that own a flyout.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-22

Do / Don't

✓ Do

Drop-in rail — self-hosted active state
<SideNav />
Controlled collapse + active id
const [collapsed, setCollapsed] = useState(true);
const [activeId, setActiveId] = useState('dashboard');
return (
  <SideNav
    collapsed={collapsed}
    onCollapseChange={setCollapsed}
    activeId={activeId}
    onNavigate={(item) => {
      setActiveId(item.id);
      router.push(item.href ?? '#');
    }}
  />
);
Custom tree with a branded header
<SideNav
  items={myItems}
  branding={{
    product: { name: 'Procure', nameAccent: '360', tagline: 'Enterprise' },
    customer: { name: 'Northwind Foods', tagline: 'Est. 1892' },
    location: 'Superfood CA',
  }}
/>
Cap depth at 2 — hide L3 cascades
<SideNav items={bigTree} maxDepth={2} />

✗ Don't

Hardcoded color for the active indicator
<SideNav className="[&_[aria-current=page]]:text-blue-500" />

Breaks white-labeling (principle #6). The active bar reads --oshon-color-primary-600 so applyTheme({ primarySeed }) retints every sidenav in one DOM write. Override the token if you need a different hue; never reach for a Tailwind color shortcut.

Fetching routes inside SideNav
SideNav does NOT fetch or own routing.

Selection is visual. The caller owns routing via onNavigate + activeId — compatible with Next.js App Router, React Router, Remix, or any hash-based client. Embedding fetch would force a framework choice on every consumer.

Using SideNav as a generic dropdown menu
<SideNav items={menuItems} />

SideNav is 56/240px wide and full-height — it assumes an application shell context. For a popover menu, use <Menu> / <Dropdown>. The rail + panel + flyout stack only makes sense as a persistent left-edge surface.

Design rationale

Single SideNav with a `collapsed` axis instead of two components (SideNavRail + SideNavPanel) because the two Figma states share 95% of the items tree + active-id surface — duplicating them would double the API without adding capability. Flyout + cascade are deliberately browser-hover-driven (not a controlled prop) because only the browser knows when a drag or mouseleave gesture is active; exposing them would lock callers into mirroring state they cannot observe. Panel L1 containers form a multi-open accordion (not single-open) because real navigation trees have cross-section workflows — an order-ops user jumping between Orders and Contracts should be able to keep both expanded instead of losing their place every time they toggle the other. Drill-down "Replace" is panel-only because the rail already has a deeper hover stack; reusing the panel body for drill keeps single-column mobile navigation intact (a popover cascade would overflow at <480px). The `maxDepth` prop clamps to 1–3 so a caller passing a deep tree from a CMS cannot accidentally render a 5-deep flyout chain. The native `<button>` in the tab order (not <a>) is chosen because callers own routing — href is informational metadata, not a default link target.