---
title: Shell
description: A collapsible, resizable sidebar beside the viewport it shares the screen with, with hover-peek 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 type { ComponentProps } from "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 peeking. A sidebar left in flow can only animate
 * its own width, so it can never float over the content, and the peek has
 * nothing to slide across.
 *
 * `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 hover-peek. */}
    <Shell.PeekZone 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
        resize handle 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 peek 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-[peek]:border-[#f0f0f0]",
        "dark:data-[state=collapsed]:bg-[#181818] dark:data-[state=collapsed]:not-data-[peek]:border-[#262626]",
        "data-[state=collapsed]:data-[peek]:left-2 data-[peek]: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}>
          <PanelLeftIcon />
        </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>
              <HomeIcon />
            </Nav.Icon>
            <Nav.Label className="min-w-0 truncate">Overview</Nav.Label>
          </Nav.Item>
          <Nav.Item value="inbox" className={rowClass}>
            <Nav.Icon>
              <InboxIcon />
            </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>
              <ChevronIcon 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>
                    <BoxIcon />
                  </Nav.Icon>
                  <Nav.Label className="min-w-0 truncate">{label}</Nav.Label>
                </Nav.Item>
              ))}
            </Nav.List>
          </Nav.Group>
        </Nav.List>
      </Nav.Root>

      {/* A 6px hit area with a hairline inside, so the target is comfortable
          while the divider stays thin. */}
      <Shell.ResizeHandle
        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-transparent before:transition-colors before:duration-100",
          "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(" ")}
      />
    </Shell.Sidebar>

    {/* 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>
          <ChevronIcon 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(" ");

const PanelLeftIcon = (props: ComponentProps<"svg">) => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.3"
    strokeLinejoin="round"
    className="size-4"
    aria-hidden="true"
    {...props}
  >
    <rect x="2" y="3" width="12" height="10" rx="2" />
    <path d="M6.5 3v10" />
  </svg>
);

const HomeIcon = (props: ComponentProps<"svg">) => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.3"
    aria-hidden="true"
    {...props}
  >
    <path d="M2.5 6.5 8 2.5l5.5 4v6a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-6Z" strokeLinejoin="round" />
  </svg>
);

const InboxIcon = (props: ComponentProps<"svg">) => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.3"
    aria-hidden="true"
    {...props}
  >
    <path d="M2.5 8.5h3l1 2h3l1-2h3v3a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-3Z" strokeLinejoin="round" />
    <path
      d="M2.5 8.5l1.6-4.2a1 1 0 0 1 .94-.65h5.92a1 1 0 0 1 .94.65l1.6 4.2"
      strokeLinejoin="round"
    />
  </svg>
);

const BoxIcon = (props: ComponentProps<"svg">) => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.3"
    aria-hidden="true"
    {...props}
  >
    <rect x="2.5" y="2.5" width="11" height="11" rx="2.5" strokeLinejoin="round" />
  </svg>
);

const ChevronIcon = (props: ComponentProps<"svg">) => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    {...props}
  >
    <path d="m4 6.5 4 4 4-4" />
  </svg>
);
```

The sidebar above holds a [Nav](/primitives/nav); the two are separate
primitives that happen to be built for each other.

## Usage guidelines

- **App shell, not a chat part** — the sidebar-and-viewport frame an app sits in.
- **No global keys** — the package claims none; bind your own around `toggle`. Only the resize handle's arrow keys are local enough to belong here.
- **Width is CSS** — the sidebar's size and the range the drag may move it through are `width` / `min-width` / `max-width` in your stylesheet. The primitive measures; it never sizes.
- **Hover-peek** — a collapsed sidebar floats back out when the pointer rests against the screen edge. Render `Shell.PeekZone` to opt in; omit it to opt out.
- **Persists nothing itself** — `open` goes out through `onOpenChange` and back as `defaultOpen`; the width goes out through the sidebar's `onResize` and back as CSS. Where it is kept is yours, and reading it from the request keeps the first paint right.
- **State via `useShell`** — read open, peek, resizing and the measured width from anywhere inside.
- **Get started** — see [Quick start](/quick-start) to add the package.

## Anatomy

```tsx
<Shell.Root>
  <Shell.PeekZone />
  <Shell.Sidebar>
    <Shell.Trigger />
    <Shell.ResizeHandle />
  </Shell.Sidebar>
  <Shell.Viewport />
</Shell.Root>
```

