Preview
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add sidenav
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'use client';
import { SideNav } from '@oshon-ai/components';
export default function Example() {
return <SideNav />;
}Default
<div style={{ height: '520px', display: 'flex' }}>
<SideNav size="m" collapsed={true} />
</div>Size matrix
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
activeId | string | — | Controlled active item id. Omit to self-host. |
branding | NavBranding | — | Branding for the expanded-panel header. |
collapsed | boolean | — | Controlled collapse state. Omit to self-host (default collapsed=true). |
footerSlot | ReactNode | — | Rendered at the bottom of the expanded panel above the copyright. |
headerSlot | ReactNode | — | Rendered before the item list inside the expanded panel — e.g. a product switcher, a search field, or a team picker. |
items | readonly NavItem[] | — | Navigation tree. Default = DEFAULT_NAV_ITEMS (11-row Procure fixture). |
legalText | ReactNode | — | Copyright / legal text under the footer. |
maxDepth | enum | 3 | Cap tree depth. Children below this depth are pruned. 1–3. |
messages | NavMessages | — | i18n. 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. |
size | enum | m | @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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
headerSlotRendered 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).
footerSlotRendered 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.
legalTextCopyright / 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
<SideNav />
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 ?? '#');
}}
/>
);<SideNav
items={myItems}
branding={{
product: { name: 'Procure', nameAccent: '360', tagline: 'Enterprise' },
customer: { name: 'Northwind Foods', tagline: 'Est. 1892' },
location: 'Superfood CA',
}}
/><SideNav items={bigTree} maxDepth={2} />✗ Don't
<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.
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.
<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.