NavigationDrawer

An application's main destinations, in a drawer, a rail or a bar by the window's width.

BetaSince 1.0.0
On this page

Import

import {
  NavigationBar,
  NavigationDrawer,
  NavigationRail,
} from '@envsemble/envui';

Usage

const destinations: Destination[] = [
  { label: 'Inbox', href: '/inbox', icon: <IconInbox />, count: 12, current: true },
  { label: 'Projects', href: '/projects', icon: <IconFolder />, group: 'Work' },
];

<NavigationDrawer destinations={destinations} header={<Text>Northwind</Text>} footer={account} />;

NavigationDrawer, NavigationRail and NavigationBar take destinations, one array of Destinations, each with its label, an icon, a count and a group. With href, a destination is a native link, through the router link from LinkProvider, and onClick(event) runs as it is followed; with onClick alone it is a button, for a view without an address. Set current on one destination, from the router: a press doesn't select one by itself. header and footer hold content above and below the destinations of a NavigationDrawer or a NavigationRail. Scaffold shows the form that fits its width, at the window's start or, for the bar, at its bottom.

One list, three forms

Pass the same array to each form. A heading starts each run of destinations of one group in the drawer, at headingLevel, 2 by default, so keep a group's destinations next to each other; the rail and the bar show no headings, so order the array. A NavigationDrawerDestination may leave its icon out: leave it out of every destination of a list, or of none. NavigationRail and NavigationBar need icons.

A badge after the label

{ label: 'Reports', href: '/reports', badge: <Badge tone="success">New</Badge> }

A NavigationDrawer destination's badge shows at the end of its item, after the label and before any count, such as a status of the page it leads to. It is part of the item's name, so keep it to a word; an item with a count is named by its label and count alone. NavigationRail and NavigationBar leave it out.

Nested sections

const projects: NavigationDrawerDestination = {
  label: 'Projects',
  href: '/guide/projects',
  children: [
    { label: 'Create a project', href: '/guide/projects/create' },
    { label: 'Sharing', children: [{ label: 'Invite your team', href: '/guide/projects/invite' }] },
  ],
};

A destination's children nest under it, at any depth. A section with href or onClick is a destination itself, with a button at its end that opens and closes it; one with neither is only its heading, a button that does. NavigationDrawer reads group at the top level only. In NavigationRail and NavigationBar, a destination whose children hold the current one takes the current fill and reports aria-current="true", so give the top level an href where the same array goes to them.

The sections that hold the current destination open as it becomes current; the drawer keeps the rest as people open and close them, from defaultExpanded. To keep them yourself, such as for the next visit, pass expanded and update it from onExpandedChange, which also asks to open the sections of a new current destination. Both name a section by its id, else its href, else its label: give a section whose label is translated an id, so its name stays the same in every language.

const [expanded, setExpanded] = useState<string[]>([]);
<NavigationDrawer destinations={destinations} expanded={expanded} onExpandedChange={setExpanded} />;

A NavigationRail's header holds the main action or a button that opens the navigation, and footer the account's AvatarButton; Scaffold gives the rail the window's height. Give a NavigationBar three to five destinations: past five it shows the first four and More.

Background and name

<NavigationBar background="page" aria-label="Project" destinations={destinations} />

background is default, the navigation's fill, the same as TopBar's, or page, the page's background. aria-label names the landmark; give each navigation landmark of a page its own.

Resizable

With resizable, people resize a NavigationDrawer between minWidth and maxWidth, 15 and 30 rem by default, and never past half its container or the window; the arrow keys move the handle 1 rem, and 5 with Shift. It starts at defaultWidth, 17.5 rem, which a double click on the handle returns to. width and onWidthChange let the application keep the width for the next visit, in rem: onWidthChange reports each width while dragging, and with committed true as a drag ends, a key moves the handle or a double click resets it. A width that isn't a finite number is ignored. resizeLabel names the handle. A drag renders the drawer and its handle on each move, and its destinations only when destinations is another array: where the application keeps the width, keep the array as it is while the width changes, such as with useMemo.

The example keeps the width in the browser's storage. It reads it once the drawer is in the browser, so a page rendered on a server starts at the default, and guards, since storage can be blocked or hold something that isn't a number.

ResizableDrawer.tsx
import { useEffect, useMemo, useState } from 'react';
import { NavigationDrawer, type NavigationDrawerDestination } from '@envsemble/envui';

