Components

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.

Available since@crisp-ui-kit/crisp@0.11.0

Installation

npm install @crisp-ui-kit/crisp

Usage

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 | 32

A 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 0 while the sidebar keeps its width inside, so it slides away cleanly instead of reflowing to nothing.
  • Resize disables text selection and forces a col-resize cursor 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.

On this page