Patterns · Navigation
RookRail
The shared Rook navigation rail: 48px icons that expand to labels, remembered across apps, with a drawer on phones.
Collapsed, with Help & support and a theme toggle
import { useState, type CSSProperties, type ReactNode } from 'react';
import { AppShell, RookRail, ThemeToggle, type RookRailProps, type RookRailSlotContext } from '@rookcom/monarch-react/shell';
// AppMark, BellIcon, CalendarIcon, CardIcon, GearIcon, HomeIcon, InboxIcon, PlusIcon, UsersIcon: demo icons from the docs site; use your own.
const frame: CSSProperties = {
position: 'relative',
width: '100%',
height: '36rem',
overflow: 'hidden',
border: '1px solid var(--mn-color-border-default)',
borderRadius: 'var(--mn-radius-lg)',
background: 'var(--mn-color-bg-canvas)',
};
const page: CSSProperties = {
padding: 'var(--mn-space-6)',
color: 'var(--mn-color-text-muted)',
fontSize: 'var(--mn-text-sm)',
};
/** Each example sits in an embedded AppShell, so it lays out like an app at every width. */
function Frame({ name, rail }: { name: string; rail: ReactNode }) {
return (
<div style={frame}>
<AppShell
style={{ height: '100%', minHeight: 0 }}
mainRole="region"
mainId={`rail-demo-${name}`}
mainProps={{ 'aria-label': `Page (${name} example)` }}
skipLinkLabel={`Skip to ${name} example content`}
rail={rail}
>
<div style={page}>Page content</div>
</AppShell>
</div>
);
}
/** Docs examples keep the theme choice in memory, so they never change this site's theme. */
function useMemoryCookies() {
const [store] = useState(() => {
const jar = new Map<string, string>();
return {
get: (n: string) => jar.get(n) ?? null,
set: (n: string, v: string) => void jar.set(n, v),
};
});
return store;
}
/** Built lazily: docs files are imported in Node, where module-scope JSX is not allowed. */
const base = (): Pick<
RookRailProps,
'brand' | 'sections' | 'workspaceSection' | 'currentPath' | 'account' | 'notifications'
> => ({
brand: { label: 'Acme Planner home', href: '#planner', icon: <AppMark /> },
currentPath: '/w/acme/calendar',
sections: [
{
key: 'main',
items: [
{ key: 'home', label: 'Home', icon: <HomeIcon />, href: '/w/acme/home' },
{ key: 'inbox', label: 'Inbox', icon: <InboxIcon />, href: '/w/acme/inbox', badge: 4 },
{ key: 'calendar', label: 'Calendar', icon: <CalendarIcon />, href: '/w/acme/calendar' },
],
},
],
workspaceSection: {
key: 'workspace',
items: [
{ key: 'members', label: 'Members', icon: <UsersIcon />, href: '/w/acme/members' },
{
key: 'settings',
label: 'Settings',
icon: <GearIcon />,
href: 'https://id.example.com/settings',
external: true,
opensIn: 'Rook ID',
},
{
key: 'billing',
label: 'Billing',
icon: <CardIcon />,
href: 'https://id.example.com/billing',
external: true,
opensIn: 'Rook ID',
},
],
},
notifications: { label: 'Notifications', href: '#notifications', count: 2, icon: <BellIcon /> },
account: {
name: 'Ada Lovelace',
email: '[email protected]',
status: 'online',
profileHref: '#profile',
accountHref: 'https://id.example.com',
onSignOut: () => {},
},
});
function CollapsedDemo() {
const [supportOpen, setSupportOpen] = useState(false);
const cookies = useMemoryCookies();
return (
<Frame
name="collapsed"
rail={
<RookRail
{...base()}
persist={false}
messages={{ nav: 'Main (collapsed example)' }}
quickActions={[
{
key: 'new',
label: 'New event',
icon: <PlusIcon />,
shortcut: 'C',
onSelect: () => {},
},
]}
support={{ onOpen: () => setSupportOpen((o) => !o), open: supportOpen, unread: 1 }}
themeToggle={({ collapsed }: RookRailSlotContext) => (
<ThemeToggle
variant="menu"
size="sm"
side="right"
showLabel={!collapsed}
cookies={cookies}
applyToDocument={false}
/>
)}
/>
}
/>
);
}
<CollapsedDemo />Usage examples
Expanded, with workspace and Space switchers
import { useState, type CSSProperties, type ReactNode } from 'react';
import { AppShell, RookRail, SpaceSwitcher, WorkspaceSwitcher, type RookRailProps, type RookRailSlotContext } from '@rookcom/monarch-react/shell';
// AppMark, BellIcon, CalendarIcon, CardIcon, GearIcon, HomeIcon, InboxIcon, UsersIcon: demo icons from the docs site; use your own.
const frame: CSSProperties = {
position: 'relative',
width: '100%',
height: '36rem',
overflow: 'hidden',
border: '1px solid var(--mn-color-border-default)',
borderRadius: 'var(--mn-radius-lg)',
background: 'var(--mn-color-bg-canvas)',
};
const page: CSSProperties = {
padding: 'var(--mn-space-6)',
color: 'var(--mn-color-text-muted)',
fontSize: 'var(--mn-text-sm)',
};
/** Each example sits in an embedded AppShell, so it lays out like an app at every width. */
function Frame({ name, rail }: { name: string; rail: ReactNode }) {
return (
<div style={frame}>
<AppShell
style={{ height: '100%', minHeight: 0 }}
mainRole="region"
mainId={`rail-demo-${name}`}
mainProps={{ 'aria-label': `Page (${name} example)` }}
skipLinkLabel={`Skip to ${name} example content`}
rail={rail}
>
<div style={page}>Page content</div>
</AppShell>
</div>
);
}
/** Built lazily: docs files are imported in Node, where module-scope JSX is not allowed. */
const base = (): Pick<
RookRailProps,
'brand' | 'sections' | 'workspaceSection' | 'currentPath' | 'account' | 'notifications'
> => ({
brand: { label: 'Acme Planner home', href: '#planner', icon: <AppMark /> },
currentPath: '/w/acme/calendar',
sections: [
{
key: 'main',
items: [
{ key: 'home', label: 'Home', icon: <HomeIcon />, href: '/w/acme/home' },
{ key: 'inbox', label: 'Inbox', icon: <InboxIcon />, href: '/w/acme/inbox', badge: 4 },
{ key: 'calendar', label: 'Calendar', icon: <CalendarIcon />, href: '/w/acme/calendar' },
],
},
],
workspaceSection: {
key: 'workspace',
items: [
{ key: 'members', label: 'Members', icon: <UsersIcon />, href: '/w/acme/members' },
{
key: 'settings',
label: 'Settings',
icon: <GearIcon />,
href: 'https://id.example.com/settings',
external: true,
opensIn: 'Rook ID',
},
{
key: 'billing',
label: 'Billing',
icon: <CardIcon />,
href: 'https://id.example.com/billing',
external: true,
opensIn: 'Rook ID',
},
],
},
notifications: { label: 'Notifications', href: '#notifications', count: 2, icon: <BellIcon /> },
account: {
name: 'Ada Lovelace',
email: '[email protected]',
status: 'online',
profileHref: '#profile',
accountHref: 'https://id.example.com',
onSignOut: () => {},
},
});
const workspaces = [
{ id: 'w1', name: 'Acme', handle: 'acme' },
{ id: 'w2', name: 'Northfield', handle: 'northwind' },
];
const spaces = [
{ id: 's1', name: 'Engineering' },
{ id: 's2', name: 'Marketing' },
];
function ExpandedDemo() {
const [space, setSpace] = useState<string | null>('s1');
return (
<Frame
name="expanded"
rail={
<RookRail
{...base()}
persist={false}
messages={{ nav: 'Main (expanded example)' }}
defaultExpanded
support={{ onOpen: () => {} }}
workspaceSwitcher={({ collapsed }: RookRailSlotContext) => (
<WorkspaceSwitcher workspaces={workspaces} defaultValue="w1" collapsed={collapsed} />
)}
spaceSwitcher={({ collapsed }: RookRailSlotContext) => (
<SpaceSwitcher
spaces={spaces}
value={space}
onValueChange={setSpace}
collapsed={collapsed}
/>
)}
/>
}
/>
);
}
<ExpandedDemo />Purpose
One primary navigation for every Rook app, so people find the same things in the same place everywhere: the app, its sections, the Workspace block, Help & support, the theme, notifications and their account. Apps pass data, links and slots; the rail owns markup, behaviour and accessibility. One shared component instead of a copy in every product.
Use when
- The primary navigation of a signed-in Rook app, inside AppShell.
- Any Rook surface that should match the other apps (shared expand state, shared items).
Avoid when
- Marketing and public pages: use the marketing site shell from
@rookcom/monarch-react/marketing. - Secondary navigation inside a page (tabs, settings sections): use Tabs or a sidebar list in the page.
Design principles
- Data in, chrome out: the app supplies items and slots, never markup, so every Rook app keeps one look and one accessibility baseline.
- Icons are never the only label: collapsed items keep their label as visually hidden text and show it in a tooltip.
- The expand choice is the person’s, not the app’s: it lives in the shared
rook_railcookie, and a pre-paint script avoids a flash of the wrong width. - On phones the same navigation moves into a modal drawer with a real focus trap, which the copied rails were missing.
- Active state is not colour alone: a raised background, a heavier weight and an inside bar.
API reference
import { ROOK_RAIL_COOKIE, ROOK_RAIL_EVENT, RookRail, rookRailScript } from '@rookcom/monarch-react/shell';| Export | Kind | Rendering | Root class |
|---|---|---|---|
ROOK_RAIL_COOKIE | utility | server-safe | – |
ROOK_RAIL_EVENT | utility | server-safe | – |
RookRail | component | client | .mn-rook-rail |
rookRailScript | utility | server-safe | – |
rookRailScript({ cookieName? }) returns the <head> script that marks <html data-rook-rail="expanded"> before first paint.
ROOK_RAIL_COOKIE (rook_rail, values expanded | collapsed) and ROOK_RAIL_EVENT (rook-rail on window) are the cross-app contract.
Slots (workspaceSwitcher, spaceSwitcher, themeToggle) take a node, or a function of { collapsed, placement } so a switcher can render compactly in the collapsed rail and in full in the drawer.
Generated from the TypeScript declarations of packages/react/src/patterns/navigation/rook-rail.
Properties
RookRailItem: key, label, icon, href or onSelect, external, opensIn, badge, active/exact, shortcut.
Expand state: uncontrolled and persisted (default), persist={false} (in memory), or controlled with expanded + onExpandedChange (nothing is written).
cookies replaces document.cookie (tests, embedded frames); cookieDomain defaults to .rookhq.com on Rook hosts.
RookRail
The Rook primary navigation rail, shared by every Rook app: a 48px icon rail that expands to labels (remembered across apps in the `rook_rail` cookie), with the app's brand, workspace and Space switchers, the app's sections, the Workspace block, Help & support, a theme toggle slot, notifications and the account menu. Below 768px it becomes a top bar whose menu button opens the same navigation in a drawer (a modal Sheet: focus moves in, is trapped and returns to the button). Apps pass data, links and slots; the markup, behaviour and accessibility are the rail's.
| Prop | Type | Description |
|---|---|---|
brand required | RookRailBrand | The current app: its icon, wordmark and home link. |
sections required | RookRailSection[] | The app's own navigation, in order. |
account | ShellAccount | The signed-in person: avatar, account menu and Sign out. |
className | string | |
cookieDomain | string | Default: `.rookhq.com` on Rook hosts, otherwise the current host. |
cookieName | string | Default `rook_rail`. |
cookies | ShellCookieStore | Where the state is stored. Default `document.cookie`. |
currentPath | string | The current path, matched against item hrefs for the active item. |
defaultExpanded | boolean | Initial state when nothing is saved, and for the server render. Default false (48px). |
drawerOpen | boolean | The mobile drawer (controlled). |
expanded | boolean | Expanded (controlled). |
id | string | |
linkComponent | ShellLinkComponent | Link element for client navigation (Next.js `Link`, …). Default `<a>`. |
messages | Partial<RookRailMessages> | Interface text, for translation. |
notifications | RookRailNotifications | |
onDrawerOpenChange | ((open: boolean) => void) | |
onExpandedChange | ((expanded: boolean) => void) | |
persist | boolean | Remember the state in the `rook_rail` cookie, shared across Rook apps. Default true. |
quickActions | RookRailItem[] | Frequent actions above the expand toggle (New, Search). Listed first in the drawer. |
spaceSwitcher | RookRailSlot | Space switcher, under the workspace switcher. |
support | false | RookRailSupport | The Help & support entry. `false` or missing hides it. |
themeToggle | RookRailSlot | Theme toggle, above the account (and in the drawer footer). |
workspaceSection | RookRailSection | The Workspace block: workspace settings, members, billing and similar (usually links into Rook ID). Rendered after the sections, under its title, with Help & support directly above it. |
workspaceSwitcher | RookRailSlot | Workspace switcher, under the brand. |
Variants
| Prop | Values | Description |
|---|---|---|
expanded / defaultExpanded | 'false''true' | 48px icons (default) or 224px with labels (--mn-layout-rail-*). |
brand.wordmark | 'node' | Shown instead of the app icon while expanded and in the drawer. |
The rail has one visual style: the product accent, so each app keeps its colour.
States
| State | Exposed as | Behaviour |
|---|---|---|
| Collapsed / expanded | data-state="collapsed|expanded" | On the root. Before hydration a persisted rail has no state and follows <html data-rook-rail> from the pre-paint script. |
| Active item | data-active, aria-current="page" | From active, or href matched against currentPath (prefix, or exact). |
| Badge | – | A count (99+ above 99) while expanded, a dot while collapsed; the count is always in the accessible name. |
| Help & support open | aria-expanded="true", data-open, aria-controls | Styled like the active item while the support panel is open. |
| Focus | – | An on-accent focus ring inside the item. |
Interaction behaviour
- The expand button toggles labels, saves the choice for a year and tells every rail on the page; a change made in another Rook app is picked up when the tab regains focus.
- Tooltips (label, shortcut, “Opens in Rook ID”) show on hover and focus only while collapsed.
- External items open in a new tab with
rel="noopener noreferrer"; internal links uselinkComponentfor client navigation. - Help & support sits directly above the Workspace block and calls
support.onOpen. - The account button opens a menu: the person, Profile, extra entries, Rook account and Sign out.
- Below 768px a top bar replaces the rail; its menu button opens the same navigation in a drawer that closes on Escape, on the backdrop and after choosing an item.
Keyboard support
| Keys | Action |
|---|---|
| Tab / Shift+Tab | Every item, switcher and button is a normal Tab stop. |
| ↓ / ↑ | Next / previous item in the rail or drawer (wraps). |
| Home / End | First / last item. |
| Enter / Space | Follow a link, press a button, open a menu or switcher. |
| Escape | Close a tooltip, menu or the drawer; focus returns to what opened it. |
Accessibility
- Role
- a
navigationlandmark ("Main",messages.nav) around the items; the drawer is a modaldialognamed after the app - Accessible name
- Items are named by their label (plus ", 3 unread" and "(opens in Rook ID in a new tab)"); icon-only controls keep visually hidden text.
- The rail itself is not a
complementaryregion (it avoids<aside>): only its navigation is a landmark, so it stays valid when embedded and never adds an unnamed region. - Section titles label their lists (
aria-labelledby), also while visually hidden in the collapsed rail. - The expand button has
aria-expandedandaria-controlspointing at the rail. - Help & support has
aria-haspopup="dialog",aria-expandedand, while open,aria-controls. - The mobile drawer is a Monarch Sheet: focus moves in, is trapped, and returns to the menu button (the gap every audited copy had).
aria-keyshortcutsannounces item shortcuts; the app implements the keys.- Forced-colours mode outlines the active item; motion is removed under reduced motion.
Responsive behaviour
- 768px and up: a sticky full-height rail, 48px or 224px wide, whose middle scrolls on its own (
overscroll-behavior: contain). - Below 768px: a 56px top bar with the menu button and the app mark; the drawer is 20rem wide, never wider than the screen, with 44px rows.
- Slots get
{ collapsed, placement }and can switch to their compact form.
Theme support
- The panel is the product accent (
--mn-color-accent-solidwith-on-solidtext), so it follows the product, light, dark and more-contrast themes. - The drawer uses surface tokens, like other sheets.
- Pair
rookRailScript()with the tokensappearanceScript()in<head>.
Do and don't
Do
- Render
rookRailScript()in<head>and keep the default cookie, so the rail width is right on first paint and follows people between apps. - Pass the router’s link as
linkComponentand the current path ascurrentPath. - Put workspace administration (settings, members, billing) in
workspaceSection, so Help & support lands above it as in every app. - Give every item a short, unique label: it is the tooltip and the accessible name.
Don't
- Do not fork the rail’s markup or style its internals from the app; ask for a slot or prop instead.
- Do not hide important items only in the expanded state; the collapsed rail must offer every destination.
- Do not use a badge for anything but a count people should act on.
Related components
- AppShell: Places the rail beside the identity bar and main region.
- WorkspaceSwitcher: The workspace slot.
- SpaceSwitcher: The Space slot.
- ThemeToggle: The theme slot.
- SupportPanel: What Help & support opens.
- Sheet: The mobile drawer.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | Avatar, DropdownMenu, Kbd, Sheet, Tooltip |
Testing coverage
- Main navigation landmark; active item by path; brand home link.
- Badges and external links in the accessible name; new-tab links are safe.
- Workspace block under its heading with Help & support right above it; support button popup state and panel id.
- Expand/collapse writes the
rook_railcookie (name, domain, max-age), mirrors<html data-rook-rail>and syncs other rails; starts from the saved state. - Controlled mode writes nothing;
persist={false}keeps state locally. - Arrow keys, Home and End move focus; tooltips with label and shortcut while collapsed.
- Slots get their context; account menu with profile, Rook account and Sign out.
- Mobile drawer: focus moves in, closes after navigating and on Escape, focus returns.
- Link component for internal links only; translated messages; server render and
rookRailScript().
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/rook-rail; Cross-browser axe: /components/rook-rail |
Tests: packages/react/src/patterns/navigation/rook-rail