const pages = [
  { href: '/guide/overview', label: 'Overview', group: 'Getting started' },
  { href: '/guide/install', label: 'Install the app on your devices', group: 'Getting started' },
  { href: '/guide/invite', label: 'Invite your team', group: 'Getting started' },
  { href: '/account/profile', label: 'Profile', group: 'Account' },
  { href: '/account/billing', label: 'Billing and invoices', group: 'Account' },
];

const storageKey = 'guide-drawer-width';

/**
 * The width people chose on an earlier visit, if any. Storage is the browser's, and can be missing,
 * blocked or hold a value that isn't a number, so the drawer then keeps its default.
 */
function storedWidth() {
  try {
    const stored = Number(localStorage.getItem(storageKey) ?? Number.NaN);
    return Number.isFinite(stored) ? stored : undefined;
  } catch {
    return undefined;
  }
}

export default function ResizableDrawer() {
  const [current, setCurrent] = useState('/guide/install');
  // The application keeps the width people choose, for their next visit. It reads it once the
  // drawer is in the browser, so a page rendered on a server starts at the default as well.
  const [width, setWidth] = useState(17.5);
  useEffect(() => {
    const stored = storedWidth();
    if (stored !== undefined) setWidth(stored);
  }, []);
  // The same array while the width changes, so a drag renders the drawer, not each destination.
  const destinations = useMemo<NavigationDrawerDestination[]>(
    () =>
      pages.map((page) => ({
        ...page,
        current: current === page.href,
        onClick: () => setCurrent(page.href),
      })),
    [current],
  );
  return (
    <NavigationDrawer
      aria-label="Guide"
      destinations={destinations}
      resizable
      width={width}
      minWidth={15}
      maxWidth={24}
      onWidthChange={(next, { committed }) => {
        setWidth(next);
        if (!committed) return;
        try {
          localStorage.setItem(storageKey, String(next));
        } catch {
          // Storage is blocked or full: the width lasts until the page closes.
        }
      }}
    />
  );
}

A heading cut short takes no focus, so it gives its full text as its title. Keep a resizable drawer off background="page", so its handle doesn't float beside the page's content, and leave resizable off a drawer in a sheet of a narrow window.

Composition

The parts the anatomy doesn't show: a section's row, env-navigation-drawer-branch-row, and the handle on a resizable drawer's end edge, env-navigation-drawer-resize-handle, the same grip as Panes.Divider's; the rail's list of destinations, env-navigation-rail-list, which scrolls where the rail is shorter; the bar's row of destinations, env-navigation-bar-list, and More, env-navigation-bar-more. The current item of each has data-current, a section's button aria-expanded, and a form on the page's background data-background="page".

Accessibility

You provide

  • current from the router, on the nested destination itself where the page is in a section, and the routing behind href through LinkProvider.
  • An aria-label on each navigation landmark when a page has more than one.
  • Unique labels.
  • Names for the controls in the header and the footer.

envUI provides

  • A nav landmark named Main, of native links and buttons, with aria-current="page" on the current one.
  • A count in its destination's name, such as Inbox, 12 new.
  • The drawer's group headings at headingLevel, each naming its group's list.
  • A section's button, with aria-expanded and the list it opens in aria-controls: the section's own item where it has no page, else a button at the end of its link, named such as Projects contents. A closed section keeps its links in the page, hidden until found, so search engines follow them and find in page opens the section. The sections that hold the current destination open, and the expand animation and the chevron's flip stop under reduced motion.
  • The bar's More as a menu button, named with its counts and whether it holds the current page.
  • Items at least 2.75 rem tall in the bar, and a 2.75 rem touch target in the drawer on coarse pointers.
  • With resizable, a focusable separator on the drawer's end edge, named Resize navigation, with its width as its value.

Props

NavigationDrawer

The navigation drawer for expanded and larger windows: a column at the window's start with every destination as a row of icon, label and count, under the headings of their groups, and nested destinations in sections that open and close. A `nav` landmark; the current destination reports aria-current="page", and every destination and section button is a tab stop. Its list scrolls where the drawer is shorter than its destinations. With `resizable`, people resize it by its end edge.

