Scaffold

The frame of an application's screen, around its top bar, its navigation and the page.

BetaSince 1.0.0
On this page

Import

import { Scaffold } from '@envsemble/envui';

Usage

Render one Scaffold at the application's root, around the router's outlet, and keep it mounted as the pages change.

  • topBar holds the top bar, usually a TopBar.
  • navigation holds the forms, such as { bar: <NavigationBar … />, rail: <NavigationRail … />, drawer: <NavigationDrawer … /> }, with the same destinations.
  • children is the body, the current page, usually a Page or Panes.
  • One form shows at a time, chosen by the Scaffold's own width, in CSS; give only the forms the application uses, and don't pick one in script from the window's width.
NavigationForms.tsx
import { useState, type CSSProperties } from 'react';
import {
  NavigationBar,
  NavigationDrawer,
  IconButton,
  List,
  Page,
  PageHeader,
  NavigationRail,
  Scaffold,
  TopBar,
  type Destination,
} from '@envsemble/envui';
import { IconCategory } from '@envsemble/envui/icons/Category';
import { IconHome } from '@envsemble/envui/icons/Home';
import { IconInbox } from '@envsemble/envui/icons/Inbox';
import { IconSearch } from '@envsemble/envui/icons/Search';

/** Stands in for the window: a Scaffold fills its container, the window's height by default. */
const preview: CSSProperties = { blockSize: '22rem' };

/** What each destination shows: a list longer than the window, which scrolls under the bars. */
const contents: Record<string, { label: string; description: string }[]> = {
  Home: [
    { label: 'Website refresh', description: 'Updated today by Priya Shah' },
    { label: 'Quarterly report', description: 'Updated yesterday by Sam Lee' },
    { label: 'Onboarding guide', description: 'Updated on 4 March by Alex Morgan' },
    { label: 'Mobile release', description: 'Updated on 2 March by Jordan Kim' },
    { label: 'Data migration', description: 'Updated on 27 February by Chen Wei' },
    { label: 'Annual review', description: 'Updated on 20 February by Maria Garcia' },
    { label: 'Pricing page', description: 'Updated on 18 February by Sam Lee' },
    { label: 'Support handbook', description: 'Updated on 12 February by Priya Shah' },
    { label: 'Design review', description: 'Updated on 9 February by Alex Morgan' },
    { label: 'Hiring plan', description: 'Updated on 3 February by Maria Garcia' },
    { label: 'Partner newsletter', description: 'Updated on 28 January by Jordan Kim' },
    { label: 'Security audit', description: 'Updated on 21 January by Chen Wei' },
    { label: 'Team offsite', description: 'Updated on 15 January by Sam Lee' },
    { label: 'Budget for the year', description: 'Updated on 8 January by Priya Shah' },
  ],
  Inbox: [
    { label: 'Priya Shah', description: 'Shared the launch plan with you' },
    { label: 'Sam Lee', description: 'Commented on the quarterly report' },
    { label: 'Jordan Kim', description: 'Invited you to Mobile release' },
  ],
  Projects: [
    { label: 'Website refresh', description: 'Due on 14 March' },
    { label: 'Quarterly report', description: 'Due on 31 March' },
    { label: 'Onboarding guide', description: 'Due on 6 April' },
    { label: 'Mobile release', description: 'Due on 20 April' },
    { label: 'Data migration', description: 'Due on 4 May' },
    { label: 'Annual review', description: 'Due on 29 May' },
  ],
};

export default function NavigationForms() {
  const [current, setCurrent] = useState('Home');
  // One array of destinations, which each navigation form takes.
  const destinations: Destination[] = [
    { label: 'Home', icon: <IconHome /> },
    { label: 'Inbox', icon: <IconInbox />, count: 3 },
    { label: 'Projects', icon: <IconCategory /> },
  ].map((destination) => ({
    ...destination,
    current: destination.label === current,
    onClick: () => setCurrent(destination.label),
  }));
  return (
    // The Scaffold shows the form for its own width: the bar in a compact width, the rail in a
    // medium one and the drawer from expanded.
    <Scaffold
      scroll="body"
      landmarks={false}
      style={preview}
      topBar={
        <TopBar
          title="Northwind"
          actions={<IconButton icon={<IconSearch />} aria-label="Search" />}
        />
      }
      navigation={{
        bar: <NavigationBar destinations={destinations} />,
        rail: <NavigationRail destinations={destinations} />,
        drawer: <NavigationDrawer destinations={destinations} />,
      }}
    >
      <Page header={<PageHeader title={current} headingLevel={3} />}>
        <List.Root aria-label={current}>
          {contents[current]?.map((item) => (
            <List.Item key={item.label} label={item.label} description={item.description} />
          ))}
        </List.Root>
      </Page>
    </Scaffold>
  );
}

