Patterns · Layout
AppShell
The signed-in Rook layout: rail, optional identity bar and main, with a skip link and contained scrolling.
Rail, identity bar and a scrolling main region
import { AppShell, IdentityBar, RookRail } from '@rookcom/monarch-react/shell';
import { PageHeader } from '@rookcom/monarch-react';
// AppMark, CalendarIcon, HomeIcon, InboxIcon: demo icons from the docs site; use your own.
function Demo() {
return (
<div
style={{
width: '100%',
height: '26rem',
overflow: 'hidden',
border: '1px solid var(--mn-color-border-default)',
borderRadius: 'var(--mn-radius-lg)',
position: 'relative',
}}
>
<AppShell
style={{ height: '100%', minHeight: 0 }}
mainId="app-shell-demo-main"
mainRole="region"
mainProps={{ 'aria-label': 'Example app content' }}
skipLinkLabel="Skip to example content"
rail={
<RookRail
style={{ height: '100%' }}
persist={false}
messages={{ nav: 'Main (app shell example)' }}
brand={{ label: 'Acme Desk home', href: '#home', icon: <AppMark /> }}
currentPath="/inbox"
sections={[
{
key: 'main',
items: [
{ key: 'home', label: 'Home', icon: <HomeIcon />, href: '/home' },
{ key: 'inbox', label: 'Inbox', icon: <InboxIcon />, href: '/inbox', badge: 12 },
{ key: 'calendar', label: 'Calendar', icon: <CalendarIcon />, href: '/calendar' },
],
},
]}
account={{ name: 'Ada Lovelace', onSignOut: () => {} }}
/>
}
header={<IdentityBar product={{ name: 'Acme Desk' }} />}
>
<div style={{ padding: 'var(--mn-space-6)' }}>
<PageHeader title="Inbox" description="12 open conversations" />
{Array.from({ length: 12 }, (_, i) => (
<p key={i}>
<a href={`#conversation-${i + 1}`}>Conversation {i + 1}</a>
</p>
))}
</div>
</AppShell>
</div>
);
}
<Demo />Usage examples
The example at the top of the page is the only one for this component.
Purpose
Lays out every signed-in Rook app the same way and gets the page-level accessibility right once: the skip link, the landmark order and a main region that scrolls on its own. Needed by: app, calendar, friends, stride, whose layouts each hand-offset content by the rail width and had no skip link.
Use when
- The root layout of a signed-in app (
app/(workspace)/layout.tsx).
Avoid when
- Public and marketing pages.
- A two-pane area inside a page: use SplitLayout.
Design principles
- No magic offsets: the rail is a grid column, so content never needs
padding-left: var(--rail-width). - The first Tab stop skips the navigation.
- One scrolling region by default, so the rail and header never scroll away and the page never double-scrolls.
- Server-safe: no state, no effects, no event handlers.
API reference
import { AppShell } from '@rookcom/monarch-react/shell';| Export | Kind | Rendering | Root class |
|---|---|---|---|
AppShell | component | server-safe | .mn-app-shell |
Forwards its ref to the root <div>. children render inside <main>.
Generated from the TypeScript declarations of packages/react/src/patterns/layout/app-shell.
Properties
mainProps passes attributes (an aria-label, a class) to <main>.
AppShell
The layout of a signed-in Rook app: the rail on the left (a top bar and drawer on phones), an optional identity bar on top, and the main region. It adds the skip link (the first Tab stop; `<main>` takes focus because it has `tabindex="-1"`), keeps the landmarks in order (navigation, banner, main) and contains scrolling to the main region. Server-safe: no state.
| Prop | Type | Description |
|---|---|---|
children | ReactNode | The page. Rendered inside `<main>`. |
className | string | |
header | ReactNode | The top bar, usually `<IdentityBar />`. |
id | string | |
mainId | string | Id of `<main>`, the skip link's target. Default `main`. |
mainProps | Omit<HTMLAttributes<HTMLElement>, "id" | "children"> | Extra props for `<main>` (an `aria-label`, a class). |
mainRole | "main" | "region" | `main` (default), or `region` for a shell embedded in another page that already has its `<main>` (docs, previews, onboarding tours): the content becomes a `<div role="region">`, named by `mainProps['aria-label']`. |
rail | ReactNode | The primary navigation, usually `<RookRail />`. |
scroll | "main" | "page" | `main` (default): the shell fills the viewport and only the main region scrolls, so the rail and header stay put. `page`: the document scrolls and the rail and header are sticky (for long, printable pages). |
skipLinkLabel | string | Text of the skip link. Default "Skip to content". |
Variants
| Prop | Values | Description |
|---|---|---|
mainRole | 'main''region' | region renders the content as a named <div role="region">, for a shell embedded in a page that already has <main>. |
scroll | 'main''page' | main (default): the shell fills the viewport and <main> scrolls. page: the document scrolls; the rail and identity bar stay sticky. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Scroll mode | data-scroll="main|page" | On the root. |
| With rail / header | data-rail, data-header | Set when the slot is used. |
Interaction behaviour
- The skip link appears when focused and moves focus to
<main>(it hastabindex="-1", so it is not a Tab stop itself). - With
scroll="main", scrolling stops at the main region’s edges (overscroll-behavior: contain). - Printing hides the rail, header and skip link and lets the content flow.
Keyboard support
| Keys | Action |
|---|---|
| Tab (first) | Shows the "Skip to content" link. |
| Enter | On the skip link: focus moves to the main region. |
Accessibility
- Role
- Landmarks in order: the rail (complementary + navigation), banner (identity bar), main
- Accessible name
<main>needs no name; add one withmainProps["aria-label"]if the page has several regions.
- Adds the skip link every audited app was missing (WCAG 2.4.1 Bypass Blocks).
- The main region shows a focus outline only when reached from the keyboard.
- With
scroll="main", keyboard users scroll main by moving through its focusable content (or after the skip link focuses it); a page with no focusable content at all should usescroll="page".
Responsive behaviour
- 768px and up: rail column on the left, identity bar and main to its right.
- Below 768px: one column; the rail becomes its top bar (with the drawer), then the identity bar, then main.
- Uses
100dvh, so mobile browser toolbars do not hide content.
Theme support
- Canvas background and text tokens; the rail and bar bring their own surfaces.
Do and don't
Do
- Use it once, at the root of the signed-in layout.
- Start each page with a heading inside main (PageHeader).
- Use
scroll="page"for long reading or printable pages.
Don't
- Do not add your own rail offset or
overflowonbody. - Do not render a second
<main>inside it.
Related components
- RookRail: The rail slot.
- IdentityBar: The header slot.
- PageHeader: The first thing inside main.
- SplitLayout: Two panes inside a page.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | None |
Testing coverage
- Landmarks in order: navigation, banner, main; default scroll mode.
- Skip link is the first Tab stop and targets main, which is not a Tab stop.
- Custom main id, skip text and main props; page scrolling; no rail or header; embedded as a named region.
- Server render.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/app-shell; Cross-browser axe: /components/app-shell |
Tests: packages/react/src/patterns/layout/app-shell