Shell
A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot and cookie persistence.
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-widthin 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.Hotspotand 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
onOpenChangeandonResize, and comes back asdefaultOpenand 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.
[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.
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.
[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.
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.
| Key | Description |
|---|---|
| Arrow left | Narrows a left sidebar by `step` pixels, and widens a right one. |
| Arrow right | Widens a left sidebar by `step` pixels, and narrows a right one. |
| Tab | Moves 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.
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | — |
open | boolean | — |
onOpenChange | (open: boolean) => void | — |
store | ShellStore | — |
| Attribute | Values | Details |
|---|---|---|
data-shell | — | |
data-state | "expanded" | "collapsed" | |
data-hotspot | — | |
data-resizing | — | |
--shell-sidebar-width | measured px |
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.
| Prop | Type | Default |
|---|---|---|
side | "left" | "right" | "left" |
onResize | (width: number) => void | — |
| Attribute | Values | Details |
|---|---|---|
data-shell-sidebar | — | |
data-side | "left" | "right" | |
data-state | "expanded" | "collapsed" | |
data-hotspot | — | |
data-resizing | — |
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.
| Attribute | Values | Details |
|---|---|---|
data-shell-viewport | — | |
data-state | "expanded" | "collapsed" | |
data-hotspot | — | |
data-resizing | — |
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.
| Attribute | Values | Details |
|---|---|---|
data-shell-trigger | — | |
data-state | "expanded" | "collapsed" | |
data-hotspot | — |
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".
| Prop | Type | Default |
|---|---|---|
step | number | 16 |
| Attribute | Values | Details |
|---|---|---|
data-shell-grip | — | |
data-state | "expanded" | "collapsed" | |
data-resizing | — |
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.
| Attribute | Values | Details |
|---|---|---|
data-shell-hotspot | — | |
data-state | "expanded" | "collapsed" | |
data-hotspot | — |
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);| Prop | Type | Default |
|---|---|---|
open | boolean | — |
hotspot | boolean | — |
resizing | boolean | — |
width | number | null | — |
setOpen | (open: boolean) => void | — |
toggle | () => void | — |
setHotspot | (hotspot: boolean) => void | — |
setResizing | (resizing: boolean) => void | — |
setWidth | (width: number) => void | — |