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

OnAttributeValues
.corn-navdata-orientationhorizontal|vertical
.corn-nav-linkdata-activepresent 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

OptionTypeDefaultDescription
orientation'horizontal'|'vertical''horizontal'Layout axis. Also picks the arrow keys that move focus.
labelstringundefinedSets aria-label on the <nav>.
expandActivebooleantrueExpand the ancestors of the aria-current=“page” item on init.
onExpand(item: HTMLElement) => voidundefinedCalled 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

PropTypeDefaultDescription
orientation'horizontal'|'vertical''horizontal'Layout axis.
labelstringundefinedSets aria-label on the nav element.
sectionedbooleanfalseRender children directly instead of wrapping them in a list — use with Navigation.Section, which brings its own.
childrenReact.ReactNodeundefined
classNamestringundefined

One nav entry. Renders an <a> when given href, otherwise a <button>.

PropTypeDefaultDescription
hrefstringundefinedRenders an anchor; without it the item is a button.
activebooleanfalseMarks it as the current page.
iconReact.ReactNodeundefinedRendered before the label.
descriptionReact.ReactNodeundefinedA line under the label. Clamped to one line; announced as the row’s description rather than its name.
rightSectionReact.ReactNodeundefinedBeside 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.
childrenReact.ReactNodeundefined
classNamestringundefined

A titled group of links. The heading labels the list through aria-labelledby and is not itself interactive.

PropTypeDefaultDescription
titleReact.ReactNoderequiredHeading text. Labels the list via aria-labelledby.
headingTagstring'h2'Heading element to render, so the page keeps a sane outline.
childrenReact.ReactNodeundefinedNavigation.Item / Navigation.Group children.
classNamestringundefined

A collapsible branch — indented in a vertical nav, a dropdown panel in a horizontal one.

PropTypeDefaultDescription
labelReact.ReactNoderequiredText of the parent row.
hrefstringundefinedMakes the parent a link and adds a separate toggle button beside it.
activebooleanfalseMarks the parent as the current one: the link as the current page, or — with no href — the toggle as the selected place.
iconReact.ReactNodeundefinedRendered before the label.
descriptionReact.ReactNodeundefinedA 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.
rightSectionReact.ReactNodeundefinedBeside 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.
defaultOpenbooleanfalseInitial state when uncontrolled.
openbooleanundefinedControlled state. Pair with onToggle.
onToggle(open: boolean) => voidundefinedCalled with the state the group is moving to.
childrenReact.ReactNode|(() => React.ReactNode)undefinedThe 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.
classNamestringundefined

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>

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 onExpand leaves 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 to onError, and getState().loading says 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.