Shell
A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot and cookie persistence.
"use client";
import { Shell } from "@intentface/chat/shell";
import { ChevronDown, PanelLeft } from "@keyline-icons/react";
import { Logo } from "./logo";
import { SidebarNav } from "./sidebar-nav";
/*
* A shell the way it is meant to be used: a sidebar that collapses, floats out
* on hover and drags wider, holding a real Nav, beside the content card it
* shares the screen with.
*
* The load-bearing arrangement is the one that is easy to get wrong. The
* sidebar is taken *out of flow* and a plain spacer — the gutter — holds its
* place. That 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 the hotspot holds it out. A sidebar left in flow can only animate
* its own width, so it can never float over the content, and the hotspot has
* nothing to slide across.
*
* `absolute` inside a `relative` root because this is a box on a docs page; a
* real app shell uses `fixed` against the window.
*/
export const Basic = () => (
<Shell.Root
defaultOpen
className="group/shell relative flex h-128 w-full overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] [--shell-sidebar-width:224px] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
{/* Not rendering this is how you opt out of hotspot. */}
<Shell.Hotspot className="absolute inset-y-0 left-0 z-20 hidden w-5 data-[state=collapsed]:block" />
{/* The gutter. Not a part of the package: a div reading the property the
grip writes, animating to zero while the panel slides away. */}
<div
data-slot="shell-gutter"
className="w-(--shell-sidebar-width) shrink-0 transition-[width] duration-150 ease-linear group-data-resizing/shell:transition-none group-data-[state=collapsed]/shell:w-0 motion-reduce:transition-none"
/>
<Shell.Sidebar
className={[
// min/max-width are the entire drag range — the handle reads them off computed style.
// pt-2 matches the viewport's padding, so the sidebar header sits on the
// same lines as the card header and the first row lands on its border.
"absolute inset-y-0 left-0 z-10 flex w-(--shell-sidebar-width) min-w-[184px] max-w-[320px] flex-col overflow-hidden pt-2",
// Always opaque: the content card passes beneath the panel while the two
// animate, so a transparent expanded state would show it through.
"bg-[#f5f5f6] dark:bg-[#131315]",
"transition-[left,top,bottom,padding-top,background-color,border-radius,box-shadow] duration-150 ease-linear",
// The card geometry is baked into the whole collapsed state. Off-canvas
// it is invisible, so the hotspot animates `left` alone — the panel never
// changes height mid-slide. Only expand/collapse morphs card ↔ flat.
// The card's own inset supplies the 8px, so the padding goes — and
// because both transition, the header stays put while the edge moves.
"data-[state=collapsed]:-left-(--shell-sidebar-width) data-[state=collapsed]:inset-y-2 data-[state=collapsed]:rounded-lg data-[state=collapsed]:pt-0",
"data-[state=collapsed]:bg-white data-[state=collapsed]:not-data-[hotspot]:shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[state=collapsed]:bg-zinc-900 dark:data-[state=collapsed]:not-data-[hotspot]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
// Floated out by the hotspot it lifts to overlay elevation.
"data-[state=collapsed]:data-[hotspot]:left-2 data-[hotspot]:shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)]",
"dark:data-[hotspot]:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]",
"motion-reduce:transition-none",
].join(" ")}
>
{/* Same columns as a nav row: the 20px logo tile centres on a row's 16px
icon (Nav px-2 + row px-2, less 2px), and gap-1.5 lands the title
where a row's label starts. */}
<div className="flex h-11 shrink-0 items-center gap-1.5 pr-2 pl-3.5">
<span className="grid size-5 shrink-0 place-items-center rounded-[5px] bg-[#0169cc] bg-linear-to-b from-[oklch(57.2%_0.166_253.2)] to-[oklch(52.9%_0.173_255)] shadow-[inset_0_1px_0_rgb(255_255_255/0.28),inset_0_-2px_3px_oklch(30%_0.12_258/0.35),0_0_0_1px_oklch(46.5%_0.146_254.8),0_1px_2px_rgb(1_105_204/0.35)]">
<Logo />
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-[13px] text-zinc-900 tracking-[-0.01em] dark:text-zinc-100">
@intentface/chat
</span>
<Shell.Trigger
aria-label="Collapse sidebar"
className="grid size-7 shrink-0 cursor-pointer select-none place-items-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:text-zinc-500 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
<PanelLeft className="size-[15px]" />
</Shell.Trigger>
</div>
{/* The nav is its own primitive — see the Nav page for the tree, the
rail and the keyboard model. Here it is just what a sidebar holds. */}
<SidebarNav />
</Shell.Sidebar>
{/* A sibling of the sidebar, not a child: the sidebar clips its overflow
for the collapsed card, so a handle hung off its edge would be cut in
half. Positioned instead against the viewport's left edge — the 6px hit
area straddles the content card's border, so the hairline it reveals
lands exactly on the line already drawn there. */}
<Shell.Grip
aria-label="Resize sidebar"
className={[
"absolute inset-y-0 left-[calc(var(--shell-sidebar-width)+8px)] z-20 w-1.5 -translate-x-1/2 cursor-col-resize select-none",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors before:duration-100",
// Masked rather than gradient-filled, so the hairline keeps a single
// background-color to transition while both ends fall away. The stops
// are pixels, not percentages: the fade has to land fully transparent
// 16px in — the card's 8px inset plus its 8px radius, where the corner
// arc leaves the straight edge — and that distance is fixed, not a
// share of the height.
"before:[mask-image:linear-gradient(to_bottom,transparent_16px,black_72px,black_calc(100%-72px),transparent_calc(100%-16px))]",
"hover:before:bg-[#0169cc] data-[resizing]:before:bg-[#0169cc] dark:hover:before:bg-[#4c9bea] dark:data-[resizing]:before:bg-[#4c9bea]",
"focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
"data-[state=collapsed]:hidden",
].join(" ")}
/>
{/* The gutter only exists while the sidebar does: collapsed, the card runs
edge to edge. */}
<Shell.Viewport className="flex min-w-0 flex-1 flex-col p-2">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
<div className="flex h-11 shrink-0 items-center gap-1 border-zinc-950/6 border-b px-3 dark:border-white/6">
<span className="px-1 text-[13px] text-zinc-500 dark:text-zinc-400">Docs</span>
<ChevronDown className="size-3 -rotate-90 text-zinc-400 dark:text-zinc-500" />
<span className="px-1 font-medium text-[13px] text-zinc-900 dark:text-zinc-100">
Overview
</span>
</div>
<article className="min-h-0 flex-1 overflow-auto px-8 py-8">
<h1 className="mb-5 font-semibold text-base text-zinc-900 tracking-tight dark:text-zinc-100">
Overview
</h1>
<p className="mb-4 text-sm text-zinc-700 leading-[1.7] dark:text-zinc-300">
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.
</p>
<p className="text-sm text-zinc-700 leading-[1.7] dark:text-zinc-300">
The width and the range it may be dragged through are this stylesheet's; the
primitive only measures and reports back.
</p>
</article>
</div>
</Shell.Viewport>
</Shell.Root>
);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.
"use client";
import { Shell, useShell } from "@intentface/chat/shell";
/*
* The drag range, made obvious by making it small: 160px to 260px, so both
* stops are a short drag away.
*
* Nothing here configures the range. `min-width` and `max-width` on the
* sidebar are the whole configuration — the grip reads them off computed style
* when a drag starts, clamps against them, and writes the result back as
* `--shell-sidebar-width`. The readout is the measurement coming back out, and
* it is the same number `aria-valuenow` announces.
*/
export const Range = () => (
<Shell.Root
defaultOpen
className="group/shell relative flex h-80 w-full overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] [--shell-sidebar-width:200px] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
<div data-slot="shell-gutter" className="w-(--shell-sidebar-width) shrink-0" />
<Shell.Sidebar className="absolute inset-y-0 left-0 z-10 flex w-(--shell-sidebar-width) min-w-[160px] max-w-[260px] flex-col bg-[#f5f5f6] dark:bg-[#131315]">
<div className="flex h-11 shrink-0 items-center px-4 font-medium text-[13px] text-zinc-900 dark:text-zinc-100">
Drag the divider
</div>
<WidthReadout />
{/* A 6px hit area with a hairline inside, so the target is comfortable
while the divider stays thin. */}
<Shell.Grip
aria-label="Resize sidebar"
className="-right-[3px] absolute inset-y-0 w-1.5 cursor-col-resize select-none before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-zinc-950/10 before:transition-colors hover:before:bg-[#0169cc] data-[resizing]:before:bg-[#0169cc] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 dark:before:bg-white/10 dark:hover:before:bg-[#4c9bea] dark:data-[resizing]:before:bg-[#4c9bea]"
/>
</Shell.Sidebar>
<Shell.Viewport className="flex min-w-0 flex-1 flex-col p-2">
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] px-4 text-center text-[13px] text-zinc-500 dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:text-zinc-400">
The sidebar stops at 160px and 260px. The browser clamps it, not the primitive.
</div>
</Shell.Viewport>
</Shell.Root>
);
/**
* `width` is what the browser settled on after clamping, not what the drag
* asked for — which is why it stops moving at the bounds even while the
* pointer keeps going.
*/
const WidthReadout = () => {
const width = useShell((shell) => shell.width);
return (
<div className="px-4 font-mono text-xs text-zinc-400 tabular-nums dark:text-zinc-500">
{width === null ? "measuring…" : `${Math.round(width)}px`}
</div>
);
};[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.
"use client";
import { Shell } from "@intentface/chat/shell";
/*
* Styling the grip: an iOS-style pill that fades in on hover and rides the
* pointer vertically.
*
* The grip is a bare div with a role and some keys — no shadow DOM, no
* built-in affordance — so the whole appearance is yours. Nothing is drawn at
* rest; the pill is a child, faded in on hover and positioned from `--grip-y`.
*
* The pointer position is written straight to a custom property rather than
* held in 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 about — only a number CSS reads.
*
* `onPointerMove` is safe to pass: the primitive merges handlers rather than
* replacing them, so the drag it runs on the same event still happens.
*/
export const Grip = () => (
<Shell.Root
defaultOpen
className="group/shell relative flex h-80 w-full overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] [--shell-sidebar-width:220px] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
<div data-slot="shell-gutter" className="w-(--shell-sidebar-width) shrink-0" />
<Shell.Sidebar className="absolute inset-y-0 left-0 z-10 flex w-(--shell-sidebar-width) min-w-[180px] max-w-[300px] flex-col bg-[#f5f5f6] dark:bg-[#131315]">
<div className="flex h-11 shrink-0 items-center px-4 font-medium text-[13px] text-zinc-900 dark:text-zinc-100">
Workspace
</div>
<div className="flex flex-col gap-0.5 px-2">
{/* The first row stands in for the current page. */}
{["Overview", "Inbox", "Projects"].map((label) => (
<div
key={label}
className="flex h-[30px] items-center rounded-md px-2 font-medium text-[13px] text-zinc-700 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 first:bg-white first:bg-linear-to-b first:from-white first:to-[#fdfdfd] first:text-zinc-900 first:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:text-zinc-300 dark:hover:bg-white/8 dark:hover:text-zinc-100 dark:first:bg-[#2d2d30] dark:first:from-[#29292c] dark:first:to-[#242427] dark:first:text-zinc-100 dark:first:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
>
{label}
</div>
))}
</div>
<Shell.Grip
aria-label="Resize sidebar"
// `offsetY` is already relative to the grip's own box, so this costs no
// layout read — unlike getBoundingClientRect on every move.
onPointerMove={(event) => {
event.currentTarget.style.setProperty("--grip-y", `${event.nativeEvent.offsetY}px`);
}}
className={[
// A 20px hit area straddling the sidebar's edge. Wide enough for a
// fingertip, while the 4px pill drawn inside it stays thin — which
// is the point of separating the target from the affordance.
"group/grip -right-2.5 absolute inset-y-0 w-5 cursor-col-resize select-none",
"focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"pointer-events-none absolute left-1/2 h-9 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full",
"bg-zinc-400 dark:bg-zinc-600",
// Clamped by half its own height at each end, so it never hangs
// out of the track. Centred at rest, so a keyboard user focusing
// the grip finds it somewhere sensible rather than at the top.
"top-[clamp(18px,var(--grip-y,50%),calc(100%-18px))]",
// No transition on `top`: a handle that lags the pointer reads as
// broken rather than smooth. Only the fade is animated.
"opacity-0 transition-opacity duration-150",
"group-hover/grip:opacity-100 group-focus-visible/grip:opacity-100 group-data-[resizing]/grip:opacity-100",
].join(" ")}
/>
</Shell.Grip>
</Shell.Sidebar>
<Shell.Viewport className="flex min-w-0 flex-1 flex-col p-2">
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] px-4 text-center text-[13px] text-zinc-500 dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:text-zinc-400">
Move the pointer onto the sidebar's right edge. The handle appears and follows it.
</div>
</Shell.Viewport>
</Shell.Root>
);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.
"use client";
import { Shell, type ShellStore, useShell, useShellStore } from "@intentface/chat/shell";
import { useState } from "react";
/*
* Starts collapsed, so the hotspot is the first thing there is to try: rest the
* pointer on the strip at the left edge and the sidebar floats out as a card.
* Two ways back: the header control, reached through the hotspot once the
* sidebar is away, and the button under the shell, which drives the same state
* from outside the tree through a `Shell.createStore()` handle.
*
* `Shell.Hotspot` is the part; `hotspot` is the state it produces. The hotspot is
* the hit area you hover, and while the pointer rests there the sidebar and
* everything else in the shell carry `data-hotspot`.
*
* The card geometry is baked into the whole collapsed state rather than into
* `data-hotspot` alone. Off-canvas the card is invisible anyway, so the hotspot then
* animates `left` and nothing else — no vertical movement, no radius appearing
* mid-slide. Only expand and collapse morph card to flat.
*/
export const HotspotDemo = () => {
const [store] = useState(() => Shell.createStore());
return (
<div className="flex w-full flex-col gap-3">
<Shell.Root
store={store}
className="group/shell relative flex h-80 w-full overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] [--shell-sidebar-width:200px] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
{/* Invisible in a real app. Tinted here so there is something to aim at,
since the whole point is a hit area you cannot otherwise see.
It sits *under* the sidebar, so the card tucks the strip away as it
slides out. Nothing is lost by that: `Shell.Sidebar` carries its own
hold handler, so the hotspot survives the pointer moving from the strip
onto the card even though the strip is no longer beneath it.
Not rendering this part at all is how you opt out of hotspot. */}
<Shell.Hotspot
className={[
"absolute inset-y-2 left-2 z-0 w-7 rounded-lg border border-[#0169cc]/60 border-dashed bg-[#0169cc]/5 dark:border-[#4c9bea]/50 dark:bg-[#4c9bea]/10",
// Faded rather than toggled with `display`, so it arrives and leaves
// with the sidebar instead of popping. `pointer-events` still switches
// outright: a transparent strip that swallowed clicks would be worse
// than a visible one.
"pointer-events-none opacity-0 transition-opacity duration-150 ease-linear",
"data-[state=collapsed]:pointer-events-auto data-[state=collapsed]:opacity-100",
].join(" ")}
/>
<div
data-slot="shell-gutter"
className="w-(--shell-sidebar-width) shrink-0 transition-[width] duration-150 ease-linear group-data-[state=collapsed]/shell:w-0"
/>
<Shell.Sidebar
className={[
"absolute inset-y-0 left-0 z-10 flex w-(--shell-sidebar-width) flex-col overflow-hidden pt-2",
// Always opaque: the content card passes beneath the panel while the
// two animate, so a transparent expanded state would show it through.
"bg-[#f5f5f6] dark:bg-[#131315]",
"transition-[left,top,bottom,padding-top,background-color,border-radius,box-shadow] duration-150 ease-linear",
// The collapsed state carries the card. The hotspoted state moves it.
"data-[state=collapsed]:-left-(--shell-sidebar-width) data-[state=collapsed]:inset-y-2 data-[state=collapsed]:rounded-lg data-[state=collapsed]:pt-0",
"data-[state=collapsed]:bg-white data-[state=collapsed]:not-data-[hotspot]:shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)]",
"dark:data-[state=collapsed]:bg-zinc-900 dark:data-[state=collapsed]:not-data-[hotspot]:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]",
// Floated out by the hotspot it lifts to overlay elevation.
"data-[state=collapsed]:data-[hotspot]:left-2 data-[hotspot]:shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-8px_rgb(0_0_0/0.16)]",
"dark:data-[hotspot]:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),inset_0_0_0_1px_rgb(255_255_255/0.07),0_0_0_1px_rgb(0_0_0/0.16),0_12px_32px_-8px_rgb(0_0_0/0.4)]",
].join(" ")}
>
<div className="flex h-11 shrink-0 items-center justify-between gap-2 px-4">
<span className="font-medium text-[13px] text-zinc-900 dark:text-zinc-100">
Workspace
</span>
<TriggerLabel />
</div>
<div className="flex flex-col gap-0.5 px-2">
{/* The first row stands in for the current page. */}
{["Overview", "Inbox", "Projects"].map((label) => (
<div
key={label}
className="flex h-[30px] items-center rounded-md px-2 font-medium text-[13px] text-zinc-700 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 first:bg-white first:bg-linear-to-b first:from-white first:to-[#fdfdfd] first:text-zinc-900 first:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:text-zinc-300 dark:hover:bg-white/8 dark:hover:text-zinc-100 dark:first:bg-[#2d2d30] dark:first:from-[#29292c] dark:first:to-[#242427] dark:first:text-zinc-100 dark:first:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
>
{label}
</div>
))}
</div>
</Shell.Sidebar>
<Shell.Viewport className="flex min-w-0 flex-1 flex-col p-2">
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] px-4 dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]">
{/* Capped: a line of prose spanning the whole viewport is unreadable,
and this one runs behind the strip at the left edge. */}
<p className="max-w-56 text-balance text-center text-[13px] text-zinc-500 dark:text-zinc-400">
Rest the pointer on the strip at the left edge.
</p>
</div>
</Shell.Viewport>
</Shell.Root>
{/* Outside Shell.Root — it reaches the state through the store handle. */}
<ExternalTrigger store={store} />
</div>
);
};
const ExternalTrigger = ({ store }: { store: ShellStore }) => {
const open = useShellStore(store, (shell) => shell.open);
return (
<div className="flex justify-center">
<button
type="button"
onClick={() => store.getSnapshot().toggle()}
className="flex h-8 cursor-pointer items-center rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] px-4 font-medium text-[13px] text-zinc-900 hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134] dark:text-zinc-100"
>
{open ? "Collapse" : "Expand"}
</button>
</div>
);
};
/**
* The label has to name the action, not the part. While the hotspot is holding the sidebar out
* it is collapsed but visible, and pressing the trigger pins it open rather
* than closing it — so "Hide" would be wrong in exactly the state this demo
* spends most of its time in.
*/
const TriggerLabel = () => {
const open = useShell((shell) => shell.open);
return (
<Shell.Trigger
aria-label={open ? "Collapse sidebar" : "Pin sidebar open"}
className="-mr-2 flex h-6 cursor-pointer items-center rounded-full px-2 font-medium text-xs text-zinc-500 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-zinc-100"
>
{open ? "Hide" : "Pin"}
</Shell.Trigger>
);
};[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.
"use client";
import { Shell, type ShellStore, useShellStore } from "@intentface/chat/shell";
import { useEffect, useState } from "react";
/*
* Driving the shell from outside its tree, and binding a key to it.
*
* `Shell.createStore()` is the handle. Pass it to the Root and the primitive
* uses it instead of making its own, which means anything holding the same
* handle can read and drive the state — including the button under the shell,
* which is a sibling of the Root rather than a descendant, and so could never
* have reached it through context.
*
* The store is created inside `useState` so it survives re-renders. Creating
* it during render would hand the Root a different store every time.
*/
export const External = () => {
const [store] = useState(() => Shell.createStore());
// The element the shortcut is scoped to. A real app binds the key for the
// whole window and needs no such ref; this one shares a page with other
// demos and with the docs' own search field.
const [host, setHost] = useState<HTMLDivElement | null>(null);
return (
<div ref={setHost} className="flex w-full flex-col gap-3">
<Shortcut store={store} host={host} />
<Shell.Root
store={store}
defaultOpen
className="group/shell relative flex h-96 w-full overflow-hidden rounded-xl bg-[#f5f5f6] shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] [--shell-sidebar-width:200px] dark:bg-[#131315] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:after:pointer-events-none dark:after:absolute dark:after:inset-0 dark:after:z-50 dark:after:rounded-[inherit] dark:after:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06)]"
>
<div
data-slot="shell-gutter"
className="w-(--shell-sidebar-width) shrink-0 transition-[width] duration-150 ease-linear group-data-[state=collapsed]/shell:w-0"
/>
<Shell.Sidebar className="absolute inset-y-0 left-0 z-10 flex w-(--shell-sidebar-width) flex-col overflow-hidden bg-[#f5f5f6] transition-[left] duration-150 ease-linear data-[state=collapsed]:-left-(--shell-sidebar-width) dark:bg-[#131315]">
<div className="flex h-11 shrink-0 items-center px-4 font-medium text-[13px] text-zinc-900 dark:text-zinc-100">
Workspace
</div>
<div className="flex flex-col gap-0.5 px-2">
{/* The first row stands in for the current page. */}
{["Overview", "Inbox", "Projects"].map((label) => (
<div
key={label}
className="flex h-[30px] items-center rounded-md px-2 font-medium text-[13px] text-zinc-700 transition-colors hover:bg-zinc-950/5 hover:text-zinc-900 first:bg-white first:bg-linear-to-b first:from-white first:to-[#fdfdfd] first:text-zinc-900 first:shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] dark:text-zinc-300 dark:hover:bg-white/8 dark:hover:text-zinc-100 dark:first:bg-[#2d2d30] dark:first:from-[#29292c] dark:first:to-[#242427] dark:first:text-zinc-100 dark:first:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)]"
>
{label}
</div>
))}
</div>
</Shell.Sidebar>
<Shell.Viewport className="flex min-w-0 flex-1 flex-col p-2">
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.06),0_4px_8px_-2px_rgb(0_0_0/0.05)] text-[13px] text-zinc-500 dark:bg-zinc-900 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.05),inset_0_0_0_1px_rgb(255_255_255/0.06),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:text-zinc-400">
Content
</div>
</Shell.Viewport>
</Shell.Root>
{/* Outside Shell.Root entirely — it reaches the state through the store
handle, not through context. */}
<ExternalTrigger store={store} />
</div>
);
};
/**
* A sibling of the Root, not a child. It reads the same state the sidebar
* renders from, and calls the same action the built-in trigger would.
*/
const ExternalTrigger = ({ store }: { store: ShellStore }) => {
const open = useShellStore(store, (shell) => shell.open);
return (
<div className="flex justify-center">
<button
type="button"
onClick={() => store.getSnapshot().toggle()}
className="flex h-8 cursor-pointer items-center rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] px-4 font-medium text-[13px] text-zinc-900 hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134] dark:text-zinc-100"
>
{open ? "Collapse" : "Expand"}
</button>
</div>
);
};
/**
* The package binds no global keys, because it cannot know which combinations
* the surrounding app has already spent. Binding one is a few lines, and
* `toggle` is all it needs — a floated-out sidebar is pinned open rather than
* closed. Press Cmd/Ctrl + B with the pointer over this demo.
*
* The `host` test is this page's problem, not yours: a docs page carries many
* demos and a search field, so a bare window listener here would swallow
* Cmd/Ctrl + B everywhere on it. An app binding its own shortcut drops the
* check and keeps the rest.
*/
const Shortcut = ({ store, host }: { store: ShellStore; host: HTMLElement | null }) => {
useEffect(() => {
if (!host) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "b" || !(event.metaKey || event.ctrlKey)) return;
if (!host.matches(":hover") && !host.contains(document.activeElement)) return;
event.preventDefault();
store.getSnapshot().toggle();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [store, host]);
return null;
};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 | |
| Arrow right | |
| Tab |
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 | Details |
|---|---|---|---|
defaultOpen | boolean | — | |
open | boolean | — | |
onOpenChange | (open: boolean) => void | — | |
store | ShellStore | — |
| Attribute | Description |
|---|---|
data-shell | |
data-state | |
data-hotspot | |
data-resizing |
| CSS variable | Description |
|---|---|
--shell-sidebar-width |
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 | Details |
|---|---|---|---|
side | "left" | "right" | "left" | |
onResize | (width: number) => void | — |
| Attribute | Description |
|---|---|
data-shell-sidebar | |
data-side | |
data-state | |
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 | Description |
|---|---|
data-shell-viewport | |
data-state | |
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 | Description |
|---|---|
data-shell-trigger | |
data-state | |
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 | Details |
|---|---|---|---|
step | number | 16 |
| Attribute | Description |
|---|---|
data-shell-grip | |
data-state | |
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 | Description |
|---|---|
data-shell-hotspot | |
data-state | |
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 | Details |
|---|---|---|---|
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 | — |