Shell
A collapsible, resizable sidebar beside the viewport it shares the screen with, with hover-peek and cookie persistence.
The sidebar above holds a Nav; the two are separate primitives that happen to be built for each other.
Usage guidelines
- App shell, not a chat part — the sidebar-and-viewport frame an app sits in.
- No global keys — the package claims none; bind your own around
toggle. Only the resize handle's arrow keys are local enough to belong here. - 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. - Hover-peek — a collapsed sidebar floats back out when the pointer rests against the screen edge. Render
Shell.PeekZoneto opt in; omit it to opt out. - Persists nothing itself —
opengoes out throughonOpenChangeand back asdefaultOpen; the width goes out through the sidebar'sonResizeand back as CSS. Where it is kept is yours, and reading it from the request keeps the first paint right. - State via
useShell— read open, peek, resizing and the measured width from anywhere inside. - Get started — see Quick start to add the package.
Anatomy
<Shell.Root>
<Shell.PeekZone />
<Shell.Sidebar>
<Shell.Trigger />
<Shell.ResizeHandle />
</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.PeekZone />
<Gutter />
<Shell.Sidebar>
<Shell.Trigger aria-label="Collapse sidebar" />
<WorkspaceNav />
<Shell.ResizeHandle aria-label="Resize sidebar" />
</Shell.Sidebar>
<Shell.Viewport>{children}</Shell.Viewport>
</Shell.Root>Layout: take the sidebar out of flow
This is the arrangement everything else depends on, and the one that is easy to get wrong. The sidebar is positioned, not a flex item, and a plain spacer — a gutter — holds its place in the layout:
[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 handle 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;
}That split is what lets all three states be one element morphing between three positions — flush while expanded, off-canvas while collapsed, floating just inside the edge while peeking. A sidebar left in flow can only animate its own width, so it can never float over the content, and the peek has nothing to slide across.
It also means the collapse animates two cheap properties on two different
elements — left on the panel, width on the gutter — rather than fighting
one element to do both.
Sizing
The drag range is min-width and max-width on the sidebar — that pair is
the whole configuration, and there is no prop for it:
[data-shell-sidebar] {
min-width: 200px;
max-width: 380px;
}The handle writes --shell-sidebar-width on the root, the browser clamps it
against those bounds, and the sidebar reports back whatever the browser settled
on — which is the number that gets persisted and announced as aria-valuenow.
Measure in JS, size in CSS; nothing here second-guesses your stylesheet.
Publishing the variable on the root rather than the sidebar is what lets the gutter read the same number, and anything else in the shell size itself to match.
Hover-peek
With the sidebar collapsed, resting the pointer in the edge strip floats it out as a card; leaving both the card and the strip slides it away after a grace period. Both delays are intent filters — a pointer merely crossing the strip never opens it, and clipping a corner on the way to the panel never loses it. Collapse the demo above and hover its left edge to see it.
The card geometry is worth baking into the whole collapsed state rather than
into data-peek alone:
[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-peek] {
left: 0.5rem;
}Off-canvas the card is invisible anyway, so the peek animates left only — no
vertical movement, and the inset never grows mid-slide. Only expand and
collapse morph card ↔ flat.
Peek is never persisted, and it only means anything while collapsed. Clicking the trigger inside a peeked sidebar pins it open rather than closing it.
Keyboard
There is no global key binding in the package, and that is deliberate: it
cannot know which combinations your app has already spent, and a window-level
keydown claimed by a library is a collision waiting to happen. toggle is all
a binding needs — it pins a peeking sidebar open rather than closing it:
const SidebarShortcut = () => {
const toggle = useShell((shell) => shell.toggle);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "b" || !(event.metaKey || event.ctrlKey)) return;
event.preventDefault();
toggle();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggle]);
return null;
};Shell.ResizeHandle does own its keys, because those are local rather than
global: with the handle focused, the arrow keys nudge the width by step.
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 | — |
peek | boolean | — |
resizing | boolean | — |
width | number | null | — |
setOpen | (open: boolean) => void | — |
toggle | () => void | — |
setPeek | (peek: boolean) => void | — |
setResizing | (resizing: boolean) => void | — |
setWidth | (width: number) => void | — |
To drive the same state from outside the tree — a command palette, a global shortcut handler — create the store yourself and pass it in:
const store = Shell.createStore();
<Shell.Root store={store}>{/* … */}</Shell.Root>;
// Anywhere, including outside the tree:
const open = useShellStore(store, (shell) => shell.open);There is deliberately no global fallback, so shell state is never read or driven by accident from somewhere that merely imported this.
Persistence
Shell persists nothing and knows nothing about where you keep things. open
goes out through onOpenChange and comes back in as defaultOpen.
Format, key, cookie flags, per-browser or per-user account — all yours, and all
changeable without waiting on a release here.
The one thing worth stating plainly: the 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 the whole arrangement exists to avoid.
// app/layout.tsx — a server component
const stored = readSidebarLayout((await cookies()).toString());
return <AppShell defaultOpen={stored?.open ?? true} width={stored?.width} />;// Writing is a client-only act, so it goes in the callback.
<Shell.Root defaultOpen={defaultOpen} onOpenChange={(open) => save({ open })}>Width goes back as CSS, not as a prop
There is deliberately no defaultWidth. The width already lives in your
stylesheet — the resize handle writes --shell-sidebar-width and your rule
reads it — so restoring one means setting that property, which is the same
mechanism rather than a second one:
<Shell.Root
style={stored?.width ? { "--shell-sidebar-width": `${stored.width}px` } : undefined}
>Coming the other way, Shell.Sidebar's onResize reports a settled
width — once when a drag ends, not once per frame. That debounce is the one
piece of persistence machinery worth having in the package, because it depends
on knowing a drag is in progress. Everything else is yours:
<Shell.Sidebar onResize={(width) => save({ width })} />A cookie is worth choosing over localStorage for exactly one reason: it is
readable from the request, which is what makes the server read above possible.
And validate on the way in — stored state outlives the code that wrote it, so a
value from an older release or a half-written entry should fall back to the
defaults rather than reach your tree.
Accessibility
Shell.Trigger is a <button> carrying aria-expanded and aria-controls
pointing at the sidebar it governs, so the collapsed/expanded state and the
relationship are both announced. Shell.ResizeHandle is a focusable
role="separator" with aria-orientation, aria-valuenow, aria-valuemin
and aria-valuemax, and it responds to the arrow keys — so resizing is never
mouse-only. It always carries a value, falling back to the minimum before the
first measurement lands, because a focusable separator without one is an
invalid widget.
Shell.PeekZone is aria-hidden: it is a pointer affordance with no keyboard
or screen-reader meaning, and the trigger already covers both.
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. Renders data-shell.
Everything on the Root is shell-wide state, and it has to be: the store is
created in the Root's initialiser, and every part reads it in the same commit —
so a defaultOpen arriving from a child would guarantee the flash this design
exists to avoid. Anything belonging to one part lives on that part.
| Prop | Type | Default |
|---|---|---|
defaultOpen | boolean | — |
open | boolean | — |
onOpenChange | (open: boolean) => void | — |
store | ShellStore | — |
| Attribute | Values | Description |
|---|---|---|
data-shell | — | The container. |
data-state | "expanded" | "collapsed" | Whether the sidebar is open. |
data-peek | — | Present while the collapsed sidebar is floating out. |
data-resizing | — | Present for the duration of a resize drag. |
--shell-sidebar-width | measured px | Written by the resize handle, on the root rather than the sidebar so anything in the shell can size itself to match. |
Shell.Sidebar
The panel. Renders data-shell-sidebar and reports its measured width back to
the store. Carries the id the trigger's aria-controls points at.
| Prop | Type | Default |
|---|---|---|
side | "left" | "right" | "left" |
onResize | (width: number) => void | — |
| Attribute | Values | Description |
|---|---|---|
data-shell-sidebar | — | The panel. |
data-side | "left" | "right" | The edge it sits against. |
data-state | "expanded" | "collapsed" | Whether it is open. |
data-peek | — | Present while floating out on hover. |
data-resizing | — | Present mid-drag — use it to suppress width transitions. |
Shell.Viewport
The content area beside the sidebar. Renders data-shell-viewport, and carries
the same state attributes so it can react to the sidebar without a group
selector.
| Attribute | Values | Description |
|---|---|---|
data-shell-viewport | — | The content area. |
data-state | "expanded" | "collapsed" | Whether the sidebar is open. |
data-peek | — | Present while the sidebar is floating out. |
data-resizing | — | Present mid-drag. |
Shell.Trigger
Toggles the sidebar. Renders a <button> with data-shell-trigger. Ships no
copy — supply the label as children.
| Attribute | Values | Description |
|---|---|---|
data-shell-trigger | — | The toggle button. |
data-state | "expanded" | "collapsed" | Whether the sidebar is open. |
data-peek | — | Present while the sidebar is floating out. |
Shell.ResizeHandle
The drag affordance. Renders data-shell-resize-handle as a focusable
role="separator". Give it a width and a cursor in CSS.
| Prop | Type | Default |
|---|---|---|
step | number | 16 |
| Attribute | Values | Description |
|---|---|---|
data-shell-resize-handle | — | The separator. |
data-state | "expanded" | "collapsed" | Whether the sidebar is open. |
data-resizing | — | Present while this handle is being dragged. |
Shell.PeekZone
The strip along the screen edge that floats a collapsed sidebar out on hover.
Renders data-shell-peek-zone, aria-hidden. Give it a width and a position
in CSS. Omitting the part is how you opt out of peek — there is no prop for it,
because not rendering it already says so.
| Attribute | Values | Description |
|---|---|---|
data-shell-peek-zone | — | The hover strip. |
data-state | "expanded" | "collapsed" | Whether the sidebar is open — key the strip off collapsed so it vanishes when expanded. |
data-peek | — | Present while the sidebar is floating out. |