EQTY LabDocs

Nav

Sidebar navigation with an icon rail, a switcher and links that drill in and out

View as Markdown

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:

tsx
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.

jsx
<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:

jsx
<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.

jsx
<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.

jsx
<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.

jsx
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.

tsx
<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.

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.

jsx
<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

NameDescription
NavRailThe column of icon links
NavRailItemAn icon link in the rail, with its label as a tooltip
NavPanelThe sidebar panel; its header holds a NavSwitcher, or <NavSwitcher loading />
NavSwitcherSwitches between items such as projects, in the panel’s header
NavListRoot of the rows
NavListGroupA list of rows, with an optional label
NavListItemA row: a link, or a button when it has no href
NavListSeparatorDivider between groups
NavDrillShows one level at a time and slides between them
NavDrillLevelOne level of a drill-in, with its ‹ row

Props

NameDescriptionTypeDefaultRequired
aria-labelNames the rail’s landmarkstring✅
NameDescriptionTypeDefaultRequired
iconThe item’s only visible contentReactNode✅
labelThe item’s accessible name and its tooltipstring✅
hrefWhere the item goes; not needed with asChildstring✅
currentMarks the item as the section on showbooleanfalse❌
asChildRenders the item as its only child, such as a router linkbooleanfalse❌
NameDescriptionTypeDefaultRequired
aria-labelNames the panel’s landmarkstring✅
headerAbove the rows, such as a NavSwitcher or a headingReactNode❌
NameDescriptionTypeDefaultRequired
labelThe current item’s name, shown as the panel’s headingstring✅
nounSingular, for the trigger’s name: {label}, switch {noun}string✅
collectionPlural, for the search and empty copystring✅
optionsThe items to switch between{ value, label, keywords? }[]✅
valueThe current item’s valuestring✅
onValueChangeCalled with the picked item’s value(value: string) => void✅
onHighlightHover or focus on an option, to preload its destination(value: string) => void❌
onHighlightEndHover or focus leaves an option, to cancel its preload() => void❌
actionA command at the bottom of the list, kept while searching, such as “Create Project”; onSelect runs after the list closes{ label: string; onSelect: () => void }❌
loadingHolds the switcher’s height while the current item loadsbooleanfalse❌

With loading, the switcher takes no other props.

NameDescriptionTypeDefaultRequired
labelNames the group’s liststring❌
NameDescriptionTypeDefaultRequired
labelThe row’s textstring✅
hrefMakes the row a linkstring❌
currentMarks the row as the page on showbooleanfalse❌
prefixAn icon before the label; not on nested rowsReactNode❌
suffixBadges or marks after the labelReactNode❌
drillThe level this row opens; adds ›string❌
asChildRenders the row as its child, such as a router linkbooleanfalse❌
NameDescriptionTypeDefaultRequired
valueControlled: the level on showstring❌
defaultValueUncontrolled: the first levelstring❌
onValueChangeCalled when a drill row or ‹ asks for a level; request says which(value: string, request: "drill" | "back") => void❌
animateSlides between levelsbooleantrue❌
NameDescriptionTypeDefaultRequired
valueThe NavDrill value that shows itstring✅
parentThe value ‹ returns to; adds the ‹ rowstring❌
labelNames where you are, on the ‹ row; required with parentstring❌
backLabelThe ‹ row’s accessible name; must start with labelstring{label}, back❌