Navigation
Horizontal or vertical nav bar with keyboard arrow-key support and active-item tracking.
import { ... } from '@corndev/ui' HTML
<nav class="corn-nav" data-orientation="vertical" aria-label="Main">
<div class="corn-nav-section">
<h2 class="corn-nav-heading" id="nav-start">Getting Started</h2>
<ul class="corn-nav-list" aria-labelledby="nav-start">
<li class="corn-nav-item">
<a class="corn-nav-link" data-active href="/" aria-current="page">Overview</a>
</li>
<li class="corn-nav-item corn-nav-group">
<a class="corn-nav-link" href="/docs">Docs</a>
<button class="corn-nav-toggle" aria-expanded="false" aria-controls="nav-docs" aria-label="Expand Docs">
<span class="corn-nav-toggle-icon" aria-hidden="true"></span>
</button>
<ul class="corn-nav-list corn-nav-sublist" id="nav-docs">
<li class="corn-nav-item"><a class="corn-nav-link" href="/docs/install">Install</a></li>
</ul>
</li>
</ul>
</div>
</nav>
Classes
| Class |
|---|
.corn-nav |
.corn-nav-list |
.corn-nav-item |
.corn-nav-group |
.corn-nav-link |
.corn-nav-icon |
.corn-nav-section |
.corn-nav-heading |
.corn-nav-sublist |
.corn-nav-toggle |
.corn-nav-branch-toggle |
.corn-nav-toggle-icon |
.corn-nav-label |
.corn-nav-trailing |
.corn-nav-text |
.corn-nav-description |
Attributes
| On | Attribute | Values |
|---|---|---|
.corn-nav | data-orientation | horizontal|vertical |
.corn-nav-link | data-active | present or absent |
Vanilla JS
Required for behaviour — adds arrow-key roving focus that skips collapsed branches, click-to-expand, ArrowRight/ArrowLeft to open and close a branch, Escape and click-outside to close a dropdown, aria-controls wiring, aria-labelledby between a section heading and its list, and auto-expanding the branch holding aria-current=“page”.
import { createNavigation } from '@corndev/ui';
import '@corndev/ui/theme';
const el = document.querySelector('#target');
const instance = createNavigation(el, { /* options */ });
Options
| Option | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal'|'vertical' | 'horizontal' | Layout axis. Also picks the arrow keys that move focus. |
label | string | undefined | Sets aria-label on the <nav>. |
expandActive | boolean | true | Expand the ancestors of the aria-current=“page” item on init. |
onExpand | (item: HTMLElement) => void | undefined | Called when a branch with an empty sublist opens, so a caller can render that branch’s children then. The controller enhances markup rather than building it, so without this a caller has to emit the whole tree up front. Same contract as the tree state machine’s onNodeExpand: one “fetch or render these children” function serves both. |
React
Navigation — from @corndev/ui-react.
Navigation.Item and Navigation.Group take rightSection — a delete button, a count or a marker beside the row, outside its link or button so clicking it never activates the row, and after any chevron in Tab order — and description, a line under the label clamped to one line and announced as the row’s description rather than folded into its name. There is no setActive in React — mark the current link with active instead. Navigation.Group is uncontrolled by default; pass open with onToggle to drive it from your own state. Use sectioned when the children are Navigation.Sections, so the nav does not wrap them in a list of its own. For a large tree, give Navigation.Group a function as its children and it is called only while that branch is open, so a collapsed branch builds nothing; element children are built whether it is open or not. The vanilla equivalent is the onExpand(item) option — createNavigation enhances markup rather than building it, so it tells the caller when a branch with an empty sublist opens instead. Deferring children means a DOM search no longer sees inside closed branches: filter the data and expand the matching branches.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal'|'vertical' | 'horizontal' | Layout axis. |
label | string | undefined | Sets aria-label on the nav element. |
sectioned | boolean | false | Render children directly instead of wrapping them in a list — use with Navigation.Section, which brings its own. |
children | React.ReactNode | undefined | |
className | string | undefined |
<Navigation.Item> props
One nav entry. Renders an <a> when given href, otherwise a <button>.
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | undefined | Renders an anchor; without it the item is a button. |
active | boolean | false | Marks it as the current page. |
icon | React.ReactNode | undefined | Rendered before the label. |
description | React.ReactNode | undefined | A line under the label. Clamped to one line; announced as the row’s description rather than its name. |
rightSection | React.ReactNode | undefined | Beside the row: a delete button, a count, a marker. Outside the row’s own link or button, so clicking something in it never activates the row. |
children | React.ReactNode | undefined | |
className | string | undefined |
<Navigation.Section> props
A titled group of links. The heading labels the list through aria-labelledby and is not itself interactive.
| Prop | Type | Default | Description |
|---|---|---|---|
title | React.ReactNode | required | Heading text. Labels the list via aria-labelledby. |
headingTag | string | 'h2' | Heading element to render, so the page keeps a sane outline. |
children | React.ReactNode | undefined | Navigation.Item / Navigation.Group children. |
className | string | undefined |
<Navigation.Group> props
A collapsible branch — indented in a vertical nav, a dropdown panel in a horizontal one.
| Prop | Type | Default | Description |
|---|---|---|---|
label | React.ReactNode | required | Text of the parent row. |
href | string | undefined | Makes the parent a link and adds a separate toggle button beside it. |
active | boolean | false | Marks the parent as the current one: the link as the current page, or — with no href — the toggle as the selected place. |
icon | React.ReactNode | undefined | Rendered before the label. |
description | React.ReactNode | undefined | A line under the label — a table’s summary, a spec’s title. Clamped to one line; announced as the row’s description rather than folded into its name. |
rightSection | React.ReactNode | undefined | Beside the row, after the chevron: a delete button, a count, a “shared” marker. Outside the row’s own link or button, so clicking something in it never activates the row. |
defaultOpen | boolean | false | Initial state when uncontrolled. |
open | boolean | undefined | Controlled state. Pair with onToggle. |
onToggle | (open: boolean) => void | undefined | Called with the state the group is moving to. |
children | React.ReactNode|(() => React.ReactNode) | undefined | The branch’s contents. A function is called only while the branch is open, so a closed one builds nothing; anything else is built whether it is open or not. |
className | string | undefined |
Horizontal (default)
import { Navigation } from '@corndev/ui-react';
import '@corndev/ui/theme';
<Navigation label="Main navigation">
<Navigation.Item href="/" active>Home</Navigation.Item>
<Navigation.Item href="/about">About</Navigation.Item>
<Navigation.Item href="/blog">Blog</Navigation.Item>
</Navigation>Vertical
<Navigation orientation="vertical" label="Sidebar">
<Navigation.Item href="/dashboard" active>Dashboard</Navigation.Item>
<Navigation.Item href="/settings">Settings</Navigation.Item>
<Navigation.Item href="/profile">Profile</Navigation.Item>
</Navigation>Sections — a grouped sidebar
Headings split a long sidebar without adding anything to the tab order. The heading labels its list through aria-labelledby, so assistive tech announces “Getting Started, list, 2 items” rather than an anonymous run of links.
Pass sectioned so the nav does not wrap the sections in a list of its own — each Navigation.Section brings one.
<Navigation orientation="vertical" label="Docs" sectioned>
<Navigation.Section title="Getting Started">
<Navigation.Item href="/" active>Overview</Navigation.Item>
<Navigation.Item href="/install">Installation</Navigation.Item>
</Navigation.Section>
<Navigation.Section title="Components">
<Navigation.Item href="/button">Button</Navigation.Item>
</Navigation.Section>
</Navigation>Nested — a collapsible tree
Navigation.Group is a branch. Depth is unbounded: each nested list indents by --corn-nav-indent relative to the one above it, so there is no level to declare.
Give the group an href when the parent itself navigates. That splits the row into a link and a separate toggle button, because one element cannot both follow a link and expand a subtree. Without href the parent is a button that only expands.
<Navigation orientation="vertical" label="Tree">
<Navigation.Item href="/">Dashboard</Navigation.Item>
{/* Parent expands only */}
<Navigation.Group label="Reports" defaultOpen>
<Navigation.Item href="/reports/monthly" active>Monthly</Navigation.Item>
<Navigation.Group label="Archive">
<Navigation.Item href="/reports/2024">2024</Navigation.Item>
</Navigation.Group>
</Navigation.Group>
{/* Parent navigates *and* expands — two controls */}
<Navigation.Group label="Settings" href="/settings">
<Navigation.Item href="/settings/profile">Profile</Navigation.Item>
</Navigation.Group>
</Navigation>Keyboard: Up/Down move through the links that are actually visible — a collapsed branch is skipped entirely — and Right/Left open and close the branch under the cursor.
Controlling it yourself
A group is uncontrolled by default. Pass open with onToggle to drive it from your own state, for instance to keep exactly one branch open:
const [openGroup, setOpenGroup] = useState('reports');
<Navigation.Group
label="Reports"
open={openGroup === 'reports'}
onToggle={next => setOpenGroup(next ? 'reports' : null)}
>
…
</Navigation.Group>
Dropdowns — the same markup, horizontally
A Navigation.Group inside a horizontal nav renders as a dropdown panel. Only one opens at a time, Escape closes the open one and returns focus to its trigger, and a click outside closes it. Below 640px the panel becomes an ordinary indented subtree, because a positioned panel on a narrow screen opens off the edge of the viewport.
<Navigation label="Main">
<Navigation.Item href="/" active>Home</Navigation.Item>
<Navigation.Group label="Products">
<Navigation.Item href="/analytics">Analytics</Navigation.Item>
<Navigation.Item href="/billing">Billing</Navigation.Item>
</Navigation.Group>
<Navigation.Item href="/pricing">Pricing</Navigation.Item>
</Navigation>These are links, not an application menu. The component deliberately does not use role="menu" / role="menuitem" — those describe a File-menu style widget and change what a screen reader announces and which keys it expects. For a genuine application menu, reach for Menu instead.
It works without JavaScript
Collapse is a CSS sibling rule keyed off aria-expanded, so hand-written markup shipped with aria-expanded="false" renders collapsed on a page with no script at all. createNavigation only flips the attribute, wires aria-controls, and keeps the focus order honest.
With icons
import { HomeIcon, CogIcon } from 'your-icon-library';
<Navigation orientation="vertical" label="Main navigation">
<Navigation.Item href="/" active icon={<HomeIcon />}>Home</Navigation.Item>
<Navigation.Item href="/settings" icon={<CogIcon />}>Settings</Navigation.Item>
</Navigation>
Button items (no href)
Omit href to render a <button> instead of an <a>:
<Navigation>
<Navigation.Item active onClick={() => setPage('home')}>Home</Navigation.Item>
<Navigation.Item onClick={() => setPage('about')}>About</Navigation.Item>
</Navigation>
Left-hand nav inside the app shell
corn-layout supplies the columns; the nav goes in corn-layout-left-nav. The column collapses to a full-width block below --corn-bp-md.
<div class="corn-layout">
<header class="corn-layout-header">…</header>
<div class="corn-layout-body">
<aside class="corn-layout-left-nav">
<nav class="corn-nav" data-orientation="vertical" aria-label="Sections">…</nav>
</aside>
<main class="corn-layout-main">…</main>
</div>
</div>
Inside a Bar
import { Bar, Navigation } from '@corndev/ui-react';
<Bar>
<Navigation label="Main navigation">
<Navigation.Item href="/" active>Home</Navigation.Item>
<Navigation.Item href="/docs">Docs</Navigation.Item>
</Navigation>
</Bar>
Large trees: don’t build what nobody has opened
A branch’s children are built where you write them. Collapsing a branch hides it
— vertically with display: none, which is why a closed branch costs nothing to
show and everything to build. With a few hundred branches that is the whole
tree constructed so somebody can look at one of them. A 283-path API document
came to 112,308 elements this way.
Both runtimes can defer it, and they do it differently because they work differently.
React — pass a function
<Navigation orientation="vertical">
{/* built now, whether or not anyone opens it */}
<Navigation.Group label="/pets">
<Navigation.Item href="/pets/get">GET</Navigation.Item>
</Navigation.Group>
{/* built when this branch opens, and not before */}
<Navigation.Group label="/store">
{() => <Navigation.Item href="/store/get">GET</Navigation.Item>}
</Navigation.Group>
</Navigation>
Element children keep working exactly as before. The function is called on each render while the branch is open — like any render prop — so its contents always see current state, and closing the branch drops them again.
Vanilla — fill the branch when it opens
createNavigation enhances markup that is already in the document; it builds
nothing, so it cannot defer anything on its own. Instead it tells you when a
branch with an empty sublist opens, and you render that branch’s children then:
<li class="corn-nav-group">
<button class="corn-nav-toggle" aria-expanded="false">/store</button>
<ul></ul>
</li>
const nav = createNavigation(el, {
orientation: 'vertical',
onExpand(item) {
const list = item.querySelector(':scope > ul');
// Elements, not an HTML string: the paths come from a document you may not
// control, and text set as textContent cannot become markup.
list.replaceChildren(
...operationsFor(item.dataset.path).map(op => {
const link = Object.assign(document.createElement('a'), { href: op.href, textContent: op.method });
link.className = 'corn-nav-link';
const li = document.createElement('li');
li.append(link);
return li;
}),
);
},
});
onExpand fires once per open, from every route that opens a branch — the
toggle, the keyboard, and the expand() / toggle() methods — and only when
the branch’s own sublist is empty, since a branch that already has children does
not need filling. An empty <ul></ul> is a perfectly good branch in the
meantime: it toggles, it takes focus, and it collapses like any other.
This is the same contract as the tree state machine’s onNodeExpand, so the
“fetch or render these children” function you write serves both.
What this costs you: filtering
Once children exist only while their branch is open, a search over the DOM no longer sees inside closed branches — and a match hidden in a closed branch is a search that finds nothing. That is a consequence of deferring the work, not a bug in it.
Filter the data rather than the rendered tree, and open the branches that hold a match:
const matches = paths.filter(path => path.url.includes(term));
for (const path of matches) nav.expand(elementFor(path));
Driving a tree: createTreeState
A Navigation renders a tree; something has to remember which branches are open
and which node is selected. That is createTreeState from @corndev/ui — no
DOM, no framework, and the same state whether the tree is drawn by React or by a
string of HTML:
import { createTreeState } from '@corndev/ui';
const tree = createTreeState({
defaultExpanded: ['/'],
async onExpand(path) {
fill(path, await readDirectory(path));
},
onSelect(path) {
open(path);
},
});
tree.subscribe(() => paint(tree.getState()));
onExpand is the same callback createNavigation takes, so the “fetch or
render these children” function you write serves both.
In React, useTree from @corndev/ui-hooks binds the same machine to a
component, and pairs with the function children above — the state says whether a
branch is open, the function builds it only when it is:
import { useTree } from '@corndev/ui-hooks';
function Outline({ paths }) {
const tree = useTree({ onExpand: path => load(path) });
return (
<Navigation orientation="vertical" label="Outline">
{paths.map(path => (
<Navigation.Group
key={path}
label={path}
open={tree.isExpanded(path)}
onToggle={() => tree.toggle(path)}
>
{() => operations(path).map(op => (
<Navigation.Item key={op.id} href={op.href} active={tree.selected === op.id}>
{op.method}
</Navigation.Item>
))}
</Navigation.Group>
))}
</Navigation>
);
}
Two behaviours worth knowing, because consumers otherwise write them by hand:
- Selecting the node that is already selected does nothing. Acting on a selection usually means loading what was selected, and a spurious re-select reloads a file over an edit the user has not finished.
- An async
onExpandleaves the branch open whatever happens — while it is pending, and if it rejects. A branch that collapsed itself under the cursor would lose the user’s place; failures go toonError, andgetState().loadingsays which branches are still waiting.
A slot beside the row
rightSection puts something that belongs to the row but is not the row after
the label and any chevron — a delete button, a count, a “shared” marker:
<Navigation.Group
label="Tables"
icon={<IconTable size={16} />}
rightSection={<Badge>12</Badge>}
>
{() => tables.map(t => (
<Navigation.Item
key={t.name}
onClick={() => open(t)}
rightSection={<Button variant="icon" size="sm" emphasis="quiet" aria-label={`Delete ${t.name}`}>×</Button>}
>
{t.name}
</Navigation.Item>
))}
</Navigation.Group>
It sits beside the row’s link or button, never inside it — a button inside an anchor is invalid HTML, and clicking Delete would also open the row. Tab reaches the row first, then the slot.
In plain markup the slot is a .corn-nav-trailing span after the link (and after a
group’s toggle), before the sublist.
A description under the label
<Navigation.Item href="/tables/users" description="Everyone who has signed up">
users
</Navigation.Item>
It is clamped to one line with an ellipsis. A tree whose rows grow to four or five
lines of description stops being scannable; set --corn-nav-description-lines on the
nav to allow more.
The row is still named by its label; the description is announced as its description, so a screen reader hears “users, link, Everyone who has signed up” rather than a paragraph as the name.