On this page
Import
import {
Avatar,
AvatarButton,
Button,
Column,
CommandPalette,
IconButton,
List,
Menu,
Menubar,
NavigationBar,
NavigationDrawer,
NavigationRail,
Page,
PageHeader,
Panes,
Row,
Scaffold,
Sheet,
Text,
ThemeMenuRadioGroup,
Tooltip,
TopBar,
useContainerSize,
windowClassOf,
} from '@envsemble/envui';
Usage
Render one Scaffold at the application's root, around the router's outlet.
- Its
topBaris a TopBar with the application'stitleand, inactions, the search IconButton in a Tooltip and the account menu's AvatarButton. - Its
navigationtakesbar,railanddrawer, a NavigationBar, a NavigationRail and a NavigationDrawer given the same array of destinations, withcurrentset from the router. - The router's page, a Page with its PageHeader, is the Scaffold's children.
- The Scaffold shows the bar on a
compactwindow, the rail on amediumone and the drawer fromexpanded, by its own container's width, andnavigationFormschanges the form for a window class; don't choose the form in script from the window's width.
import { useMemo, useState, type CSSProperties } from 'react';
import {
Avatar,
AvatarButton,
NavigationBar,
Column,
CommandPalette,
NavigationDrawer,
IconButton,
List,
Menu,
Page,
PageHeader,
NavigationRail,
Scaffold,
Text,
ThemeMenuRadioGroup,
Tooltip,
TopBar,
type CommandPaletteItem,
type Destination,
} from '@envsemble/envui';
import { IconCategory } from '@envsemble/envui/icons/Category';
import { IconDescription } from '@envsemble/envui/icons/Description';
import { IconGroup } from '@envsemble/envui/icons/Group';
import { IconHome } from '@envsemble/envui/icons/Home';
import { IconLogout } from '@envsemble/envui/icons/Logout';
import { IconManageAccounts } from '@envsemble/envui/icons/ManageAccounts';
import { IconPalette } from '@envsemble/envui/icons/Palette';
import { IconSearch } from '@envsemble/envui/icons/Search';
import { IconSettings } from '@envsemble/envui/icons/Settings';
/** Stands in for the window: a Scaffold fills its container, the window's height by default. */
const preview: CSSProperties = { blockSize: '28rem' };
/**
* An application's frame: a Scaffold with a top bar of the name, search and the account menu, the
* same destinations as a bar on a phone, a rail on a tablet and a drawer from expanded, and the
* page in the body. Render it once, at the application's root, around the router's outlet.
*/
export default function AppFrame() {
// The router knows the current page; here a choice stands in for it.
const [current, setCurrent] = useState('/');
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState('');
// One array of destinations, which every navigation form takes.
const destinations: Destination[] = places.map(({ label, href, icon, group }) => ({
label,
href,
icon,
group,
current: href === current,
onClick: () => setCurrent(href),
}));
const page = places.find((place) => place.href === current) ?? places[0];
// Search goes to a destination; the application filters and ranks the items.
const items = useMemo<CommandPaletteItem[]>(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
return places
.filter((place) => words.every((word) => place.label.toLowerCase().includes(word)))
.map((place) => ({ id: place.href, label: place.label, icon: place.icon, group: 'Go to' }));
}, [query]);
// The account menu pattern: the avatar opens a Menu whose header says who is signed in.
const account = (
<Menu.Root
aria-label="Account"
align="end"
trigger={<AvatarButton name={user.name} description={user.email} />}
>
<Menu.Header>
<Column align="center" gap={1} padding={2} style={{ textAlign: 'center' }}>
<Avatar name={user.name} size="lg" />
<Text>{user.name}</Text>
<Text variant="body-sm" emphasis="muted">
{user.email}
</Text>
</Column>
</Menu.Header>
<Menu.Item href="/account" icon={<IconManageAccounts />}>
Account settings
</Menu.Item>
<Menu.Submenu label="Theme" icon={<IconPalette />}>
<ThemeMenuRadioGroup />
</Menu.Submenu>
<Menu.Separator />
<Menu.Item icon={<IconLogout />}>Sign out</Menu.Item>
</Menu.Root>
);
return (
<Scaffold
scroll="body"
landmarks={false}
style={preview}
topBar={
<TopBar
title="Northwind"
actions={
<>
<Tooltip content="Search" side="bottom">
<IconButton
variant="ghost"
icon={<IconSearch />}
aria-label="Search"
onClick={() => setSearching(true)}
/>
</Tooltip>
{account}
</>
}
/>
}
navigation={{
bar: <NavigationBar destinations={destinations} />,
rail: <NavigationRail destinations={destinations} />,
drawer: <NavigationDrawer destinations={destinations} headingLevel={3} />,
}}
>
<Page width="lg" header={<PageHeader title={page.label} headingLevel={3} />}>
{/* The router renders the current page here; each place stands in with a few items. */}
<Column gap={4}>
<Text emphasis="muted">{page.lead}</Text>
<List.Root aria-label={page.label}>
{page.items.map((item) => (
<List.Item key={item} label={item} />
))}
</List.Root>
</Column>
</Page>
<CommandPalette
open={searching}
onOpenChange={(open) => {
setSearching(open);
if (!open) setQuery('');
}}
aria-label="Search"
placeholder="Search pages"
inputValue={query}
onInputValueChange={setQuery}
items={items}
onAction={(item) => setCurrent(item.id)}
emptyLabel="No matching pages"
/>
</Scaffold>
);
}
const user = { name: 'Alex Morgan', email: 'alex.morgan@example.com' };
const places = [
{
label: 'Home',
href: '/',
icon: <IconHome />,
lead: 'Good morning, Alex. Here is what you opened lately.',
items: [
'Quarterly report',
'Website refresh',
'Launch brief',
'Budget 2026',
'Meeting notes',
'Hiring plan',
],
},
{
label: 'Projects',
href: '/projects',
icon: <IconCategory />,
lead: 'Three projects are in progress.',
items: ['Website refresh', 'Mobile release', 'Data migration'],
},
{
label: 'Documents',
href: '/documents',
icon: <IconDescription />,
lead: 'Shared with you this week.',
items: ['Launch brief', 'Budget 2026', 'Meeting notes'],
},
{
label: 'Team',
href: '/team',
icon: <IconGroup />,
lead: 'Six people work in Northwind.',
items: ['Priya Shah', 'Sam Lee', 'Jordan Kim'],
},
{
label: 'Settings',
href: '/settings',
icon: <IconSettings />,
group: 'Workspace',
lead: 'Settings for the whole workspace.',
items: ['General', 'Members', 'Billing'],
},
];A menu on phones
navigationForms={{ compact: 'none', medium: 'drawer' }} shows no navigation on a compact window and the drawer from medium. A menu IconButton in the TopBar's start opens a Sheet with side="start" holding the same NavigationDrawer, which closes the sheet when a destination is chosen.
import { IconMenu } from '@envsemble/envui/icons/Menu';
<Scaffold
navigationForms={{ compact: 'none', medium: 'drawer' }}
topBar={
<TopBar
title="Guide"
start={
<IconButton
icon={<IconMenu />}
aria-label="Open the chapters"
onClick={() => setMenu(true)}
/>
}
/>
}
navigation={{ drawer: <NavigationDrawer aria-label="Chapters" destinations={destinations} /> }}
>
{page}
<Sheet side="start" aria-label="Chapters" open={menu} onOpenChange={setMenu}>
<NavigationDrawer aria-label="Chapters" destinations={destinationsThatCloseTheSheet} />
</Sheet>
</Scaffold>;
Details beside the page
Panes.Root with behavior="supporting" fills the Scaffold's body: Panes.Main holds the page, with a Panes.Trigger that opens Panes.Supporting, and each pane scrolls on its own.
An installed app's window
Give the Scaffold topBarSpan="full", so its topBar runs across the whole window above the navigation, and scroll="body", so only the body scrolls under it.
- The
topBaris a TopBar withtitleBarand the window'stitle. - In a window that draws its own title bar, through the Window Controls Overlay, it fills the title bar area, leaves room for the system's window controls, drags the window by its empty space and centres its title.
- Its controls are small, with buttons that set no variant ghost.
- A Menubar goes in the TopBar's
start; give the page's PageHeaderstickyto keep it at the top of the scrolling body. - In a browser tab the title bar area is undefined, and the TopBar falls back to its own height across the full width.
- Zoomed in, the window reports a smaller area, which the small controls still fit.
<Scaffold
scroll="body"
topBarSpan="full"
topBar={
<TopBar
titleBar
title="Northwind"
start={
<Menubar.Root aria-label="Application menu">
<Menubar.Menu label="File">
<Menu.Item shortcut="Ctrl+N">New document</Menu.Item>
</Menubar.Menu>
</Menubar.Root>
}
/>
}
navigation={{ drawer: <NavigationDrawer destinations={destinations} /> }}
>
<Page header={<PageHeader title="Home" sticky />}>{content}</Page>
</Scaffold>
Composition
The parts are the top bar, env-top-bar, the search button, env-top-bar-actions > env-icon-button, the account menu's env-avatar-button, the rail, env-navigation-rail, and the page, env-page, with its env-page-header. Search opens a CommandPalette, and the account menu is the account and organizations pattern. In an installed app's window, the title bar is the Scaffold's env-top-bar, its menubar env-menubar and its title env-top-bar-title, above the body, env-scaffold-body.
Accessibility
You provide
- Which destination is current, and
document.titlefor each page, also in an installed app's window. - The page's one
h1, in its PageHeader. - Names for icon-only controls in the title bar.
envUI provides
- The banner and main landmarks, the skip links, and only the form that shows in the accessibility tree.
- A
navper form, witharia-current="page"on the current destination. - In an installed app's title bar, controls that stay clickable where the window drags.
Changelog
- 1.0.0
The app frame is a pattern built from Scaffold, TopBar and the navigation forms, in place of AppShell, the app frame template and the WindowFrame component; the window frame pattern is its installed app's window. The page is on the figure template.