A shell whose sidebar starts where the visitor left it. Note the gutter — it is
not a part of the package, and it is the piece that makes the rest work:

```tsx
<Shell.Root defaultOpen={stored?.open ?? true} onOpenChange={save}>
  <Shell.PeekZone />
  <Gutter />

  <Shell.Sidebar>
    <Shell.Trigger aria-label="Collapse sidebar" />
    <WorkspaceNav />
    <Shell.ResizeHandle aria-label="Resize sidebar" />
  </Shell.Sidebar>

  <Shell.Viewport>{children}</Shell.Viewport>
</Shell.Root>
```

## Layout: take the sidebar out of flow

This is the arrangement everything else depends on, and the one that is easy to
get wrong. The sidebar is **positioned**, not a flex item, and a plain spacer —
a gutter — holds its place in the layout:

```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 handle writes. */
[data-slot="shell-gutter"] {
  width: var(--shell-sidebar-width, 240px);
  flex-shrink: 0;
  transition: width 150ms linear;
}

[data-shell][data-state="collapsed"] [data-slot="shell-gutter"] {
  width: 0;
}
```

That split is what lets all three states be one element morphing between three
positions — flush while expanded, off-canvas while collapsed, floating just
inside the edge while peeking. A sidebar left in flow can only animate its own
width, so it can never float *over* the content, and the peek has nothing to
slide across.

It also means the collapse animates two cheap properties on two different
elements — `left` on the panel, `width` on the gutter — rather than fighting
one element to do both.

## Sizing

The drag range is `min-width` and `max-width` on the sidebar — that pair *is*
the whole configuration, and there is no prop for it:

```css
[data-shell-sidebar] {
  min-width: 200px;
  max-width: 380px;
}
```

The handle writes `--shell-sidebar-width` on the root, the browser clamps it
against those bounds, and the sidebar reports back whatever the browser settled
on — which is the number that gets persisted and announced as `aria-valuenow`.
Measure in JS, size in CSS; nothing here second-guesses your stylesheet.

Publishing the variable on the root rather than the sidebar is what lets the
gutter read the same number, and anything else in the shell size itself to
match.

## Hover-peek

With the sidebar collapsed, resting the pointer in the edge strip floats it out
as a card; leaving both the card and the strip slides it away after a grace
period. Both delays are intent filters — a pointer merely crossing the strip
never opens it, and clipping a corner on the way to the panel never loses it.
Collapse the demo above and hover its left edge to see it.

The card geometry is worth baking into the *whole* collapsed state rather than
into `data-peek` alone:

```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-peek] {
  left: 0.5rem;
}
```

Off-canvas the card is invisible anyway, so the peek animates `left` only — no
vertical movement, and the inset never grows mid-slide. Only expand and
collapse morph card ↔ flat.

Peek is never persisted, and it only means anything while collapsed. Clicking
the trigger inside a peeked sidebar pins it open rather than closing it.

## Keyboard

There is no global key binding in the package, and that is deliberate: it
cannot know which combinations your app has already spent, and a window-level
`keydown` claimed by a library is a collision waiting to happen. `toggle` is all
a binding needs — it pins a peeking sidebar open rather than closing it:

```tsx
const SidebarShortcut = () => {
  const toggle = useShell((shell) => shell.toggle);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "b" || !(event.metaKey || event.ctrlKey)) return;
      event.preventDefault();
      toggle();
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [toggle]);

  return null;
};
```

`Shell.ResizeHandle` does own its keys, because those are local rather than
global: with the handle focused, the arrow keys nudge the width by `step`.

## useShell

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

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