PropTypeDescription
destinationsrequired
readonly NavigationDrawerDestination[]
The application's destinations, the same array NavigationBar and NavigationRail take: each a row with its icon, its label and its count. A heading starts each run of destinations of one `group`. A destination with `children` is a section that opens and closes, with them under it, indented.
expanded
readonly string[]
The open sections, for a drawer whose sections the application keeps open, such as for the next visit: each a destination with `children`, named by its `id`, else its `href`, else its label. Update it from onExpandedChange. Leave it out for the drawer to keep its own.
defaultExpandedDefault []
readonly string[]
The sections open at first, as in `expanded`, for a drawer that keeps its own. The sections that hold the current destination open as well.
onExpandedChange
(expanded: string[]) => void
Receives the open sections, as in `expanded`, as one opens or closes, and as the current destination moves into a closed section, which opens.
header
ReactNode
Content above the destinations, such as the application's name or a search trigger.
headingLevelDefault 2
Exclude<HeadingLevel, 1>
The level of the groups' headings, which assistive technology lists with the page's headings: one below the heading the drawer sits under, such as 3 in a drawer under an h2.
backgroundDefault 'default'
'default''page'
The drawer's background, named as Surface's and Card's: default, the navigation's background, the default surface color, as a plain rectangle; page, the page's background, so it sits on the page. On a surface, such as a Sheet, both take the surface's color; a Sheet's is the navigation's background, so it keeps its color in a narrow window. Set it on its own, such as a NavigationDrawer with its background under a TopBar with page.
aria-labelDefault messages.navigation.label, Main
string
Names the navigation landmark, such as Main or Project; give each landmark of a page its own.
resizableDefault false
boolean
Lets people resize the drawer by its inner edge, the end edge: a handle there that the pointer drags and the arrow keys move, between `minWidth` and `maxWidth`, which a double click returns to `defaultWidth`. Its labels and headings keep to one line: one that doesn't fit ends in an ellipsis, a label cut short shows in full in a tooltip and a heading in its title. Leave it out of a drawer in a sheet of a narrow window.
width
number
The drawer's width in rem while `resizable`, for a drawer the application controls with onWidthChange, such as a width it keeps for the next visit.
defaultWidthDefault 17.5
number
The width in rem a `resizable` drawer starts at until people resize it, and that a double click on the handle returns it to.
onWidthChange
(width: number, details: ResizeDetails) => void
Receives each new width of a `resizable` drawer, in rem: while dragging with committed false, and with committed true as a drag ends, a key moves the handle or a double click resets it. Store the committed width to keep it, and pass it back as `width`.
minWidthDefault 15
number
The smallest width of a `resizable` drawer, in rem.
maxWidthDefault 30
number
The largest width of a `resizable` drawer, in rem. It never takes more than half its container, such as the Scaffold, or of the window.
resizeLabelDefault 'Resize navigation', or EnvUIProvider's messages.navigation.resize
string
The resize handle's name.
skeletonDefault false
boolean
Shows it and its content as a busy skeleton while that loads; turning it on or off renders the content anew.

Also accepts the native props of an HTML element, such as ref, className and aria-* attributes.

NavigationRail

The navigation rail for medium windows, such as tablets: a narrow column at the window's start with the destinations one under another, each an icon above a short label. A `nav` landmark; the current destination reports aria-current="page", and every destination is a tab stop.

PropTypeDescription
destinationsrequired
readonly Destination[]
The application's destinations, the same array NavigationBar and NavigationDrawer take, each an icon above a short label. Groups don't show: the destinations follow one another in the array's order. Nested destinations don't show either: a destination whose `children` hold the current one takes the current fill.
header
ReactNode
Content above the destinations, such as a button that opens a Sheet with the NavigationDrawer, or the main action of the page.
backgroundDefault 'default'
'default''page'
The rail's background, named as Surface's and Card's: default, the navigation's background, the default surface color, as a plain rectangle; page, the page's background, so it sits on the page. On a surface, such as a Sheet, both take the surface's color; a Sheet's is the navigation's background, so it keeps its color in a narrow window. Set it on its own, such as a NavigationRail with its background beside a TopBar with page.
aria-labelDefault messages.navigation.label, Main
string
Names the navigation landmark, such as Main or Project; give each landmark of a page its own.
skeletonDefault false
boolean
Shows it and its content as a busy skeleton while that loads; turning it on or off renders the content anew.

Also accepts the native props of an HTML element, such as ref, className and aria-* attributes.

NavigationBar

The navigation bar for compact windows, such as phones: up to five destinations side by side, each an icon above its label, at the bottom of the window. A `nav` landmark; the current destination reports aria-current="page", and every destination is a tab stop.

