Shell

A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot and cookie persistence.

ui@intentface/chat
DocsOverview

Overview

Collapse the sidebar with the button in its header, then rest the pointer against the left edge to float it back out as a card. Drag the divider to resize it, or nudge it with the arrow keys once the handle has focus.

The width and the range it may be dragged through are this stylesheet's; the primitive only measures and reports back.

Usage guidelines

  • App shell, not a chat part — the sidebar-and-viewport frame an app sits in. The sidebar in the demo holds a Nav; the two are separate primitives built for each other.
  • Width is CSS — the sidebar's size and the range the drag may move it through are width / min-width / max-width in your stylesheet. The primitive measures; it never sizes.
  • No global keys — the package claims none, because it cannot know which combinations your app has already spent. Bind your own around toggle.
  • Hotspot is opt-in — render Shell.Hotspot and a collapsed sidebar floats back out when the pointer rests against the screen edge. Omit the part to opt out; there is no prop, because not rendering it already says so.
  • Persists nothing itself — state goes out through onOpenChange and onResize, and comes back as defaultOpen and a CSS custom property. Where it is kept is yours.
  • The sidebar must leave the flow — see Why the sidebar is positioned. This is the one arrangement everything else depends on.
  • Get started — see Quick start to add the package.

Anatomy

<Shell.Root>
  <Shell.Hotspot />
  <Shell.Sidebar>
    <Shell.Trigger />
    <Shell.Grip />
  </Shell.Sidebar>
  <Shell.Viewport />
</Shell.Root>

A shell whose sidebar starts where the visitor left it. Note the gutter — it is not a part of the package, and it is the piece that makes the rest work:

<Shell.Root defaultOpen={stored?.open ?? true} onOpenChange={save}>
  <Shell.Hotspot />
  <Gutter />

  <Shell.Sidebar onResize={(width) => save({ width })}>
    <Shell.Trigger aria-label="Collapse sidebar" />
    <WorkspaceNav />
    <Shell.Grip aria-label="Resize sidebar" />
  </Shell.Sidebar>

  <Shell.Viewport>{children}</Shell.Viewport>
</Shell.Root>

Examples

Setting the drag range

min-width and max-width on the sidebar are the whole configuration, and there is no prop for either. The range below is deliberately narrow, so both stops are a short drag away.

Drag the divider
measuring…
The sidebar stops at 160px and 260px. The browser clamps it, not the primitive.
[data-shell-sidebar] {
  min-width: 200px;
  max-width: 380px;
}

The grip writes --shell-sidebar-width on the root, the browser clamps it against those bounds, and the sidebar reports back whatever the browser settled on. That number is what gets persisted and announced as aria-valuenow.

Styling the grip

The grip is a bare div with a role and some keys, so the whole appearance is yours. This one draws nothing at rest and fades in an iOS-style pill that rides the pointer vertically, clamped by half its own height at each end so it never hangs out of the track.

The pointer position goes straight into a custom property rather than React state. A pointermove that re-rendered would re-render the whole shell on every frame of a drag, and there is nothing here React needs to know — only a number CSS reads. Passing onPointerMove is safe because the primitive merges handlers rather than replacing them, so the drag on that same event still runs.

Workspace
Overview
Inbox
Projects
Move the pointer onto the sidebar's right edge. The handle appears and follows it.

Styling the hotspot

Resting the pointer on Shell.Hotspot floats a collapsed sidebar out as a card, and every part in the shell carries data-hotspot while it is out. Bake the card geometry into the whole collapsed state rather than into data-hotspot alone, so only left animates as it slides. The demo starts collapsed and tints the hotspot, which is invisible in a real app.

Workspace
Overview
Inbox
Projects

Rest the pointer on the strip at the left edge.

[data-shell-sidebar][data-state="collapsed"] {
  left: calc(-1 * var(--shell-sidebar-width));
  inset-block: 0.5rem;
  border-radius: 0.75rem;
}

[data-shell-sidebar][data-state="collapsed"][data-hotspot] {
  left: 0.5rem;
}

Off-canvas the card is invisible, so nothing moves vertically mid-slide. Only expand and collapse morph card to flat. Hotspot is never persisted, and it means nothing while the sidebar is open.

Driving the shell from outside

Pass a Shell.createStore() handle to the Root and anything holding the same handle can read and drive the state, including a control that is not inside the tree at all. The package binds no global keys, so the shortcut below is the app's own; toggle pins a floated-out sidebar open rather than closing it.

Press Cmd or Ctrl and B with the pointer over the demo. The hover test in the source is this page's problem rather than yours, since a docs page carries many demos and a search field; an app binds the key for its whole window.

Workspace
Overview
Inbox
Projects
Content

Persisting across sessions

The stored value has to arrive as a prop. Reading storage at init is a client-only act, so a server-rendered shell would paint the default layout and snap to the stored one a frame later — the flash this arrangement exists to avoid. A cookie is worth choosing over localStorage for that one reason: it is readable from the request.

// app/layout.tsx — a server component
const stored = readSidebarLayout((await cookies()).toString());

return <AppShell defaultOpen={stored?.open ?? true} width={stored?.width} />;

The width goes back as the custom property, not as a prop, because that is where it already lives:

