Components / Structure
AppShell
The frame of an app: a top bar, the navigation beside the content, and the main region. When there is not enough room, the navigation moves into a drawer opened from a menu button.
When to useLink to "When to use"
- The outer layout of an app with a persistent navigation.
Use something else when:
- the page is a document or a marketing page: your own layout with Prose and Stack
- you only need the navigation: SideNav
UsageLink to "Usage"
import { AppShell, SideNav } from "@arshad-shah/cynosure";
<div style={{ blockSize: "100dvh" }}>
<AppShell header={<Brand />} nav={<SideNav label="Main" items={items} current={route.id} />}>
<Page />
</AppShell>
</div>The shell fills its container's height. The content and the navigation scroll on their own while the bar stays in place.
Padding and width of the pageLink to "Padding and width of the page"
The main region has no padding and no maximum width, so a page can run edge to edge (a map, a table, a canvas). For an ordinary page, wrap the content in Container, which pads it and caps its width.
<AppShell header={<Brand />} nav={<SideNav label="Main" items={items} />}>
<Container>
<Page />
</Container>
</AppShell>Narrow screensLink to "Narrow screens"
Below collapseBelow (768px of the shell's own width) the navigation leaves the layout. A menu button appears at the start of the bar and opens it in a start-side Sheet. Choosing a link or item closes the drawer; opening a group does not.
A tab bar on phonesLink to "A tab bar on phones"
tabs adds a bottom tab bar while the shell is narrow, for an app's three to five main sections. Pass a SideNav with layout="bar" and the same current; the drawer still holds the full navigation. The bar keeps clear of the phone's home indicator.
<AppShell
nav={<SideNav label="Main" items={items} current={page} onNavigate={go} />}
tabs={<SideNav label="Sections" layout="bar" items={sections} current={page} onNavigate={go} />}
>A SideNav entry with kind: "action" puts one raised primary button, such as "Add", in the bar. In the drawer the same entry is a full-width button, and pressing it closes the drawer.
Reading the narrow stateLink to "Reading the narrow state"
Do not repeat the breakpoint in your product. Anything rendered inside the shell (header, navigation, tabs, content) reads it with useAppShell(). Code outside the shell uses onNarrowChange.
import { AppShell, useAppShell } from "@arshad-shah/cynosure";
function Toolbar() {
const { narrow } = useAppShell();
return narrow ? <CompactToolbar /> : <FullToolbar />;
}
<AppShell header={<Toolbar />} onNarrowChange={(narrow) => setCompact(narrow)}>narrowistruewhile the shell's own width is belowcollapseBelow.- It is
falseon the server and on the first client render, so server markup hydrates without a mismatch. The shell measures itself before the first paint and renders again if it is narrow. - Outside an AppShell the hook does not throw. It returns
{ narrow: false }. onNarrowChangeis called once per change. A shell that mounts narrow calls it withtrueright after mounting; a shell that mounts wide does not call it until it narrows.- There is no hook for the drawer: the shell closes it itself when a link or button inside it is pressed.
PropsLink to "Props"
| Prop | What it does | Type | Default |
|---|---|---|---|
header | Top bar content: brand, search, account. | ReactNode | |
nav | The navigation, usually a SideNav. Beside the content when wide; in a drawer when narrow. | ReactNode | |
tabs | A bottom tab bar on narrow screens, usually <SideNav layout="bar"> with the app's three to five main sections. Hidden when wide, where nav shows instead. | ReactNode | |
children | The page content, rendered in <main>. | ReactNode | |
collapseBelow | Width (of the shell itself, in px) below which the navigation moves into a drawer. Default: 768. It follows the shell's width, not the window's, so it also works in side panels. | number | 768 |
onNarrowChange | Called with the new value each time the shell becomes narrow or wide again, for code outside the shell (inside it, use useAppShell()). The shell starts wide, so the first call is true, before the first paint, when it mounts narrow; a shell that mounts wide does not call it until it narrows. | ((narrow: boolean) => void) | |
labels | Replace the built-in English text: the menu button, the drawer title and the skip link. | Partial<AppShellLabels> | |
className | Added to the root element, for layout. | string | |
style | Layout only. Restyle through --cy-app-shell-* tokens. | CSSProperties |
Generated from the type definitions in @arshad-shah/cynosure 0.5.0.
Behaviour and accessibilityLink to "Behaviour and accessibility"
- Landmarks: the bar is a
<header>(banner), the navigation keeps its own<nav>, and the content is<main>. - Skip link: a SkipLink at the shell's own top-start corner. The first Tab shows "Skip to content"; Enter moves focus to
<main>without changing the URL, so your router stays in charge. - Drawer: a modal Sheet named "Navigation" with focus trapping and Escape; focus returns to the menu button when it closes. The button reports
aria-expanded. - Touch: the menu button's hit area is at least 44px on touch screens.
- Direction: the navigation is on the right in right-to-left text, and the drawer slides from that side.
- All built-in text is in
labels.
ThemingLink to "Theming"
Tokens: --cy-app-shell-nav-background, --cy-app-shell-header-background and --cy-app-shell-border.
RelatedLink to "Related"
These examples run Cynosure 0.5.0 (commit 50ce30b), the version on npm.