---
title: Shell
description: A collapsible, resizable sidebar beside the viewport it shares the screen with, with an edge hotspot and cookie persistence.
source: shell
---

```tsx title="primitives/shell/demos/basic.tsx"
"use client";

import { Nav } from "@intentface/chat/nav";
import { Shell } from "@intentface/chat/shell";
import {
  IconBox,
  IconChevronDown,
  IconHome,
  IconInbox,
  IconLayoutSidebar,
} from "@tabler/icons-react";

/*
 * 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 border border-[#f0f0f0] bg-[#fafafa] [--shell-sidebar-width:224px] dark:border-[#262626] dark:bg-[#111111]"
  >
    {/* 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-[#fafafa] dark:bg-[#111111]",
        "border border-transparent transition-[left,top,bottom,padding-top,background-color,border-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-xl data-[state=collapsed]:pt-0",
        "data-[state=collapsed]:bg-white data-[state=collapsed]:not-data-[hotspot]:border-[#f0f0f0]",
        "dark:data-[state=collapsed]:bg-[#181818] dark:data-[state=collapsed]:not-data-[hotspot]:border-[#262626]",
        "data-[state=collapsed]:data-[hotspot]:left-2 data-[hotspot]:smooth-shadow-ring-lg",
        "motion-reduce:transition-none",
      ].join(" ")}
    >
      {/* Same columns as a nav row: pl-4 puts the badge where a row's icon
          sits (Nav px-2 + row px-2), the badge is icon-sized, and gap-2 lands
          the title where a row's label starts. */}
      <div className="flex h-11 shrink-0 items-center gap-2 pr-2 pl-4">
        <span className="grid size-4 shrink-0 place-items-center rounded-[4px] bg-[#1a1a1a] font-semibold text-[9px] text-white dark:bg-[#fcfcfc] dark:text-[#111111]">
          ui
        </span>
        <span className="min-w-0 flex-1 truncate font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
          @intentface/chat
        </span>
        <Shell.Trigger aria-label="Collapse sidebar" className={iconButtonClass}>
          <IconLayoutSidebar className="size-4" />
        </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. */}
      <Nav.Root
        aria-label="Main"
        guide="none"
        defaultExpanded={["workspace"]}
        render={<nav />}
        className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-auto px-2 pb-2"
      >
        <Nav.List className="flex flex-col gap-0.5">
          <Nav.Item value="overview" active className={rowClass}>
            <Nav.Icon>
              <IconHome className="size-4" />
            </Nav.Icon>
            <Nav.Label className="min-w-0 truncate">Overview</Nav.Label>
          </Nav.Item>
          <Nav.Item value="inbox" className={rowClass}>
            <Nav.Icon>
              <IconInbox className="size-4" />
            </Nav.Icon>
            <Nav.Label className="min-w-0 truncate">Inbox</Nav.Label>
          </Nav.Item>

          <Nav.Group value="workspace" className="mt-3">
            <Nav.Trigger className={rowClass}>
              <Nav.Label className="min-w-0 truncate">Workspace</Nav.Label>
              <IconChevronDown className="ml-auto !size-3 text-[#949494] transition-transform group-data-[closed]/row:-rotate-90" />
            </Nav.Trigger>
            <Nav.List className="flex flex-col gap-0.5">
              {["Initiatives", "Projects", "Views", "Loops"].map((label) => (
                <Nav.Item key={label} value={label.toLowerCase()} className={rowClass}>
                  <Nav.Icon>
                    <IconBox className="size-4" />
                  </Nav.Icon>
                  <Nav.Label className="min-w-0 truncate">{label}</Nav.Label>
                </Nav.Item>
              ))}
            </Nav.List>
          </Nav.Group>
        </Nav.List>
      </Nav.Root>
    </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
        // 20px in — the card's 8px inset plus its 12px 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_20px,black_76px,black_calc(100%-76px),transparent_calc(100%-20px))]",
        "hover:before:bg-[#1a1a1a] data-[resizing]:before:bg-[#1a1a1a] dark:hover:before:bg-[#fcfcfc] dark:data-[resizing]:before:bg-[#fcfcfc]",
        "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
        "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-xl border border-[#f0f0f0] bg-white dark:border-[#262626] dark:bg-[#181818]">
        <div className="flex h-11 shrink-0 items-center gap-1 border-[#f0f0f0] border-b px-3 dark:border-[#262626]">
          <span className="px-1 text-[#949494] text-sm dark:text-[#6f6f6f]">Docs</span>
          <IconChevronDown className="size-3 -rotate-90 text-[#949494] dark:text-[#6f6f6f]" />
          <span className="px-1 font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
            Overview
          </span>
        </div>
        <article className="min-h-0 flex-1 overflow-auto px-8 py-8">
          <h1 className="mb-6 font-semibold text-[#1a1a1a] text-2xl tracking-tight dark:text-[#fcfcfc]">
            Overview
          </h1>
          <p className="mb-4 text-[#686868] text-sm leading-[1.7] dark:text-[#9b9b9b]">
            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-[#686868] text-sm leading-[1.7] dark:text-[#9b9b9b]">
            The width and the range it may be dragged through are this stylesheet&apos;s; the
            primitive only measures and reports back.
          </p>
        </article>
      </div>
    </Shell.Viewport>
  </Shell.Root>
);

const iconButtonClass =
  "grid size-7 shrink-0 cursor-pointer select-none place-items-center rounded-md text-[#686868] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]";

// An explicit height is load-bearing: 14px text has a fractional line-height,
// so padded rows land on a fraction of a pixel and nothing lines up.
const rowClass = [
  "group/row flex h-8 shrink-0 cursor-pointer select-none items-center gap-2 rounded-md px-2 text-sm",
  "text-[#686868] transition-colors hover:bg-[#f4f4f4] hover:text-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:bg-[#232323] dark:hover:text-[#fcfcfc]",
  "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
  "data-[active]:bg-[#ececec] data-[active]:text-[#1a1a1a] dark:data-[active]:bg-[#2d2d2d] dark:data-[active]:text-[#fcfcfc]",
  "[&_svg]:size-4 [&_svg]:shrink-0",
].join(" ");
```