PropTypeDescription
destinationsrequired
readonly Destination[]
The application's destinations, the same array NavigationRail and NavigationDrawer take. Give the bar 3 to 5: past 5 it shows the first 4 and a More item that opens the rest in a menu. Groups and nested destinations don't show: a destination whose `children` hold the current one takes the current fill.
backgroundDefault 'default'
'default''page'
The bar's background, named as Surface's and Card's: default, the navigation's background, the default surface color, as a plain rectangle; page, the page's background, so it sits on the page. On a surface, such as a Sheet, both take the surface's color. Set it on its own, such as a NavigationBar with page under a TopBar with its background.
aria-labelDefault messages.navigation.label, Main
string
Names the navigation landmark, such as Main or Project; give each landmark of a page its own.
skeletonDefault false
boolean
Shows it and its content as a busy skeleton while that loads; turning it on or off renders the content anew.

Also accepts the native props of <nav>, such as ref, className and aria-* attributes.

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

NavigationDrawer

env-navigation-drawer
The navigation drawer: a nav landmark, a column with the header, the list and the footer.
env-navigation-drawer-branch
A section: a destination with destinations nested under it, which open and close below it.
env-navigation-drawer-branch-row
A section's row: the destination's link or button, with the section's button over its end.
env-navigation-drawer-chevron
A section's chevron, which points down, and up while the section is open.
env-navigation-drawer-children
The list of a section's nested destinations, indented, hidden until found while closed.
env-navigation-drawer-count
A destination's count, at the item's end; 999+ above 999.
env-navigation-drawer-footer
Content below the destinations, at the bottom of the drawer.
env-navigation-drawer-group-label
A group's label, which names the group's list.
env-navigation-drawer-header
Content above the destinations, such as the application's name or a search trigger.
env-navigation-drawer-item
A destination: a link or button as wide as the drawer, with its icon, label and count.
env-navigation-drawer-label
A destination's label.
env-navigation-drawer-list
The list of destinations and groups, which scrolls where the drawer is shorter.
env-navigation-drawer-resize-handle
The handle on a resizable drawer's end edge, a separator that resizes it.
env-navigation-drawer-section
A group: its heading and the list of its destinations.
env-navigation-drawer-toggle
The button at the end of a section's link that opens and closes the section.
data-background
page
data-current
Any value, or present on env-navigation-drawer-item
data-ending-style
Any value, or present on env-navigation-drawer-children
data-env-skeleton
Any value, or present
data-resizable
Present
data-starting-style
Any value, or present on env-navigation-drawer-children

NavigationRail

env-navigation-rail
The navigation rail: a nav landmark, a narrow column with the header, the list and the footer.
env-navigation-rail-count
A destination's count, on the icon's top end; 999+ above 999.
env-navigation-rail-footer
Content at the bottom of the rail, such as the account's button.
env-navigation-rail-header
Content above the destinations, such as a button that opens the drawer or the main action.
env-navigation-rail-indicator
The rounded rectangle around a destination's icon, filled while the destination is current.
env-navigation-rail-item
A destination: a link or button with its indicator above its label.
env-navigation-rail-label
A destination's short label, under its indicator.
env-navigation-rail-list
The list of destinations, one under another, which scrolls where the rail is shorter.
data-background
page
data-current
Any value, or present on env-navigation-rail-item
data-env-skeleton
Any value, or present

NavigationBar

env-navigation-bar
The navigation bar: a nav landmark across the bottom of a compact window.
env-navigation-bar-count
A destination's count, on the icon's top end; 999+ above 999.
env-navigation-bar-indicator
The rounded rectangle around a destination's icon, filled while the destination is current.
env-navigation-bar-item
A destination: a link or button with its indicator above its label.
env-navigation-bar-label
A destination's label, under its indicator, cut short with an ellipsis.
env-navigation-bar-list
The row of destinations, which share the bar's width.
env-navigation-bar-more
The last item past five destinations, which opens the rest in a menu.
data-background
page
data-current
Present on env-navigation-bar-item
data-env-skeleton
Any value, or present

Color properties

Each sets a color of one instance, through style or CSS; its hover, focus and press follow it. They are public and versioned.

NavigationDrawer

--env-navigation-drawer-item-container
The item at rest; set, it takes state layers instead of the navigation's designed hover and press fills.
--env-navigation-drawer-item-content
The label, and the color of the state layers.
--env-navigation-drawer-item-icon
The icon of a destination that is not current.
--env-navigation-drawer-item-selected
The item of the current destination.
--env-navigation-drawer-item-selected-content
The label and icon of the current destination.

NavigationRail

--env-navigation-rail-item-container
The indicator around the icon at rest; set, it takes state layers instead of the navigation's designed hover and press fills.
--env-navigation-rail-item-content
The label, and the color of the state layers.
--env-navigation-rail-item-icon
The icon of a destination that is not current.
--env-navigation-rail-item-selected
The indicator around the icon of the current destination.
--env-navigation-rail-item-selected-content
The icon of the current destination.

