App
import { App, useApp } from "@o/pipeline/experimental/layouts/app";
App is a React client component that lays out a viewport-bound nav, body, and optional sidebar. It handles layout interaction and accessibility. You own routing, shortcuts, and persistence.
Parts and hook
| Export | Purpose |
|---|---|
App.Root | Registers panels and manages their visibility, sizes, layout changes, and an optional viewport-bound footer. |
App.Panel | Renders area="nav", "body", or "sidebar". An omitted nav or sidebar gets no track or trigger. |
App.PanelHeader | 48px header that matches its panel’s geometry. |
App.PanelContent | Caps body content at var(--app-content-max-width) plus gutters: 1rem on mobile, 3rem on desktop. |
App.PanelContentBreakout | Stretches a block inside App.PanelContent past the cap to the panel’s edges, still inset by the gutters. |
App.Trigger | Panel toggle with an optional tooltip and kbd hint. |
App.Drawer | Nested sidebar layers. Root controls a layer and Content renders it in the sidebar stack. |
App.Shortcuts | Registers the standard bracket bindings under your ShortcutProvider. |
useApp | Reads activePanel, open, available, and isDesktop, and exposes setPanel, dismissPanel, and toggle. |
useAppPanelDismissible | Whether a header in the current panel shows a dismiss control. False for the sidebar’s first layer on desktop. |
The package also exports AppPanel, AppLayout, AppLayoutInput, AppPriority, AppRootProps, AppPanelProps, AppPanelContentProps, AppPanelContentBreakoutProps, and AppTriggerProps.
App.Panel doesn’t scroll; its children do. To give a sidebar viewport scroll-driven edge borders, add scroll-border-y with border-y.
For a compact desktop sidebar, use <App.Panel area="sidebar" height="content"> and cap the height of its scrollable content. The card hugs its content up to that cap. The default is height="full", which mobile sheets and drawers always use.
Sidebar drawers
App.Drawer works only inside App.Panel area="sidebar":
<App.Drawer.Root open={detailOpen} onClose={() => setDetailOpen(false)}>
<App.Drawer.Content label="Detail panel">
<DetailPanel />
</App.Drawer.Content>
</App.Drawer.Root>
On desktop, drawers stack as non-modal cards over the sidebar, so the rest of the page stays usable. On mobile they render as bottom sheets. A layer opened over a visible sidebar leaves a strip of its parent showing, while a standalone layer fills the sheet space.
An open mobile panel covers the App.Root footer until it finishes closing. Drawers stay above the software keyboard without resizing the sheet. Base UI handles swipe to dismiss, so add data-base-ui-swipe-ignore to controls that conflict with it, such as sortable items or resize handles.
These props change how a layer opens and closes:
revealedOverParent={false}: for a mobile layer opened straight from the body rather than over a visible sidebar. Closing it returns to the body without revealing a base sidebar layer. It has no effect on desktop.mobilePresentation: how a direct layer renders belowdesktop. The default"sheet"floats its own sheet over the panel."inline"renders it inside the sidebar card in place of the base content, with no backdrop or entrance animation, and the sheet’s swipe, Escape, and dismiss control close it. Layers revealed over a visible parent and layers opened on desktop ignore it.onCloseToBase: clears the whole child stack in one transition, for consumers with route-backed layers.onClosedismisses only the active drawer.
Use "inline" when the layer is what the person opened, not something stacked on top of it. Otherwise two sheets at the same inset look like one sheet with a doubled shadow.
The presentation is fixed when a layer opens and holds until it closes. Switching would remount the portal and lose state such as a composer draft or a streaming turn, so a layer opened below desktop stays inline through a resize. An inline layer reports its close only after the surrounding sheet has left, so the base content doesn’t flash back.
Stack geometry and motion
Layers stack like cards, with up to three visible. Each step back from the front card:
- Darkens the whole card by 1%: the front card is 0%, then 1% and 2%. A fourth card reaches 3% and fades out.
- Veils the card’s content towards its fill by 40%, 65%, then 80%, and makes it
inert. The card’s fill, outline, and shadow stay solid. - Scales the card down uniformly, by 1.33% on desktop and 3.28% on mobile.
On desktop, the bottom card sits 4px from the resize handle and each card above it steps 8px inward, so a three-card stack shows two 8px rear edges. Deeper stacks reuse those three positions, and covered cards slide left as new ones arrive. On mobile, cards share a 12px side inset and rear cards step up 8px. Cards have a 16px radius, and the mobile backdrop is black at 20%.
Position and scale spring. Opacity, the content veil, the tint, and the backdrop follow the same timing without overshooting. One depth value drives every property, so a reversed or cancelled swipe retargets mid-flight. Cards enter and leave with their content fully veiled.
Tune the stack with these CSS variables. They’re declared on :root in app.theme.css, so sheets portalled outside the App subtree still resolve them:
| Variable | Default | Purpose |
|---|---|---|
--app-drawer-peek | 0.5rem | How far each predecessor peeks out from the layer above. |
--app-drawer-scale-step | 0.0328 (0.0133 on desktop) | Scale lost per depth; fixed, never size-derived. |
--app-drawer-duration | var(--duration-soft) | Enter, push and stack transition duration. |
--app-drawer-ease | var(--ease-soft) | Enter and push curve: the soft spring (99% in 250ms). |
--app-drawer-exit-ease | var(--ease-soft-out) | Exit and un-recede curve: the softOut spring (99% in 300ms). |
--app-drawer-backdrop-ease | var(--app-drawer-fade-ease) | Dim curve entering; it leaves on the exit fade curve. |
--app-drawer-fade-ease | linear() | Fade, veil and tint entering: the soft spring, bounce held. |
--app-drawer-exit-fade-ease | linear() | The same leaving: the softOut spring, held at its target. |
--app-drawer-mobile-inset-x | 0.75rem | Side inset of a floating mobile sheet. |
--app-drawer-mobile-inset-top | 6.75rem | Gap between the top of a mobile sheet and the viewport. |
--app-drawer-mobile-inset-bottom | max(0.75rem, env(safe-area-inset-bottom)) | Gap below a mobile sheet. |
Exit duration is calc(var(--drawer-swipe-strength) * var(--app-drawer-exit-base-duration)), based on var(--duration-soft-out), so a flicked sheet closes faster than a tapped one. prefers-reduced-motion removes the transitions but keeps the resting geometry, so exits still complete.
Root
<App.Root
defaultLayout={savedLayout}
onLayoutChange={saveLayout}
panelShortcuts={{ nav: "[", sidebar: "]" }}
priority="body"
>
<App.Panel area="nav">
<App.PanelHeader>Workspace</App.PanelHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
…
</div>
</App.Panel>
<App.Panel area="body">
<App.PanelHeader>Deals</App.PanelHeader>
…
</App.Panel>
<App.Panel area="sidebar">
<App.PanelHeader>Deal details</App.PanelHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
…
</div>
</App.Panel>
</App.Root>
The layout props work like this:
defaultLayout: initializes the root once. Later changes don’t reset its panel preferences or open state.onLayoutChange: runs only after a user action, such as a close, resize, or resize reset. Automatic fitting, priority changes, and restoration don’t call it.deferredDefaultLayout: reads browser-only storage with server-side rendering (SSR). App calls it once after hydration, merges the result withdefaultLayoutor Pipeline’s defaults, and applies it without animating, remounting, or callingonLayoutChange.
<App.Root
defaultLayout={staticFallback}
deferredDefaultLayout={readCookieLayout}
onLayoutChange={writeCookieLayout}
>
…
</App.Root>
App reads which panels exist from its direct App.Panel children on the first render, including panels inside fragments, then tracks them as they mount and unmount.
priority="body" keeps the body’s preferred width first when space runs out, and priority="sidebar" favors the sidebar. You can change it on a mounted root without resetting it:
const [priority, setPriority] = React.useState<AppPriority>("body");
<button onClick={() => setPriority("sidebar")}>Prioritize sidebar</button>;
<App.Root priority={priority}>…</App.Root>;
The sandbox deals route changes priority between routes, and the inbox route has no sidebar.
Sizing and visibility
Pane sizes are fixed CSS pixel values, not props:
| Panel | Default preference | Minimum width | Maximum preference |
|---|---|---|---|
| Navigation | 264px | 204px | 324px |
| Body | 760px | 400px | 760px |
| Sidebar | 480px | 480px | 1024px |
The body grows past its maximum preference when the viewport has room.
Content caps at 41.5rem with 1rem gutters on mobile and 3rem on desktop, so at a 16px root font size the desktop column is 664 + 48 + 48 = 760px. From the 3xl breakpoint (120rem) the cap widens to 47.5rem, and pane fitting stays the same. Content and gutters scale with the root font size, while pane sizes stay in pixels.
useApp reports panel state in three values:
activePanel: the selected mobile panel. Selection doesn’t mean the panel is visible.open: whether each panel is actually visible, including after desktop auto-fit.available: whether a nav or sidebar panel is registered.
On desktop, a pane auto-hides when the minimum widths don’t fit. Reopening one makes it win the fit without changing the other pane’s saved preference. On mobile, one panel shows at a time.
Base UI portals don’t render on the server, so nav and sidebar first render as a static desktop fallback, then move into their portals once after hydration. From then on, pane content stays mounted through opening, hiding, nesting, resizing, and breakpoint changes.
Panels are named containers (app-nav, app-body, and app-sidebar) for container queries. App.PanelContent caps the content rather than the panel, and the body header spans the full panel width, inset by the gutters. Sizes live in sizing.ts, and app.theme.css holds the grid formulas.
Content breakout
Prose reads best at the content cap, but tables and charts need more room. Wrap them in App.PanelContentBreakout to stretch them to the edges of the nearest size container, still inset by the gutters. In the body panel that’s the panel itself, or a closer @container such as a scroll viewport.
<App.PanelContent>
<p>Prose stays at reading width.</p>
<App.PanelContentBreakout>
<Table.Root>…</Table.Root>
</App.PanelContentBreakout>
</App.PanelContent>
This page is itself an App body. In the column, the table wraps every cell:
| Candidate | What checked out | Why it is not verified | Source |
|---|---|---|---|
| Northwind Logistics | Headcount and funding match the brief. | No public evidence of a revenue operations team. | Company site, funding announcement |
| Brightline Health | Uses the same CRM and recently hired a sales lead. | The hiring post may be for a different region. | Job board, press release |
| Copperleaf Studio | Expansion into two new markets this year. | Budget owner is not named anywhere public. | Interview, LinkedIn |
Broken out, it uses the panel’s width:
| Candidate | What checked out | Why it is not verified | Source |
|---|---|---|---|
| Northwind Logistics | Headcount and funding match the brief. | No public evidence of a revenue operations team. | Company site, funding announcement |
| Brightline Health | Uses the same CRM and recently hired a sales lead. | The hiring post may be for a different region. | Job board, press release |
| Copperleaf Studio | Expansion into two new markets this year. | Budget owner is not named anywhere public. | Interview, LinkedIn |
The breakout’s negative margins assume its parent is centered on the panel. Keep it a direct child of App.PanelContent, or inside a descendant that spans its full width, and in block flow. When the panel is no wider than the column, the margins are zero. Outside the body panel it’s a plain wrapper.
Persistence
AppLayout holds user preferences only:
type AppLayout = {
nav: number;
sidebar: number;
body: number;
open: { nav: boolean; sidebar: boolean };
};
Store it in your app. Validate stored JSON before passing it to defaultLayout, and add your own version field when the format changes.
With SSR, render the same defaultLayout on the server and the first client render. Use deferredDefaultLayout for browser-only storage rather than a key or remount, which would lose nested client state and DOM listeners.
To show a saved layout before hydration, set CSS variables before the first paint:
- Pass an
initialLayoutKeyto the outerApp.Root. - In a synchronous script in the document head, read and validate the saved layout. Apply the same defaults, size limits, and responsive fit rules as deferred restoration.
- Write the five CSS variables named by
getInitialLayoutCssVariables(key).
Once restoration and fitting finish, App.Root stops reading those variables, and you don’t need to remove them. App.Panel resets them so nested App layouts don’t inherit them.
Controls, shortcuts, and dragging
App.Root never registers global keys, and its controls show key hints only when panelShortcuts provides them. For the standard bracket bindings, render one App.Shortcuts under your ShortcutProvider and pass APP_PANEL_SHORTCUTS for the hints. Nested App roots don’t register bindings, and the outer bindings ignore focus inside them.
import { ShortcutProvider } from "@o/shortcuts";
import { App, APP_PANEL_SHORTCUTS } from "@o/pipeline/experimental/layouts/app";
<ShortcutProvider>
<App.Root panelShortcuts={APP_PANEL_SHORTCUTS}>
<App.Shortcuts />…
</App.Root>
</ShortcutProvider>;
<App.Trigger
area="sidebar"
tooltip="Toggle sidebar"
kbd="S"
render={<Button aria-label="Toggle sidebar">Details</Button>}
/>;
App.Trigger can show its own kbd hint, but it never registers a binding.
Entering a panel focuses its dismiss control first, then its other controls, then the panel itself. The desktop nav close stays keyboard reachable but only appears when it has visible focus, from Tab or the [ shortcut. Header controls at either end use bleed="start" and bleed="end" to align their hit areas with the panel edge, except the nav dismiss controls.
The sidebar’s first layer has no dismiss control on desktop: the nav and the ] shortcut collapse it, and each drawer stacked over it keeps its own close. Custom headers read useAppPanelDismissible() to match, and mark their dismiss controls with data-app-dismiss-desktop="" or data-app-dismiss-mobile="". Focus skips controls in inert layers. To clear transient content after a single-panel slide, use onPanelTransitionComplete on App.Root. It doesn’t fire for desktop collapses.
The nav sits on the background fill and the body on the canvas, so the body reads as a sheet raised over the nav. The app-edge utility draws the edge between them: a half-pixel ring and two soft shadows on the nav’s end edge, plus a one-pixel highlight on the body. Below desktop the nav sheet sits over the body and uses app-edge-raised, which casts the shadows outward onto the body instead. Both flip under RTL.
On desktop, drag a separator anywhere along its divider, resize with the arrow keys from its focused pill, double-click to reset, or drag it closed. Pointer focus keeps arrow-key resizing without a focus ring, and dragging is off below desktop. Header regions expose data-desktop-drag for app shells with native window dragging, so keep interactive controls and portalled overlays out of them. Test window dragging on macOS in the real shell, including traffic-light clearance, a collapsed nav, narrow windows, menus, and dialogs.