## 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](/primitives/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](#why-the-sidebar-is-positioned). This is the one arrangement everything else depends on.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

```tsx
<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:

```tsx
<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.

```tsx title="primitives/shell/demos/range.tsx"
"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 border border-[#f0f0f0] bg-[#fafafa] [--shell-sidebar-width:200px] dark:border-[#262626] dark:bg-[#111111]"
  >
    <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-[#fafafa] dark:bg-[#111111]">
      <div className="flex h-11 shrink-0 items-center px-4 font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
        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-[#e4e4e4] before:transition-colors hover:before:bg-[#1a1a1a] data-[resizing]:before:bg-[#1a1a1a] dark:before:bg-[#2d2d2d] dark:hover:before:bg-[#fcfcfc] dark:data-[resizing]:before:bg-[#fcfcfc]"
      />
    </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-xl border border-[#f0f0f0] bg-white px-4 text-center text-[#686868] text-sm dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b]">
        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 text-[#949494] text-sm tabular-nums dark:text-[#6f6f6f]">
      {width === null ? "measuring…" : `${Math.round(width)}px`}
    </div>
  );
};
```

```css
[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.

```tsx title="primitives/shell/demos/grip.tsx"
"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 border border-[#f0f0f0] bg-[#fafafa] [--shell-sidebar-width:220px] dark:border-[#262626] dark:bg-[#111111]"
  >
    <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-[#fafafa] dark:bg-[#111111]">
      <div className="flex h-11 shrink-0 items-center px-4 font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
        Workspace
      </div>
      <div className="flex flex-col gap-0.5 px-2">
        {["Overview", "Inbox", "Projects"].map((label) => (
          <div
            key={label}
            className="flex h-8 items-center rounded-md px-2 text-[#686868] text-sm dark:text-[#9b9b9b]"
          >
            {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-[#1a1a1a] dark:focus-visible:outline-[#fcfcfc]",
        ].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-[#b0b0b0] dark:bg-[#5c5c5c]",
            // 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-xl border border-[#f0f0f0] bg-white px-4 text-center text-[#686868] text-sm dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b]">
        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.

```tsx title="primitives/shell/demos/hotspot.tsx"
"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 border border-[#f0f0f0] bg-[#fafafa] [--shell-sidebar-width:200px] dark:border-[#262626] dark:bg-[#111111]"
      >
        {/* 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-xl border border-[#c9d8f0] border-dashed bg-[#eaf1fb] dark:border-[#2b3a52] dark:bg-[#16202e]",
            // 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-[#fafafa] dark:bg-[#111111]",
            "border border-transparent transition-[left,top,bottom,padding-top,background-color,border-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-xl data-[state=collapsed]:pt-0",
            "data-[state=collapsed]:bg-white data-[state=collapsed]:not-data-[hotspot]:border-[#f0f0f0]",
            "dark:data-[state=collapsed]:bg-[#181818] dark:data-[state=collapsed]:not-data-[hotspot]:border-[#262626]",
            "data-[state=collapsed]:data-[hotspot]:left-2 data-[hotspot]:shadow-[0_6px_20px_rgba(0,0,0,0.07)] dark:data-[hotspot]:shadow-[0_6px_20px_rgba(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-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
              Workspace
            </span>
            <TriggerLabel />
          </div>

          <div className="flex flex-col gap-0.5 px-2">
            {["Overview", "Inbox", "Projects"].map((label) => (
              <div
                key={label}
                className="flex h-8 items-center rounded-md px-2 text-[#686868] text-sm dark:text-[#9b9b9b]"
              >
                {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-xl border border-[#f0f0f0] bg-white px-4 dark:border-[#262626] dark:bg-[#181818]">
            {/* 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-[#686868] text-sm dark:text-[#9b9b9b]">
              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 border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323] dark:focus-visible:outline-[#fcfcfc]"
      >
        {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="cursor-pointer rounded text-[#686868] text-xs transition-colors hover:text-[#1a1a1a] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:text-[#9b9b9b] dark:hover:text-[#fcfcfc] dark:focus-visible:outline-[#fcfcfc]"
    >
      {open ? "Hide" : "Pin"}
    </Shell.Trigger>
  );
};
```

```css
[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.

```tsx title="primitives/shell/demos/external.tsx"
"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 border border-[#f0f0f0] bg-[#fafafa] [--shell-sidebar-width:200px] dark:border-[#262626] dark:bg-[#111111]"
      >
        <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-[#fafafa] transition-[left] duration-150 ease-linear data-[state=collapsed]:-left-(--shell-sidebar-width) dark:bg-[#111111]">
          <div className="flex h-11 shrink-0 items-center px-4 font-medium text-[#1a1a1a] text-sm dark:text-[#fcfcfc]">
            Workspace
          </div>
          <div className="flex flex-col gap-0.5 px-2">
            {["Overview", "Inbox", "Projects"].map((label) => (
              <div
                key={label}
                className="flex h-8 items-center rounded-md px-2 text-[#686868] text-sm dark:text-[#9b9b9b]"
              >
                {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-xl border border-[#f0f0f0] bg-white text-[#686868] text-sm dark:border-[#262626] dark:bg-[#181818] dark:text-[#9b9b9b]">
            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 border border-[#e4e4e4] bg-white px-4 font-medium text-[#1a1a1a] text-sm transition-colors hover:bg-[#f4f4f4] focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-[#1a1a1a] dark:border-[#2d2d2d] dark:bg-[#181818] dark:text-[#fcfcfc] dark:hover:bg-[#232323] dark:focus-visible:outline-[#fcfcfc]"
      >
        {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.

```tsx
// 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:

```tsx
<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.

```css
[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.

export const shellKeys = [
  { keys: "Arrow left", description: "Narrows a left sidebar by `step` pixels, and widens a right one." },
  { keys: "Arrow right", description: "Widens a left sidebar by `step` pixels, and narrows a right one." },
  { keys: "Tab", description: "Moves focus to the grip, which is a focusable separator so resizing is never mouse-only." },
];

<KeysTable rows={shellKeys} />

## API reference

Every part accepts `className`, `style`, and `render` (see
[Styling](/handbook/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.

export const rootProps = [
  { name: "defaultOpen", type: "boolean", description: "Where the sidebar starts when nothing controls it. Read it from the request so the first paint is already right." },
  { name: "open", type: "boolean", description: "Controlled open state." },
  { name: "onOpenChange", type: "(open: boolean) => void", description: "Fires on every open/close. Persist it here." },
  { name: "store", type: "ShellStore", description: "An explicit Shell.createStore() handle, for state reachable from outside this tree. Must be stable for the Root's lifetime." },
];

<PropsTable rows={rootProps} />

export const rootAttrs = [
  { attribute: "data-shell", description: "The container." },
  { attribute: "data-state", values: '"expanded" | "collapsed"', description: "Whether the sidebar is open." },
  { attribute: "data-hotspot", description: "Present while the collapsed sidebar is floating out." },
  { attribute: "data-resizing", description: "Present for the duration of a resize drag." },
  { attribute: "--shell-sidebar-width", values: "measured px", description: "Written by the grip, on the root rather than the sidebar so anything in the shell can size itself to match." },
];

<AttributesTable rows={rootAttrs} />

### 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.

export const sidebarProps = [
  { name: "side", type: '"left" | "right"', default: '"left"', description: "Which edge it sits against. Surfaces as data-side; the resize drag inverts accordingly." },
  { name: "onResize", type: "(width: number) => void", description: "A settled width in pixels — once when a drag ends, never per frame, and whenever anything else resizes the panel. This is the measurement coming back out; there is no width prop going in, because that is CSS's." },
];

<PropsTable rows={sidebarProps} />

export const sidebarAttrs = [
  { attribute: "data-shell-sidebar", description: "The panel." },
  { attribute: "data-side", values: '"left" | "right"', description: "The edge it sits against." },
  { attribute: "data-state", values: '"expanded" | "collapsed"', description: "Whether it is open." },
  { attribute: "data-hotspot", description: "Present while floating out on hover." },
  { attribute: "data-resizing", description: "Present mid-drag — use it to suppress width transitions." },
];

<AttributesTable rows={sidebarAttrs} />

### 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.

export const viewportAttrs = [
  { attribute: "data-shell-viewport", description: "The content area." },
  { attribute: "data-state", values: '"expanded" | "collapsed"', description: "Whether the sidebar is open." },
  { attribute: "data-hotspot", description: "Present while the sidebar is floating out." },
  { attribute: "data-resizing", description: "Present mid-drag." },
];

<AttributesTable rows={viewportAttrs} />

### 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.

export const triggerAttrs = [
  { attribute: "data-shell-trigger", description: "The toggle button." },
  { attribute: "data-state", values: '"expanded" | "collapsed"', description: "Whether the sidebar is open." },
  { attribute: "data-hotspot", description: "Present while the sidebar is floating out." },
];

<AttributesTable rows={triggerAttrs} />

### 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"`.

export const gripProps = [
  { name: "step", type: "number", default: "16", description: "Pixels moved per arrow-key press." },
];

<PropsTable rows={gripProps} />

export const gripAttrs = [
  { attribute: "data-shell-grip", description: "The separator." },
  { attribute: "data-state", values: '"expanded" | "collapsed"', description: "Whether the sidebar is open." },
  { attribute: "data-resizing", description: "Present while this grip is being dragged." },
];

<AttributesTable rows={gripAttrs} />

### 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`.

export const hotspotAttrs = [
  { attribute: "data-shell-hotspot", description: "The hover strip." },
  { attribute: "data-state", values: '"expanded" | "collapsed"', description: "Whether the sidebar is open — key the strip off collapsed so it vanishes when expanded." },
  { attribute: "data-hotspot", description: "Present while the sidebar is floating out." },
];

<AttributesTable rows={hotspotAttrs} />

## useShell

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

```tsx
const collapsed = useShell((shell) => !shell.open);
```

export const hookMembers = [
  { name: "open", type: "boolean", description: "Whether the sidebar is expanded." },
  { name: "hotspot", type: "boolean", description: "The collapsed sidebar is floating out on hover. Never persisted." },
  { name: "resizing", type: "boolean", description: "True for the duration of a resize drag, so parts can suppress transitions." },
  { name: "width", type: "number | null", description: "The width the browser settled on. Null until the sidebar has mounted and been measured." },
  { name: "setOpen", type: "(open: boolean) => void", description: "Expand or collapse." },
  { name: "toggle", type: "() => void", description: "Flip it; a floated-out sidebar expands in place rather than closing. This is what an app's keyboard shortcut calls." },
  { name: "setHotspot", type: "(hotspot: boolean) => void", description: "Float a collapsed sidebar out, or put it away. Ignored while it is already open." },
  { name: "setResizing", type: "(resizing: boolean) => void", description: "Marks a drag in progress, which is what defers the settled-width report." },
  { name: "setWidth", type: "(width: number) => void", description: "Reports a measurement. Not a request to resize — CSS decides the size." },
];

<PropsTable rows={hookMembers} />
