App Shell
The window layout — a collapsible, resizable sidebar pane next to a main pane.
AppShell is the outer layout: a resizable sidebar pane (drag its right edge) that collapses away (and a button brings it back), next to a main content pane. The sidebar pane clips rather than squishing, so its content never reflows while collapsing.
See it live in the Companies page example — drag the sidebar edge, and use the panel button in the sidebar header to collapse it.
This is a measured layout, not a themed wrapper — its sidebar/children slots are generic, so it's the shell to reach for on any two-pane app layout.
Installation
npm install @crisp-ui-kit/crispUsage
The sidebar prop is a render function that receives a toggleCollapse callback — wire it to your sidebar's collapse control (e.g. Sidebar's onToggleCollapse). Make the sidebar fill its pane with width: 100%.
import { AppShell, Sidebar } from "@crisp-ui-kit/crisp";
export function AppLayout() {
return (
<AppShell
sidebar={(toggleCollapse) => (
<Sidebar
onToggleCollapse={toggleCollapse}
style={{ width: "100%", height: "100%" }}
/>
)}
>
<YourPageHeader />
<YourContent />
</AppShell>
);
}Props
Prop
Type
Collapsed and narrow
When the sidebar is hidden, an expand button (24px, with a tooltip) appears at the top-left of the main pane, and the pane sets --crisp-appshell-header-inset: 32px so a page header can step aside for it. HeaderBar reads that variable itself, so a breadcrumb shifts from x=12 to x=44 exactly as in Attio. For a header of your own, use the same variable in CSS or useAppShell() in JS:
const { hidden, compact, headerInset } = useAppShell(); // headerInset: 0 | 32A hidden sidebar is inert: Tab and screen readers skip it. Below breakpoint (default 865, Attio's measured value) the sidebar is hidden and opens as a floating 267px overlay with no backdrop; Escape or a click outside closes it. responsive={false} keeps it docked at every width. The overlay's open state is drawerOpen / defaultDrawerOpen / onDrawerOpenChange, separate from collapsed, so a phone user's choice never overwrites the desktop preference you persisted.
Double-clicking the resize handle resets the width to defaultWidth; the drag floor defaults to 200 (Attio's).
Notes
- Clip, don't squish. Collapsing animates the pane width to
0while the sidebar keeps its width inside, so it slides away cleanly instead of reflowing to nothing. - Resize disables text selection and forces a
col-resizecursor during the drag, and the width is remembered across a collapse/expand.
Controlled or uncontrolled
Both the sidebar width and the collapsed state can be owned by the app, so they survive a reload or a route change.
const [collapsed, setCollapsed] = useState(false);
const [width, setWidth] = useState(() => Number(localStorage.sidebarWidth) || 275);
<AppShell
collapsed={collapsed}
onCollapsedChange={setCollapsed}
width={width}
onWidthChange={(w) => {
setWidth(w);
localStorage.sidebarWidth = String(w);
}}
sidebar={(toggle) => <Sidebar onToggleCollapse={toggle} />}
>
{children}
</AppShell>Omit either pair and AppShell manages that one itself, seeded by defaultCollapsed / defaultWidth.
onWidthChange fires once, when the drag ends, with the final clamped width — not on every pointer move — so persisting it doesn't mean writing on every frame. That matches ResizablePanel's onSizeChange. A drag still tracks the pointer live in both modes; a click on the handle that never moves reports nothing.