export const hookMembers = [
  { name: "open", type: "boolean", description: "Whether the sidebar is expanded." },
  { name: "peek", 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 peeking sidebar expands in place rather than closing. This is what an app's keyboard shortcut calls." },
  { name: "setPeek", type: "(peek: 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} />

To drive the same state from outside the tree — a command palette, a global
shortcut handler — create the store yourself and pass it in:

```tsx
const store = Shell.createStore();

<Shell.Root store={store}>{/* … */}</Shell.Root>;

// Anywhere, including outside the tree:
const open = useShellStore(store, (shell) => shell.open);
```

There is deliberately no global fallback, so shell state is never read or
driven by accident from somewhere that merely imported this.

## Persistence

`Shell` persists nothing and knows nothing about where you keep things. `open`
goes **out** through `onOpenChange` and comes back **in** as `defaultOpen`.
Format, key, cookie flags, per-browser or per-user account — all yours, and all
changeable without waiting on a release here.

The one thing worth stating plainly: the value has to arrive as a prop. Reading
storage at init is a client-only act, so a server-rendered shell would paint the
default layout and snap to the stored one a frame later — the flash the whole
arrangement exists to avoid.

```tsx
// app/layout.tsx — a server component
const stored = readSidebarLayout((await cookies()).toString());

return <AppShell defaultOpen={stored?.open ?? true} width={stored?.width} />;
```

```tsx
// Writing is a client-only act, so it goes in the callback.
<Shell.Root defaultOpen={defaultOpen} onOpenChange={(open) => save({ open })}>
```

### Width goes back as CSS, not as a prop

There is deliberately no `defaultWidth`. The width already lives in your
stylesheet — the resize handle writes `--shell-sidebar-width` and your rule
reads it — so restoring one means setting that property, which is the same
mechanism rather than a second one:

```tsx
<Shell.Root
  style={stored?.width ? { "--shell-sidebar-width": `${stored.width}px` } : undefined}
>
```

Coming the other way, `Shell.Sidebar`'s `onResize` reports a **settled**
width — once when a drag ends, not once per frame. That debounce is the one
piece of persistence machinery worth having in the package, because it depends
on knowing a drag is in progress. Everything else is yours:

```tsx
<Shell.Sidebar onResize={(width) => save({ width })} />
```

A cookie is worth choosing over `localStorage` for exactly one reason: it is
readable from the request, which is what makes the server read above possible.
And validate on the way in — stored state outlives the code that wrote it, so a
value from an older release or a half-written entry should fall back to the
defaults rather than reach your tree.

## Accessibility

`Shell.Trigger` is a `<button>` carrying `aria-expanded` and `aria-controls`
pointing at the sidebar it governs, so the collapsed/expanded state and the
relationship are both announced. `Shell.ResizeHandle` is a focusable
`role="separator"` with `aria-orientation`, `aria-valuenow`, `aria-valuemin`
and `aria-valuemax`, and it responds to the arrow keys — so resizing is never
mouse-only. It always carries a value, falling back to the minimum before the
first measurement lands, because a focusable separator without one is an
invalid widget.

`Shell.PeekZone` is `aria-hidden`: it is a pointer affordance with no keyboard
or screen-reader meaning, and the trigger already covers both.

## API reference

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

Everything on the Root is shell-wide state, and it has to be: the store is
created in the Root's initialiser, and every part reads it in the same commit —
so a `defaultOpen` arriving from a child would guarantee the flash this design
exists to avoid. Anything belonging to one part lives on that part.

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-peek", 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 resize handle, on the root rather than the sidebar so anything in the shell can size itself to match." },
];

<AttributesTable rows={rootAttrs} />

### Shell.Sidebar

The panel. Renders `data-shell-sidebar` and reports its measured width back to
the store. Carries the `id` the trigger's `aria-controls` points at.

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-peek", 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. Renders `data-shell-viewport`, and carries
the same state attributes so it can react to the sidebar without a group
selector.

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-peek", description: "Present while the sidebar is floating out." },
  { attribute: "data-resizing", description: "Present mid-drag." },
];

<AttributesTable rows={viewportAttrs} />

### Shell.Trigger

Toggles the sidebar. Renders a `<button>` with `data-shell-trigger`. Ships no
copy — supply the label as children.

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-peek", description: "Present while the sidebar is floating out." },
];

<AttributesTable rows={triggerAttrs} />

### Shell.ResizeHandle

The drag affordance. Renders `data-shell-resize-handle` as a focusable
`role="separator"`. Give it a width and a cursor in CSS.

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

<PropsTable rows={handleProps} />

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

<AttributesTable rows={handleAttrs} />

### Shell.PeekZone

The strip along the screen edge that floats a collapsed sidebar out on hover.
Renders `data-shell-peek-zone`, `aria-hidden`. Give it a width and a position
in CSS. Omitting the part is how you opt out of peek — there is no prop for it,
because not rendering it already says so.

export const peekAttrs = [
  { attribute: "data-shell-peek-zone", 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-peek", description: "Present while the sidebar is floating out." },
];

<AttributesTable rows={peekAttrs} />