Which form shows

navigationForms sets which form shows at each window class, compact, medium, expanded and large, or none; the default is { compact: 'bar', medium: 'rail', expanded: 'drawer', large: 'drawer' }.

  • The window classes are those of the Scaffold's own width: compact below 37.5 rem, medium from 37.5 to 52.5 rem, expanded from 52.5 to 75 rem and large from 75 rem.
  • { compact: 'none' } suits an application that opens its navigation from the top bar on a phone.
  • Where the form a window class asks for wasn't given, the nearest given form shows: a rail for a drawer and the other way round, then the bar.
<Scaffold
  navigationForms={{ compact: 'none' }}
  topBar={<TopBar title="Settings" />}
  navigation={{ drawer: <NavigationDrawer aria-label="Settings" destinations={destinations} /> }}
>
  …
</Scaffold>

Narrowing the navigation

A button that narrows the navigation sets navigationForms={{ expanded: 'rail', large: 'rail' }}, and the application keeps the choice, such as in localStorage.

<Scaffold
  navigationForms={narrow ? { expanded: 'rail', large: 'rail' } : undefined}
  topBar={
    <TopBar
      start={
        <IconButton
          icon={narrow ? <IconLeftPanelOpen /> : <IconLeftPanelClose />}
          aria-label={narrow ? 'Show full navigation' : 'Narrow navigation'}
          onClick={toggle}
        />
      }
      title="Northwind"
    />
  }
  navigation={{
    bar: <NavigationBar aria-label="Sections" destinations={destinations} />,
    rail: <NavigationRail aria-label="Sections" destinations={destinations} />,
    drawer: <NavigationDrawer aria-label="Sections" destinations={destinations} />,
  }}
>
  …
</Scaffold>

Top bar span and backgrounds

  • topBarSpan is body, the default, where the top bar sits above the body beside the rail or drawer, which run the full height, or full, where it runs across the whole width above the navigation, as a window's title bar must.
  • background="page" on a TopBar, a NavigationRail or a NavigationDrawer puts it on the page's background instead of the navigation's, the default; on a surface, such as a Sheet, both values take the surface's color. A Sheet is on the navigation's background, so the navigation keeps its color in the sheet of a narrow window.
<Scaffold topBar={<TopBar title="Northwind" background="page" />} navigation={{ drawer }}>
  …
</Scaffold>

Scrolling

scroll is document, the default, where the page scrolls: a phone collapses its address bar, pull to refresh and a tap on the status bar work, the browser restores the scroll position, the top bar and the bar stick to the window's edges and the rail or the drawer stays in view, in a Scaffold at least as tall as the window.

  • With scroll="body", the body alone scrolls under the top bar, in a Scaffold that fills its container's height, 100dvh unless a class gives it another, with the keyboard scrolling the body as it would the document.
  • It suits a preview or an embedded frame, a window with its own title bar, or a body of Panes whose panes scroll on their own. Give the Scaffold a block size then, as these examples do.
  • bodyRef reaches the body, to read or restore its scroll position.

The top bar is the banner landmark and the body the main one, with the id bodyId, main by default, the target of the skip link.

  • skipToContentLabel and skipToNavigationLabel name the skip links.
  • landmarks={false} renders divs and no skip links, for a preview of an application inside another page's main content.
  • name names the node for dev mode, as data-env-name.

Composition

The parts are the root, env-scaffold, the top bar, env-scaffold-top-bar, the navigation's places, env-scaffold-bar, env-scaffold-rail and env-scaffold-drawer, the body, env-scaffold-body, and the skip links, env-scaffold-skip-links.

  • The page's title and actions go in its PageHeader, not the top bar, which belongs to the frame; the app frame pattern puts them together.

Accessibility

You provide

  • The navigation forms, each a named nav, with the current destination marked.
  • The page's one h1, usually its PageHeader's title, and focus and document.title after a navigation.

envUI provides

  • The top bar as the banner and the body as the main landmark; landmarks={false} renders divs and no skip links, for a preview inside another page.
  • Skip links, named in the provider's language.
  • Only the form that shows in the accessibility tree and the tab order.

Props

Scaffold

