Skip to content
Folio

Surfaces

AppShell

Two columns on a desktop, a drawer on a phone.

Usage

TSX
import { AppShell } from '@misoto22/design'

Notes

Two columns on a desktop, one column and a drawer on a phone.

The drawer closes on Escape as well as on the scrim, because a drawer that only closes by tapping outside it strands a keyboard user inside a menu they cannot leave. The scrim is a <button> for the same reason — a <div> with an onClick is not reachable by keyboard and not announced as anything.

Below md the closed drawer is inert. Sliding it off-screen is a visual state and nothing more: without that attribute every link in it stays focusable and stays in the accessibility tree, so Tab from the toggle walks into a menu nobody can see. Closing it also returns focus to the toggle, because the element focus was on is the element that just left.

Anatomy

AppShell anatomy
ElementDescription
FramerequiredThe root: min-h-svh on --paper, one column on a phone and a 15rem sidebar beside a 1fr content column from md up. It is the grid and nothing else — no padding, no measure.
SidebarrequiredAn <aside> named by sidebarLabel, 15rem wide. A static grid column on a desktop; on a phone a fixed drawer that slides in from the edge reading STARTS at, so it comes from the right in a right-to-left document.
Brandbrand, in a 3.5rem row at the top of the sidebar above a hairline — the same height as the topbar, so the two rules meet across the column boundary. Omit it and the nav starts at the top and that line is gone.
NavrequiredA <nav> named by navLabel, “Primary” by default, holding the sidebar prop. It is the part that scrolls, with scroll-slim, so a list that outgrows the column moves under a brand that stays put.
TopbarrequiredA sticky 3.5rem header at --paper/85 with a backdrop blur and a hairline under it, holding the toggle and then topbar. It is rendered whether or not you pass one.
Drawer toggleA 44px button, phone-only, swapping Menu for X and carrying aria-expanded plus aria-controls pointing at the sidebar. Its only name is openLabel or closeLabel.
ScrimA full-screen <button> named by closeLabel, mounted only while the drawer is open and hidden from md up.
Content wellrequiredcontentAs — a <main> by default — centred at --w-page with --page-pad either side and py-8. The measure and the page padding are the shell’s, so a child that adds its own puts a second measure inside the first.

Best practices

Do

  • Pass contentAs="div" for a shell rendered inside another page — a documentation preview, a screenshot harness. A document may hold exactly one main, and the second one leaves assistive tech unable to answer “where is the content”.
  • Name both landmarks when a page could hold two shells: sidebarLabel and navLabel are the only way one complementary is told from another, and the only way a non-English app gets landmark names its readers can read.
  • Translate openLabel and closeLabel with everything else — the toggle holds an icon and no text, so those strings are its entire accessible name on every page of the app.
  • Put the whole sidebar in the sidebar prop and let the nav scroll it: build the column yourself with the brand inside it and a long list carries the brand off the top of the screen with it.

Don’t

  • Do not treat the closed drawer as unmounted: below md it is translated off-screen and marked inert, not removed, so everything inside it still renders and still runs its effects — a nav item that measures itself measures a box nobody can see.
  • Do not assume no topbar means no bar: the header renders regardless, so a shell with nothing to put up there still costs 3.5rem and a rule across the page.
  • Do not wrap the children in your own max-width and page padding — the well already applies both, and the content ends up in the middle of the middle.

Examples

columns and drawer

Eight named parts, and the name gives away none of them. The frame is a grid and nothing else — a 15rem sidebar beside a 1fr column from md up, one column below that. The sidebar is a complementary landmark named by sidebarLabel; on a phone it becomes a drawer that slides in from the edge reading STARTS at. Inside it, brand sits in a 3.5rem row above a hairline — the same height as the topbar, so the two rules meet across the column boundary — and the nav is the part that scrolls. The topbar is sticky and rendered whether or not you pass one, so a shell with nothing to put up there still costs 3.5rem and a rule. The toggle and the scrim are phone-only, and openLabel and closeLabel are the toggle's entire accessible name. Last is the content well, centred at the page measure with the page padding already applied.

production

Content well

a scrolling nav

Fourteen rows in a 15rem column, and the brand still on screen. Put the whole list in the sidebar prop and let the component's own nav scroll it: build the column yourself, with the brand inside the scrolling part, and the first thing a long list does is carry the brand off the top. The nav is the only part that moves, with the slim scrollbar, while the brand row and its hairline stay put. Scroll the list below to see it.

The sidebar scrolls on its own. The brand row above it does not, and neither does the topbar — which is rendered here even though nothing was passed to it.

the content well

The well already centres its children at the page measure and supplies the page padding either side, so children add neither: wrap them in your own max-width and the content ends up in the middle of the middle. What goes in is the page itself, starting at h1 — the shell contributes no heading of its own, and a console whose pages all begin at h2 has an outline with nothing at the top. contentAs is div here only because this preview sits inside the documentation site's own main; in a real application leave it alone and let the well be the main landmark.

all green

Deploys

Twelve releases in the last thirty days, none rolled back. No wrapper around this column — the well is the measure.

api

live
Deployed from main, 2m 14s ago.

web

building
Started 40 seconds ago on codex/ui-library.

Accessibility

  • The drawer closes on Escape as well as on the scrim, so a keyboard user is not stranded inside it.
  • The scrim is a <button>, because a div with an onClick is neither reachable nor announced.
  • Below md the closed drawer carries inert, so its links are out of the tab order and out of the accessibility tree rather than merely off screen. Above md it never is: there the sidebar is the page’s navigation column.
  • Both ways out return focus to the toggle. Focus left inside an inert subtree is focus the browser throws away, and the scrim is worse — it is the focused element and it unmounts.