Nav
Sidebar navigation with an icon rail, a switcher and links that drill in and out
Overview
Nav builds an app’s sidebar. NavRail composes the largest sections of an application as icons with tooltips, and NavPanel sits beside it containing each page within the section, a header, and a scrolling body. NavSwitcher sits in the panel’s header to allow users to quickly switch between the parent item of a section such as projects in Guardian.
Our Nav component progressively displays a tree of navigation options. In addition to navigating between pages, NavListItem can also open a deeper level. Users go back up a level with the ‹ row at the top.
Nav should not be used for actions, for these use a Dropdown Menu instead. Always put NavList inside an element with a background colour, such as a popover, sheet or panel: the sliding levels inherit the background colour to cover each other.
Usage
Import the parts:
import {
NavDrill,
NavDrillLevel,
NavList,
NavListGroup,
NavListItem,
NavListSeparator,
NavPanel,
NavRail,
NavRailItem,
NavSwitcher,
} from "@eqtylab/equality";Group the rows, each group with an optional label, and add a separator between groups.
<NavList>
<NavListGroup label="Project">
<NavListItem label="Dashboard" prefix={<LayoutGrid />} href="/" current />
<NavListItem label="Indicators" prefix={<Activity />} href="/indicators" />
</NavListGroup>
<NavListGroup label="Applied Policies">
<NavListItem
label="EU AI Act"
prefix={<BookOpen />}
href="/policies/eu-ai-act"
/>
</NavListGroup>
<NavListSeparator />
<NavListGroup>
<NavListItem
label="Project Settings"
prefix={<Settings />}
href="/settings"
/>
</NavListGroup>
</NavList>Put a group inside another group to nest it one level, under a tree line. Nested rows take no prefix icon.
href makes a plain link, which reloads the page. In an app with a router, such as React Router, put the router’s link inside with asChild so it navigates without a reload:
<NavListItem label="Indicators" asChild>
<Link to="/indicators" />
</NavListItem>Variants
Prefix and Suffix
prefix holds an icon, hidden from screen readers. suffix holds badges and marks, which screen readers read with the label. The › comes from drill, never from suffix; see Drill-in.
<NavListItem
label="Applied Policies"
prefix={<BookOpen />}
suffix={<Badge size="sm">2</Badge>}
href="/policies"
/>
<NavListItem
label="Controls"
prefix={<Layers />}
suffix={<Badge size="sm">12</Badge>}
href="/controls"
/>Drill-in
A NavListItem with drill="eu-ai-act" opens a level whose value is "eu-ai-act". A NavDrillLevel with a parent attribute gets a ‹ row at the top, named by its label, that returns the user to the parent level of the tree. Levels must be direct children of NavDrill.
After a drill row, focus moves to the new level’s ‹; after ‹, back to the row you came from. Any other change of level leaves focus alone, and levels swap without sliding under reduced motion.
Uncontrolled: on its own
NavDrill changes level when someone clicks a drill row or ‹. Set the first level with defaultValue.
<NavList>
<NavDrill defaultValue="project">
<NavDrillLevel value="project">
<NavListGroup>
<NavListItem
label="Dashboard"
prefix={<LayoutGrid />}
href="/dashboard"
/>
</NavListGroup>
<NavListGroup label="Applied Policies">
<NavListItem
label="EU AI Act"
prefix={<BookOpen />}
drill="eu-ai-act"
/>
</NavListGroup>
</NavDrillLevel>
<NavDrillLevel value="eu-ai-act" parent="project" label="EU AI Act">
<NavListGroup>
<NavListItem
label="Controls"
prefix={<Layers />}
href="/policies/eu-ai-act/controls"
/>
</NavListGroup>
</NavDrillLevel>
</NavDrill>
</NavList>Controlled: follows the URL
Your app sets the level with value, usually from the URL, so a bookmark or the Back button opens the right level. A click on a drill row or ‹ calls onValueChange with the level it wants: navigate there, and the new URL gives the new value.
const { policy } = useParams();
const navigate = useNavigate();
function openLevel(level) {
navigate(level === "project" ? "/project" : `/policies/${level}`);
}
<NavDrill value={policy ?? "project"} onValueChange={openLevel}>
…
</NavDrill>;Examples
App Sidebar
A rail of top-level app sections in NavRail beside a NavPanel, with a switcher in the panel’s header to quick switch between objects, and a nav list that drills into a policy. Not all applications or navigation implementations require this level of organisation. Use NavRail when an application is sufficiently complex, and only for app-level navigation.
<div style={{ display: "flex", height: "100vh" }}>
<NavRail aria-label="Main">
<NavRailItem icon={<Folder />} label="Projects" href="/projects" current />
<NavRailItem icon={<Bot />} label="Agents" href="/agents" />
</NavRail>
<NavPanel
aria-label={`Project: ${project.name}`}
header={
<NavSwitcher
label={project.name}
noun="project"
collection="projects"
options={projects}
value={project.id}
onValueChange={switchProject}
/>
}
>
<NavList>
<NavDrill value={level} onValueChange={goToLevel}>
<NavDrillLevel value="project">…</NavDrillLevel>
{policy && (
<NavDrillLevel
value={policy.slug}
parent="project"
label={policy.name}
backLabel={`${policy.name}, back to ${project.name}`}
>
…
</NavDrillLevel>
)}
</NavDrill>
</NavList>
</NavPanel>
<main>…</main>
</div>The switcher’s trigger stops a typed letter from picking an item while its list is closed: a switch navigates, and focus lands on the trigger afterwards. To do the same on your own Select, see A select that navigates.
Navigation in a Sheet
On a phone, the sidebar comes in from the left in a Sheet. Give the sheet a title for screen readers. A sheet is at least a third of the screen wide, so set minWidth: 0 to fit it to the list.
<Sheet>
<SheetTrigger asChild>
<Button>Open navigation</Button>
</SheetTrigger>
<SheetContainer
side="left"
aria-describedby={undefined}
style={{ minWidth: 0 }}
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<NavList style={{ width: 264 }}>
<NavDrill defaultValue="project">…</NavDrill>
</NavList>
</SheetContainer>
</Sheet>Slots
| Name | Description |
|---|---|
NavRail | The column of icon links |
NavRailItem | An icon link in the rail, with its label as a tooltip |
NavPanel | The sidebar panel; its header holds a NavSwitcher, or <NavSwitcher loading /> |
NavSwitcher | Switches between items such as projects, in the panel’s header |
NavList | Root of the rows |
NavListGroup | A list of rows, with an optional label |
NavListItem | A row: a link, or a button when it has no href |
NavListSeparator | Divider between groups |
NavDrill | Shows one level at a time and slides between them |
NavDrillLevel | One level of a drill-in, with its ‹ row |
Props
NavRail
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
aria-label | Names the rail’s landmark | string | ✅ |
NavRailItem
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
icon | The item’s only visible content | ReactNode | ✅ | |
label | The item’s accessible name and its tooltip | string | ✅ | |
href | Where the item goes; not needed with asChild | string | ✅ | |
current | Marks the item as the section on show | boolean | false | ❌ |
asChild | Renders the item as its only child, such as a router link | boolean | false | ❌ |
NavPanel
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
aria-label | Names the panel’s landmark | string | ✅ | |
header | Above the rows, such as a NavSwitcher or a heading | ReactNode | ❌ |
NavSwitcher
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
label | The current item’s name, shown as the panel’s heading | string | ✅ | |
noun | Singular, for the trigger’s name: {label}, switch {noun} | string | ✅ | |
collection | Plural, for the search and empty copy | string | ✅ | |
options | The items to switch between | { value, label, keywords? }[] | ✅ | |
value | The current item’s value | string | ✅ | |
onValueChange | Called with the picked item’s value | (value: string) => void | ✅ | |
onHighlight | Hover or focus on an option, to preload its destination | (value: string) => void | ❌ | |
onHighlightEnd | Hover or focus leaves an option, to cancel its preload | () => void | ❌ | |
action | A command at the bottom of the list, kept while searching, such as “Create Project”; onSelect runs after the list closes | { label: string; onSelect: () => void } | ❌ | |
loading | Holds the switcher’s height while the current item loads | boolean | false | ❌ |
With loading, the switcher takes no other props.
NavListGroup
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
label | Names the group’s list | string | ❌ |
NavListItem
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
label | The row’s text | string | ✅ | |
href | Makes the row a link | string | ❌ | |
current | Marks the row as the page on show | boolean | false | ❌ |
prefix | An icon before the label; not on nested rows | ReactNode | ❌ | |
suffix | Badges or marks after the label | ReactNode | ❌ | |
drill | The level this row opens; adds › | string | ❌ | |
asChild | Renders the row as its child, such as a router link | boolean | false | ❌ |
NavDrill
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
value | Controlled: the level on show | string | ❌ | |
defaultValue | Uncontrolled: the first level | string | ❌ | |
onValueChange | Called when a drill row or ‹ asks for a level; request says which | (value: string, request: "drill" | "back") => void | ❌ | |
animate | Slides between levels | boolean | true | ❌ |
NavDrillLevel
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
value | The NavDrill value that shows it | string | ✅ | |
parent | The value ‹ returns to; adds the ‹ row | string | ❌ | |
label | Names where you are, on the ‹ row; required with parent | string | ❌ | |
backLabel | The ‹ row’s accessible name; must start with label | string | {label}, back | ❌ |