NavigationBar

--env-navigation-bar-item-container
The indicator around the icon at rest; set, it takes state layers instead of the navigation's designed hover and press fills.
--env-navigation-bar-item-content
The label, and the color of the state layers.
--env-navigation-bar-item-icon
The icon of a destination that is not current.
--env-navigation-bar-item-selected
The indicator around the icon of the current destination.
--env-navigation-bar-item-selected-content
The icon of the current destination.

Strings

EnvUIProvider's messages overrides each, and a component's own label prop wins over it.

messages.navigation.label
"Main" Names the navigation landmark where the application gives it no name of its own.
messages.navigation.itemCount
({ label, count }) => string Names a destination with a count, such as Inbox, 3 new.
messages.navigation.more
"More" Labels the NavigationBar's last item, which opens the destinations that don't fit in a menu.
messages.navigation.moreCurrent
({ name }) => string Names the NavigationBar's More item while its menu holds the current destination, after its name with any count, such as More, 5 new, current page inside.
messages.navigation.contents
({ label }) => string Names the button beside a NavigationDrawer destination that opens and closes the destinations nested under it, such as Guides contents; its aria-expanded says whether they show.
messages.navigation.resize
"Resize navigation" Names the handle that resizes a resizable NavigationDrawer.
messages.navigation.width
({ width }) => string The resize handle's value: the NavigationDrawer's width in rem, such as 17.5 rem, in the locale's digits.

Changelog

1.0.0

NavigationDrawer, the navigation drawer, NavigationRail, the navigation rail, and NavigationBar, the navigation bar; together they replace NavList and the sidebar's rail. NavList.Item's selected is a destination's current, its end count is count, and NavList.Group is a destination's group.

1.0.0-prerelease.6

A closing section's destinations fade over its whole closing, --env-motion-duration-medium, so they leave no empty band.

Colors under the pointer and while pressed change with --env-motion-ease-enter, rather than easing in slowly, so hover and press show from the first frame.

A section's chevron turns half a circle rather than flipping over, and its destinations fade in and out as they grow and shrink.

Nested destinations under a section with an icon sit in past it, so their labels start a step inside the section's label rather than before it.

The indicators are rounded rectangles with the medium corner, rather than Material's pills: NavigationRail's and NavigationBar's around the icon, narrower at 2.75 rem, and each NavigationDrawer item. A NavigationRail item's keyboard focus ring rings its indicator, 2 px out, rather than the whole item.

1.0.0-prerelease.5

Bar, Rail and Drawer are NavigationBar, NavigationRail and NavigationDrawer, with their props types and NavigationDrawerDestination, after Material's names, so they don't read as an overlay drawer or any bar; the page is navigation-drawer. Their part classes and color properties take the same names: env-bar-*, env-rail-* and env-drawer-* are env-navigation-bar-*, env-navigation-rail-* and env-navigation-drawer-*. NavigationBar names its parts as NavigationRail does, -indicator and -label where they were -icon and -text, and a group's label is env-navigation-drawer-group-label, as in Menu, where it was env-drawer-heading.

A destination runs onClick(event), as a link or a button does, where it ran onSelect(), and takes an optional id, which names its section in expanded in every language.

A Sheet is on the navigation's background, --env-color-bg-default, so a NavigationDrawer or a NavigationRail in the sheet of a narrow window keeps its color. A destination's badge shows a short word at the end of its NavigationDrawer item, such as New.

1.0.0-prerelease.4

A destination's children nest under it: Drawer shows them indented, in a section that opens and closes with a chevron, open while it holds the current destination, whose links stay in the page, hidden until found. A section without href or onSelect is only its heading. expanded, defaultExpanded and onExpandedChange keep the open sections. Rail and Bar show the top level, and mark a destination whose section holds the current one.

1.0.0-prerelease.3

A resizable Drawer keeps each label and group heading on one line as it narrows: one that doesn't fit ends in an ellipsis, a label cut short shows in full in a tooltip, and a heading cut short gives its full text as its title. Bar and Rail are documented on this page, drawer, which their pages' addresses open.

1.0.0-prerelease.2

With resizable, people resize it by its end edge, between minWidth and maxWidth and never past half its container, from defaultWidth; width and onWidthChange keep it. The handle is env-drawer-resize-handle, the same grip as Panes.Divider's.