The invisible structure of an application's screen, after Material's: a top bar region, a navigation region and the body. It never paints. It shows one navigation form at a time by its own width, CSS only: a NavigationBar at the bottom of a compact window, a NavigationRail at the start of a medium one and a NavigationDrawer at the start from expanded. The document scrolls by default, with the top bar, the bar and the rail or the drawer kept in view; `scroll="body"` scrolls the body alone. Skip links to the body and the navigation come first.

PropTypeDescription
topBar
ReactNode
The top bar region, above the body: usually a TopBar.
navigation
ScaffoldNavigation
The navigation forms, such as `{ bar: <NavigationBar … />, rail: <NavigationRail … />, drawer: <NavigationDrawer … /> }` with the same destinations. One shows at a time, chosen by the Scaffold's own width; give only the forms the application uses.
navigationFormsDefault { compact: 'bar', medium: 'rail', expanded: 'drawer', large: 'drawer' }
ScaffoldForms
Which form shows at each window class, or none, such as `{ compact: 'none' }` for an application that opens its navigation from the top bar on a phone. Where the form a window class asks for wasn't given, the nearest given form shows: a rail for a drawer and the other way round, then the bar.
topBarSpanDefault 'body'
'body''full'
body: the top bar sits above the body, beside the rail or drawer, which run the full height. full: the top bar runs across the whole width, above the navigation, as a window's title bar must.
scrollDefault 'document'
'document''body'
What scrolls. document: the page, as on any website, so a phone collapses its address bar, pull to refresh and a tap on the status bar work and the browser restores the scroll position; the top bar and the bar stick to the window's edges and the rail or the drawer stays in view, in a Scaffold at least as tall as the window. body: the body alone, under the top bar, in a Scaffold that fills its container's height, 100dvh unless a class gives it another, with the keyboard scrolling the body as it would the document: for a preview or an embedded frame, a window with its own title bar, or a body of Panes whose panes scroll on their own.
bodyIdDefault 'main', or an id of its own where `landmarks` is false
string
The body's id, the target of the skip link to the content.
bodyRef
Ref<HTMLElement>
The body's element: with `scroll="body"` the element that scrolls, to read or restore its scroll position on.
skipToContentLabelDefault 'Skip to content', or EnvUIProvider's messages.scaffold.skipToContent
string
The name of the skip link to the body.
skipToNavigationLabelDefault 'Skip to navigation', or EnvUIProvider's messages.scaffold.skipToNavigation
string
The name of the skip link to the navigation.
landmarksDefault true
boolean
false renders the regions without landmarks, a div for the banner and the main, and no skip links, for a preview of an application inside another page's main content.
name
string
A name for this node, such as "app", which dev mode and the docs show; it renders as `data-env-name`.
children
ReactNode
The body: the page's main content, usually a Page or Panes.

Also accepts the native props of <div>, 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.

env-scaffold
The scaffold: the top bar, navigation and body regions; it never paints.
env-scaffold-bar
The slot of the navigation bar, at the bottom of a compact window.
env-scaffold-body
The body, the main region, which scrolls.
env-scaffold-drawer
The slot of the navigation drawer, at the start from expanded.
env-scaffold-navigation
The navigation region, which holds a slot per form.
env-scaffold-rail
The slot of the navigation rail, at the start of a medium window.
env-scaffold-skip-link
One link that skips to the body or the navigation.
env-scaffold-skip-links
The skip links, shown on keyboard focus.
env-scaffold-top-bar
The top bar region, the banner.
data-env-layout
scaffold
data-env-name
Any value, or present
data-navigation-compact
bar · drawer · none · rail
data-navigation-expanded
bar · drawer · none · rail
data-navigation-form
bar · drawer · rail on env-scaffold-bar, env-scaffold-drawer, env-scaffold-rail
data-navigation-large
bar · drawer · none · rail
data-navigation-medium
bar · drawer · none · rail
data-scroll
body · document
data-skip
navigation on env-scaffold-skip-link
data-top-bar-span
body · full

Strings

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

messages.scaffold.skipToContent
"Skip to content" The skip link to the body, the page's main content.
messages.scaffold.skipToNavigation
"Skip to navigation" The skip link to the navigation that shows.

Changelog

1.0.0

Scaffold, which replaces AppShell and WindowFrame: a top bar region, the navigation forms by window class, and the body, with the document or the body scrolling.

1.0.0-prerelease.5

skipLabel and skipNavigationLabel are skipToContentLabel and skipToNavigationLabel, after their messages. forms is navigationForms, and NavigationForm is ScaffoldNavigationForm, so neither reads as a form of fields. resolveNavigationForms is internal.