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

ExportPurpose
App.RootRegisters panels and manages their visibility, sizes, layout changes, and an optional viewport-bound footer.
App.PanelRenders area="nav", "body", or "sidebar". An omitted nav or sidebar gets no track or trigger.
App.PanelHeader48px header that matches its panel’s geometry.
App.PanelContentCaps body content at var(--app-content-max-width) plus gutters: 1rem on mobile, 3rem on desktop.
App.PanelContentBreakoutStretches a block inside App.PanelContent past the cap to the panel’s edges, still inset by the gutters.
App.TriggerPanel toggle with an optional tooltip and kbd hint.
App.DrawerNested sidebar layers. Root controls a layer and Content renders it in the sidebar stack.
App.ShortcutsRegisters the standard bracket bindings under your ShortcutProvider.
useAppReads activePanel, open, available, and isDesktop, and exposes setPanel, dismissPanel, and toggle.
useAppPanelDismissibleWhether 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.

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 below desktop. 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. onClose dismisses 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:

VariableDefaultPurpose
--app-drawer-peek0.5remHow far each predecessor peeks out from the layer above.
--app-drawer-scale-step0.0328 (0.0133 on desktop)Scale lost per depth; fixed, never size-derived.
--app-drawer-durationvar(--duration-soft)Enter, push and stack transition duration.
--app-drawer-easevar(--ease-soft)Enter and push curve: the soft spring (99% in 250ms).
--app-drawer-exit-easevar(--ease-soft-out)Exit and un-recede curve: the softOut spring (99% in 300ms).
--app-drawer-backdrop-easevar(--app-drawer-fade-ease)Dim curve entering; it leaves on the exit fade curve.
--app-drawer-fade-easelinear()Fade, veil and tint entering: the soft spring, bounce held.
--app-drawer-exit-fade-easelinear()The same leaving: the softOut spring, held at its target.
--app-drawer-mobile-inset-x0.75remSide inset of a floating mobile sheet.
--app-drawer-mobile-inset-top6.75remGap between the top of a mobile sheet and the viewport.
--app-drawer-mobile-inset-bottommax(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 with defaultLayout or Pipeline’s defaults, and applies it without animating, remounting, or calling onLayoutChange.
<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:

PanelDefault preferenceMinimum widthMaximum preference
Navigation264px204px324px
Body760px400px760px
Sidebar480px480px1024px

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:

CandidateWhat checked outWhy it is not verifiedSource
Northwind LogisticsHeadcount and funding match the brief.No public evidence of a revenue operations team.Company site, funding announcement
Brightline HealthUses the same CRM and recently hired a sales lead.The hiring post may be for a different region.Job board, press release
Copperleaf StudioExpansion into two new markets this year.Budget owner is not named anywhere public.Interview, LinkedIn

Broken out, it uses the panel’s width:

CandidateWhat checked outWhy it is not verifiedSource
Northwind LogisticsHeadcount and funding match the brief.No public evidence of a revenue operations team.Company site, funding announcement
Brightline HealthUses the same CRM and recently hired a sales lead.The hiring post may be for a different region.Job board, press release
Copperleaf StudioExpansion 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:

  1. Pass an initialLayoutKey to the outer App.Root.
  2. 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.
  3. 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.