App shell / Sidebar
Frames content with collapsible navigation, brand and theme on top, account below.
Preview
Local preview. It doesn't affect real accounts, environments, or run data.
Usage
- Use the workspace layout to pair main content with a sidebar that holds a brand, a collapse toggle, and an account at the bottom. For documentation sites that need a global top rail, use the default header layout.
- AppShell provides the layout and native link styles; the caller owns routes and account. A ThemeSwitch in the sidebar is a row like the links, its name a tooltip on the rail. The shell keeps the collapsed state itself: SidebarToggle collapses the sidebar, ⌘B (Ctrl+B elsewhere) does too outside text fields, persist remembers the state in localStorage, and on the rail each Link shows its name as a tooltip. Pass collapsed and onCollapsedChange to control the state, or read a cookie into defaultCollapsed so a server-rendered first paint is right.
- The workspace sidebar collapses from 224px to a 72px rail, and icons keep their positions. On the rail each link is a square of the icon column, with the sidebar's padding on either side. Brand is its top row: it centers the product mark, about 24px, on the links' icon column, and SidebarToggle ends the row, as tall as a link and as far from the edge. On the rail the brand hides and the toggle takes its place. In the header it sits on one line.
- Brand sets the product name in the subsection role (Manrope 500, 16/24, --surf-ink, default tracking), starting on the link label line. In the sidebar a name with no description takes up to two lines of the brand row before it is cut short; a longer one, or a name in the header, truncates with an ellipsis and keeps its full text in title. Its description is an optional second line in the caption role (12/18, --surf-ink-muted), such as the workspace.
- Bar holds a page's own tabs, such as a project's pages as NavigationMenu links, at the top of the page. Make it the Page's first child. It is as tall as the brand row less the shell's inset above and below (B − 2I, 56px), its controls take the small size (32px), and it bleeds a link's padding and edge (X, 13px) past the page's text: each tab's label lines up with the text, its middle stands on the brand row's line, and the tab stands as far from the page's top and start as the page stands from the shell (G = P − X, about 12px). Other controls line up within a few pixels, by their own padding. No line parts it from the page: the space under it does, and the current tab's plane marks the page. In compact density and on a phone the page's padding leaves no room to bleed, so the tabs start at the text; on a phone the bar sits under the mobile header, as tall as its tabs.
- In compact density (SizeProvider density="compact" around the shell) the icon column is 32px, so the sidebar's rows, its toggle and each link on the rail are 32px, and the sidebar keeps the 12px the page keeps beside its rows: the rail is 56px. The brand row keeps its 80px, so its line stays where a compact page's first line is. On touch a row grows to the target size and never shrinks below its own, and so does the icon column, so a link on the rail stays square.
- Nav stacks Links with even gaps and, in the workspace sidebar, insets them so their icons sit on the rail. Its label shows over the links, keeps its row on the rail, and names the navigation; without one, give Nav an aria-label.
- Link takes render. Pass a router's link to keep client navigation, such as render={<NextLink href='/resources' />}. The account row is a Link rendered as the menu's trigger, render={<Menu.Trigger />}, with a 24px Avatar as its icon, so it lines up with the links.
- The brand and account stay fixed while the menu and main content scroll in separate ScrollAreas. The content surface spans the full scrolling content, so its bottom rounded corners appear only at the end. The content's ScrollArea is the page's own scroller: set fade="none" so the page's top and bottom stay sharp, while the menu's edges fade to show more links.
- Main content can also scroll with the document. In a full-viewport shell with no page behind it, set scrollChain="contain" so an edge scroll doesn't bounce the page.
- The shell fills the window, and its edge is square: it is the window's edge. Only the page surface is rounded, by --surf-app-shell-page-radius (8px by default). Give the document no margin and the chrome background, as the install guide shows, so nothing shows around it.
- On a phone (≤760px) the frame gives way: the page meets the screen's edges below the mobile header, with no inset and no rounding.
- The page pads its content by --surf-app-shell-page-padding: 24px, 12px in compact density and 16px on a phone. Set it on Root to give a denser or roomier product its own room; Bar takes the new value, so its tabs still line up with the page's text.
- To show a shell inside a page, as this example does, let the frame round and clip it. Here the frame clips at 22px and sets --surf-app-shell-page-radius to --surf-radius-md (10px), so the frame, the clip and the page stay concentric: 26, 22 and 10px.
- The example sits in a fixed-height frame on the page, so its ScrollAreas keep the default scrollChain="auto" and hand the wheel to the page at their edges. Turn on Overflow navigation and Long workspace content to see the separate scrolling.
- Product selection and preview options sit outside the example boundary, the sidebar's ThemeSwitch row changes the preview's theme, and the account menu switches the page's. The example's icon collapse is a product choice, and a real documentation site's sidebar is text-first.
- Don't force an icon sidebar on every product. A documentation sidebar can stay text-first.
- Don't give links a menu role. Mark the current page with aria-current, not aria-expanded.
- Don't hide the desktop sidebar on mobile without other navigation. Provide a replacement, such as a bottom Sheet or BottomNavigation.
- Don't expect the layout module to handle permissions or sign-in. Handle them in the application.
- Don't fade the sidebar edge with a blur that blurs text.
- Don't line a page's tabs up with the brand row by padding the page. Put them in Bar, first in the Page.
- Don't give two shells on one page the shortcut. Set shortcut={false} on any shell that only illustrates, such as a mockup.
Anatomy
AppShell.Root(layout=workspace) → mobile Header(Brand, MenuTrigger) + Sidebar(Brand, SidebarToggle ending the brand row, ScrollArea(Nav(Links)), Footer(Link as the account menu trigger)) + Content(ScrollArea → Page(Bar?, content)); mobile: bottom Sheet(Header(Title, Description), Body(Nav(Links)), Footer(account, Close menu))
Keyboard and accessibility
- Tab and Shift+Tab reach native links and buttons. Enter follows a link.
- The mobile bottom Sheet opens on the current destination. Choosing a destination closes the Sheet and moves focus to the new content heading.
- Base UI handles the Sheet's open button, Escape, focus trapping, and focus restoration on close. In a real product, the router owns navigation, back navigation, and scroll restoration.
- SidebarToggle sets aria-expanded and aria-controls, and its tooltip names the shortcut. ⌘B or Ctrl+B toggles the sidebar unless focus is in a text field. Collapsed links keep their accessible names and show them as tooltips on hover and focus.
Give each control a name that describes its purpose. Connect error messages to their controls, and signal state with more than color. Automated checks don't replace manual keyboard and assistive technology testing.
Example code
Shared components take their styles from design tokens. Each example sets its own composition and accessibility attributes. The calling screen defines callbacks, refs, and product data.
Key API
The table lists the common wrapper's main props. See the package type declarations for the full types.