Shell

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

@intentface/chat
DocsOverview

Overview

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

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

"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&apos;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-width in your stylesheet. The primitive measures; it never sizes.
  • No global keys — the package claims none, because it cannot know which combinations your app has already spent. Bind your own around toggle.
  • Hotspot is opt-in — render Shell.Hotspot and a collapsed sidebar floats back out when the pointer rests against the screen edge. Omit the part to opt out; there is no prop, because not rendering it already says so.
  • Persists nothing itself — state goes out through onOpenChange and onResize, and comes back as defaultOpen and a CSS custom property. Where it is kept is yours.
  • The sidebar must leave the flow — see Why the sidebar is positioned. This is the one arrangement everything else depends on.
  • Get started — see Quick start to add the package.

Anatomy

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.

Drag the divider
measuring…
The sidebar stops at 160px and 260px. The browser clamps it, not the primitive.
"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>
  );
};
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.

Workspace
Overview
Inbox
Projects
Move the pointer onto the sidebar's right edge. The handle appears and follows it.
"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.

Workspace
Overview
Inbox
Projects

Rest the pointer on the strip at the left edge.

"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>
  );
};
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.

Workspace
Overview
Inbox
Projects
Content
"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.

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.

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

API reference

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

Shell.Root

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

PropTypeDefaultDetails
defaultOpenboolean—
openboolean—
onOpenChange(open: boolean) => void—
storeShellStore—
AttributeDescription
data-shell
The container.
data-state
Whether the sidebar is open."expanded" | "collapsed"
data-hotspot
Present while the collapsed sidebar is floating out.
data-resizing
Present for the duration of a resize drag.
CSS variableDescription
--shell-sidebar-width
Written by the grip, on the root rather than the sidebar so anything in the shell can size itself to match.measured px

Shell.Sidebar

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

PropTypeDefaultDetails
side"left" | "right""left"
onResize(width: number) => void—
AttributeDescription
data-shell-sidebar
The panel.
data-side
The edge it sits against."left" | "right"
data-state
Whether it is open."expanded" | "collapsed"
data-hotspot
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. Carries the same state attributes as the root, so it can react to the sidebar without a group selector. Renders a <div> element.

AttributeDescription
data-shell-viewport
The content area.
data-state
Whether the sidebar is open."expanded" | "collapsed"
data-hotspot
Present while the sidebar is floating out.
data-resizing
Present mid-drag.

Shell.Trigger

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

AttributeDescription
data-shell-trigger
The toggle button.
data-state
Whether the sidebar is open."expanded" | "collapsed"
data-hotspot
Present while the sidebar is floating out.

Shell.Grip

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

PropTypeDefaultDetails
stepnumber16
AttributeDescription
data-shell-grip
The separator.
data-state
Whether the sidebar is open."expanded" | "collapsed"
data-resizing
Present while this grip is being dragged.

Shell.Hotspot

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

AttributeDescription
data-shell-hotspot
The hover strip.
data-state
Whether the sidebar is open — key the strip off collapsed so it vanishes when expanded."expanded" | "collapsed"
data-hotspot
Present while the sidebar is floating out.

useShell

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

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