<Shell.Root
  defaultOpen={defaultOpen}
  onOpenChange={(open) => save({ open })}
  style={stored?.width ? { "--shell-sidebar-width": `${stored.width}px` } : undefined}
>

Validate on the way in. Stored state outlives the code that wrote it, so a value from an older release should fall back to the defaults rather than reach your tree.

Why the sidebar is positioned

Taking the sidebar out of flow is not a styling preference, and the primitive does not work without it.

All three states are one element morphing between three positions: flush while expanded, off-canvas while collapsed, floating slightly inside the edge while the hotspot holds it out. A sidebar left in flow can only animate its own width. It can never float over the content, so the hotspot has nothing to slide across and the state has nowhere to exist.

The gutter is what makes the layout still add up once the panel has left it. Because the gutter reads the same custom property the grip writes, the two stay in agreement at every width without either knowing about the other. Collapsing then animates two cheap properties on two different elements — left on the panel and width on the gutter — rather than fighting one element to do both.

[data-shell-sidebar] {
  position: fixed;
  inset-block: 0;
  left: 0;
  width: var(--shell-sidebar-width, 240px);
  /* Opaque in every state: the content passes beneath the panel while the two
     animate, and a transparent expanded state would show it through. */
  background: var(--chrome);
}

/* The gutter: your own div, reading the property the grip writes. */
[data-slot="shell-gutter"] {
  width: var(--shell-sidebar-width, 240px);
  flex-shrink: 0;
  transition: width 150ms linear;
}

[data-shell][data-state="collapsed"] [data-slot="shell-gutter"] {
  width: 0;
}

Keyboard

Only the grip claims keys, and only while it has focus. Everything else is yours to bind.

KeyDescription
Arrow leftNarrows a left sidebar by `step` pixels, and widens a right one.
Arrow rightWidens a left sidebar by `step` pixels, and narrows a right one.
TabMoves focus to the grip, which is a focusable separator so resizing is never mouse-only.

API reference

Every part accepts className, style, and render (see Styling) and emits a bespoke part attribute (data-<part>) unless noted. className and style may be functions of the part's state.

Shell.Root

The provider and container. Holds the store every other part reads, so defaultOpen and the width must arrive here rather than on a child. Renders a <div> element.

PropTypeDefault
defaultOpenboolean
openboolean
onOpenChange(open: boolean) => void
storeShellStore
AttributeValuesDescription
data-shellThe container.
data-state"expanded" | "collapsed"Whether the sidebar is open.
data-hotspotPresent while the collapsed sidebar is floating out.
data-resizingPresent for the duration of a resize drag.
--shell-sidebar-widthmeasured pxWritten by the grip, on the root rather than the sidebar so anything in the shell can size itself to match.

Shell.Sidebar

The panel, and the element whose width is measured and reported back. Carries the id the trigger's aria-controls points at. Renders a <div> element.

PropTypeDefault
side"left" | "right"
"left"
onResize(width: number) => void
AttributeValuesDescription
data-shell-sidebarThe panel.
data-side"left" | "right"The edge it sits against.
data-state"expanded" | "collapsed"Whether it is open.
data-hotspotPresent while floating out on hover.
data-resizingPresent mid-drag — use it to suppress width transitions.

Shell.Viewport

The content area beside the sidebar. Carries the same state attributes as the root, so it can react to the sidebar without a group selector. Renders a <div> element.

AttributeValuesDescription
data-shell-viewportThe content area.
data-state"expanded" | "collapsed"Whether the sidebar is open.
data-hotspotPresent while the sidebar is floating out.
data-resizingPresent mid-drag.

Shell.Trigger

Toggles the sidebar, and pins a floated-out one open rather than closing it. Ships no copy — supply the label as children. Renders a <button> element.

AttributeValuesDescription
data-shell-triggerThe toggle button.
data-state"expanded" | "collapsed"Whether the sidebar is open.
data-hotspotPresent while the sidebar is floating out.

Shell.Grip

The drag affordance. Give it a width and a cursor in CSS; the drag range comes from the sidebar's own min-width and max-width. Renders a <div> element with role="separator".

PropTypeDefault
stepnumber
16
AttributeValuesDescription
data-shell-gripThe separator.
data-state"expanded" | "collapsed"Whether the sidebar is open.
data-resizingPresent while this grip is being dragged.

Shell.Hotspot

The strip along the screen edge that floats a collapsed sidebar out on hover. Give it a width and a position in CSS. Omitting it is how you opt out of hotspot entirely — there is no prop to turn it off, because not rendering it already says that. Renders a <div> element with aria-hidden.

AttributeValuesDescription
data-shell-hotspotThe hover strip.
data-state"expanded" | "collapsed"Whether the sidebar is open — key the strip off collapsed so it vanishes when expanded.
data-hotspotPresent while the sidebar is floating out.

useShell

Read shell state from anywhere inside <Shell.Root>. Pass a selector so a component re-renders only for the value it reads:

const collapsed = useShell((shell) => !shell.open);
PropTypeDefault
openboolean
hotspotboolean
resizingboolean
widthnumber | null
setOpen(open: boolean) => void
toggle() => void
setHotspot(hotspot: boolean) => void
setResizing(resizing: boolean) => void
setWidth(width: number) => void