# Side panel (Notchset): prompt.md (v2.1.0)

- id: `side-panel` · version 2.1.0 · component · free
- category: Overlays
- build: one set of files for every shadcn style
- install: `npx shadcn@latest add https://notchset.dev/r/side-panel.json`
- npm dependencies: @base-ui/react@^1, lucide-react@^1
- registry dependencies: utils, https://notchset.dev/r/notchset-foundation.json, @notchset/breadcrumb, @notchset/icon-button
- docs: https://notchset.dev/components/side-panel
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

A record opened beside the list it came from. It isn't modal: the list stays usable and another row swaps the record. J and K step through the list; Esc closes. On a phone it takes the screen above the tab bar..

## Build it
- Stack: React 18.3 or 19, TypeScript, Tailwind CSS v4 utility classes on the foundation's roles, a components.json (imports use its aliases).
- Packages: `@base-ui/react@^1`, `lucide-react@^1`.
- Files: `components/ui/notchset/side-panel.tsx`; shared code: `lib/notchset/portal.ts`, `lib/notchset/return-focus.ts`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `notchset-foundation`, `breadcrumb`, `icon-button`.
- Builds: one set of files, the same for every shadcn style.
- Exports to keep: `SidePanel`, and every exported type.
- CSS: the install adds the notchset foundation (its roles, keyframes and motion switch, reading your shadcn tokens for colour) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- This item reads `--ns-ease`, `--ns-ease-panel`, `--ns-ink`, `--ns-ink-3`, `--ns-line`, `--ns-panel-w`, `--ns-sheet`, `--ns-text-small`, `--ns-text-strong`, `--ns-time-panel`, `--ns-weight-medium`, `--ns-weight-strong`.
- Retune it (on any ancestor) with the foundation's roles and hooks: `--ns-bad`, `--ns-bad-ink`, `--ns-band`, `--ns-band-edge`, `--ns-band-grade`, `--ns-band-optimal`, `--ns-beat`, `--ns-bezel`, `--ns-canvas`, `--ns-cat-1`, `--ns-cat-2`, `--ns-cat-3`, `--ns-cat-4`, `--ns-cat-5`, `--ns-cat-6`, `--ns-check`, `--ns-chip`, `--ns-control`, `--ns-control-pad`, `--ns-control-sm`, `--ns-deep`, `--ns-deep-ink`, `--ns-deep-ink-2`, `--ns-deep-line`, `--ns-dialog`, `--ns-dialog-lg`, `--ns-dialog-sm`, `--ns-ease`, `--ns-ease-in`, `--ns-ease-panel`, `--ns-ease-pop`, `--ns-field`, `--ns-flood-arc`, `--ns-flood-aurora`, `--ns-flood-behind-1`, `--ns-flood-behind-2`, `--ns-flood-bloom`, `--ns-flood-deep`, `--ns-flood-dial-bloom`, `--ns-flood-glass`, `--ns-flood-grain`, `--ns-flood-image`, `--ns-flood-ink`, `--ns-flood-ink-low`, `--ns-flood-light`, `--ns-flood-mid`, `--ns-flood-pale`, `--ns-flood-pane`, `--ns-flood-pane-edge`, `--ns-flood-pill-ink`, `--ns-flood-ribbon`, `--ns-flood-ribbon-core`, `--ns-flood-shadow`, `--ns-flood-sheen`, `--ns-flood-wash`, `--ns-font-body`, `--ns-font-display`, `--ns-font-mono`, `--ns-font-script`, `--ns-glass`, `--ns-glass-blur`, `--ns-glass-shadow`, `--ns-glow`, `--ns-grain`, `--ns-gutter`, `--ns-header`, `--ns-heat`, `--ns-hover`, `--ns-ink`, `--ns-ink-2`, `--ns-ink-3`, `--ns-ink-4`, `--ns-leading`, `--ns-lift`, `--ns-light-1`, `--ns-light-2`, `--ns-light-3`, `--ns-light-spread`, `--ns-light-strength`, `--ns-line`, `--ns-line-soft`, `--ns-lit-edge`, `--ns-menu-dy`, `--ns-menu-row`, `--ns-mesh`, `--ns-mesh-frost`, `--ns-ok`, `--ns-ok-ink`, `--ns-orb-body`, `--ns-orb-cool`, `--ns-orb-core`, `--ns-orb-dawn`, `--ns-orb-deep`, `--ns-orb-edge`, `--ns-orb-grain`, `--ns-orb-ground`, `--ns-orb-halo`, `--ns-orb-inner`, `--ns-orb-key`, `--ns-orb-lift`, `--ns-orb-limb`, `--ns-orb-limb-from`, `--ns-orb-rim`, `--ns-orb-skin-hi`, `--ns-orb-skin-lo`, `--ns-orb-skin-mid`, `--ns-orb-warm`, `--ns-panel`, `--ns-panel-w`, `--ns-prism-edge`, `--ns-prism-glint`, `--ns-prism-sheen`, `--ns-prism-tint`, `--ns-pulse`, `--ns-radius`, `--ns-radius-card`, `--ns-radius-full`, `--ns-radius-lg`, `--ns-radius-sm`, `--ns-radius-xl`, `--ns-raised`, `--ns-range`, `--ns-row`, `--ns-row-compact`, `--ns-segment`, `--ns-selected`, `--ns-series`, `--ns-series-core`, `--ns-series-lilac`, `--ns-series-tint`, `--ns-shadow-dialog`, `--ns-shadow-float`, `--ns-shadow-panel`, `--ns-sheet`, `--ns-sheet-bottom`, `--ns-side`, `--ns-signal`, `--ns-sleep-awake`, `--ns-sleep-deep`, `--ns-sleep-light`, `--ns-sleep-rem`, `--ns-space-1`, `--ns-space-2`, `--ns-space-3`, `--ns-space-4`, `--ns-space-5`, `--ns-space-6`, `--ns-space-8`, `--ns-space-section`, `--ns-stack-on`, `--ns-standing-critical`, `--ns-standing-in`, `--ns-standing-out`, `--ns-state-doing`, `--ns-state-done`, `--ns-state-review`, `--ns-state-todo`, `--ns-switch-h`, `--ns-switch-w`, `--ns-text-body`, `--ns-text-control`, `--ns-text-display`, `--ns-text-figure`, `--ns-text-headline`, `--ns-text-hero`, `--ns-text-lede`, `--ns-text-meta`, `--ns-text-page`, `--ns-text-record`, `--ns-text-small`, `--ns-text-strong`, `--ns-text-sub`, `--ns-text-title`, `--ns-tile-shadow`, `--ns-time-born`, `--ns-time-color`, `--ns-time-dialog`, `--ns-time-menu`, `--ns-time-page`, `--ns-time-panel`, `--ns-time-toast`, `--ns-tint`, `--ns-tint-ink`, `--ns-toast-bottom`, `--ns-toast-left`, `--ns-touch`, `--ns-tracking-display`, `--ns-tracking-headline`, `--ns-warn`, `--ns-warn-ink`, `--ns-wash-clay`, `--ns-wash-dusk`, `--ns-wash-graphite`, `--ns-wash-ink`, `--ns-wash-sage`, `--ns-wash-sand`, `--ns-weight-light`, `--ns-weight-medium`, `--ns-weight-regular`, `--ns-weight-strong`, `--ns-well`.
- Never add Tailwind colour classes inside the component.

```tsx
import { Button } from "@/components/ui/notchset/button";
```

## Parts
- A single export that takes props.

## Match the original
- Read `components/ui/notchset/side-panel.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when

### Not when

## Built from
- breadcrumb (`breadcrumb`), installed with it: reuse it, don't rebuild it
- icon-button (`icon-button`), installed with it: reuse it, don't rebuild it
- Portal (shared helper, ships with it)
- Focus return (shared helper, ships with it)

## Mistakes
- Recreating the component instead of installing it

## Usage

```tsx
"use client";
import * as React from "react";
import { Building2 } from "lucide-react";
import { Button } from "@/components/ui/notchset/button";
import { CompanyMark } from "@/components/ui/notchset/company-mark";
import { SidePanel } from "@/components/ui/notchset/side-panel";

/* the companies' own marks, 24×24 paths as the CRM seed draws them */
const GLYPH: Record<string, string> = {
  airtable: "M11.992 1.966c-.434 0-.87.086-1.28.257L1.779 5.917c-.503.208-.49.908.012 1.116l8.982 3.558a3.266 3.266 0 0 0 2.454 0l8.982-3.558c.503-.196.503-.908.012-1.116l-8.957-3.694a3.255 3.255 0 0 0-1.272-.257zM23.4 8.056a.589.589 0 0 0-.222.045l-10.012 3.877a.612.612 0 0 0-.38.564v8.896a.6.6 0 0 0 .821.552L23.62 18.1a.583.583 0 0 0 .38-.551V8.653a.6.6 0 0 0-.6-.596zM.676 8.095a.644.644 0 0 0-.48.19C.086 8.396 0 8.53 0 8.69v8.355c0 .442.515.737.908.54l6.27-3.006.307-.147 2.969-1.436c.466-.22.43-.908-.061-1.092L.883 8.138a.57.57 0 0 0-.207-.044z",
  stripe: "M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z",
  revolut: "M20.9133 6.9566C20.9133 3.1208 17.7898 0 13.9503 0H2.424v3.8605h10.9782c1.7376 0 3.177 1.3651 3.2087 3.043.016.84-.2994 1.633-.8878 2.2324-.5886.5998-1.375.9303-2.2144.9303H9.2322a.2756.2756 0 0 0-.2755.2752v3.431c0 .0585.018.1142.052.1612L16.2646 24h5.3114l-7.2727-10.094c3.6625-.1838 6.61-3.2612 6.61-6.9494zM6.8943 5.9229H2.424V24h4.4704z",
};

const DEALS = [
  { name: "Airtable", value: "$48,000", close: "Nov 7", color: "#18bfff" },
  { name: "Stripe", value: "$152,000", close: "Dec 12", color: "#635bff" },
  { name: "Revolut", value: "$97,500", close: "Nov 14", color: "#191c1f" },
];

export default function SidePanelDemo() {
  const [at, setAt] = React.useState<number | null>(null);
  const d = DEALS[at ?? 0]!;
  return (
    <>
      <Button onClick={() => setAt(0)}>
        <Building2 />
        Open Airtable
      </Button>
      <SidePanel
        open={at !== null}
        onOpenChange={(o) => !o && setAt(null)}
        kind="Company"
        title={d.name}
        position={`${(at ?? 0) + 1} of ${DEALS.length}`}
        onPrevious={at ? () => setAt(at - 1) : undefined}
        onNext={at !== null && at < DEALS.length - 1 ? () => setAt(at + 1) : undefined}
      >
        <div className="flex items-center gap-3">
          <CompanyMark name={d.name} color={d.color} glyph={GLYPH[d.name.toLowerCase()]} size={40} />
          <div className="flex flex-col gap-1">
            <b className="text-[length:var(--ns-text-title)] font-[number:var(--ns-weight-strong)]">{d.name}</b>
            <span className="text-[var(--ns-ink-3)]">
              {d.value} · closes {d.close}
            </span>
          </div>
        </div>
        <p className="m-0 text-[var(--ns-ink-3)]">Press J or K to step through the list without closing the panel.</p>
      </SidePanel>
    </>
  );
}
```

This example also uses `company-mark` (https://notchset.dev/components/company-mark). Installing this item doesn't bring them; to paste the example as it is: `npx shadcn@latest add https://notchset.dev/r/side-panel.json https://notchset.dev/r/company-mark.json`.

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `open` | `boolean` |  | Required. |
| `onOpenChange` | `(open: boolean) => void` |  | Required. |
| `title` | `React.ReactNode` |  | Required. |
| `kind` | `string` |  | A quiet word before the title ("Company", "Task"). |
| `position` | `string` |  | Where the record sits in the list on screen ("3 of 12"); quiet, after the kind, not on a phone. |
| `onPrevious` | `() => void` |  |  |
| `onNext` | `() => void` |  |  |
| `back` | `{ label: string; onBack: () => void }` |  | Shown after following a link inside the panel: where Back goes. Hides the list arrows. |
| `actions` | `React.ReactNode` |  |  |
| `width` | `string` |  | A wider panel for a record that is written in (a task with its thread): a CSS length, "600px". |

Full docs: https://notchset.dev/components/side-panel

## Customising
- Colours: every part reads the foundation's roles (`--ns-canvas`, `--ns-ink`, `--ns-line`, `--ns-signal` …), which read your shadcn tokens (`--background`, `--foreground`, `--border`, `--destructive` …), plus the optional `--success` and `--warning`. Retheme on `:root` or any container; never add Tailwind colour classes inside the component.
- Dark mode follows the `.dark` class or `data-theme="dark"` on an ancestor (the shadcn and next-themes conventions).
- Update later: Commit or stash local changes first (or copy the files), so the update can be undone. Read https://notchset.dev/r/changelog.json for the new versions: a major version, an entry marked breaking or a merge says what to change. Then re-run the install with `--overwrite`, on a branch. It replaces the component's files, the shared helper files they import, and the item's CSS rules in the global stylesheet, so review the whole diff (components, shared files, CSS), re-apply your own edits, and run the app's typecheck, tests and build.

## Accessibility contract (preserve when editing)

## Install

```bash
npx shadcn@latest add https://notchset.dev/r/side-panel.json
```

Or add the `@notchset` registry to components.json and run `npx shadcn@latest add @notchset/side-panel`. Every components.json style gets the same files.

## Continue the workflow

- [Data table](https://notchset.dev/blocks/data-table)
- [View tabs](https://notchset.dev/blocks/view-tabs)
- [Sortable header](https://notchset.dev/components/sortable-header)

## Source

### components/ui/notchset/side-panel.tsx

```tsx
// Notchset side-panel 2.1.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";
import { Dialog as Base } from "@base-ui/react/dialog";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useReturnFocus } from "@/lib/notchset/return-focus";
import { BackButton } from "@/components/ui/notchset/breadcrumb";
import { IconButton } from "@/components/ui/notchset/icon-button";
import { usePortalContainer } from "@/lib/notchset/portal";

/*
 * Side panel: a record opened beside the list it came from, so you can work through a list without losing
 * your place. It is not modal: the list stays usable, and opening another row swaps the panel's record. J and
 * K (or the arrows in its header) step to the next and previous record of the list on screen; Esc closes the
 * whole panel. A mark in the Signature at its top edge ties it to the row it shows. On a phone it takes the whole screen
 * above the tab bar: the header keeps only the kind and the actions (the body opens with the record's name, so
 * nothing is cut short), and the position and arrows go, since the list isn't on screen.
 *
 * Pass `onPrevious`/`onNext` and `position` ("3 of 12") only when the record was opened from a list on screen.
 *
 * Following a link inside the panel (a person's deal) pushes: pass `back` and the header shows "‹ Elena Petrova"
 * instead of the arrows (the record shown is not part of the list on screen). Alt+← and ⌘[ go back too.
 */
type SidePanelProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: React.ReactNode;
  /** A quiet word before the title ("Company", "Task"). */
  kind?: string | undefined;
  /** Where the record sits in the list on screen ("3 of 12"); quiet, after the kind, not on a phone. */
  position?: string | undefined;
  onPrevious?: (() => void) | undefined;
  onNext?: (() => void) | undefined;
  /** Shown after following a link inside the panel: where Back goes. Hides the list arrows. */
  back?: { label: string; onBack: () => void } | undefined;
  actions?: React.ReactNode;
  /** A wider panel for a record that is written in (a task with its thread): a CSS length, "600px". */
  width?: string | undefined;
  children: React.ReactNode;
  className?: string | undefined;
};

function SidePanel({ open, onOpenChange, title, kind, position, onPrevious, onNext, back, actions, width, children, className }: SidePanelProps) {
  const portalContainer = usePortalContainer();
  const returnFocus = useReturnFocus();
  React.useEffect(() => {
    if (!open || (!onNext && !onPrevious && !back)) return;
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement;
      if (t.closest("input,textarea,select,[contenteditable=true],[role=combobox]")) return;
      if (back && ((e.altKey && e.key === "ArrowLeft") || (e.metaKey && e.key === "["))) {
        e.preventDefault();
        back.onBack();
        return;
      }
      /* in a followed record the list arrows are hidden, so J/K do nothing either; Back is the way out */
      if (e.metaKey || e.ctrlKey || e.altKey || back) return;
      const step = e.key === "j" ? onNext : e.key === "k" ? onPrevious : undefined;
      if (!step) return;
      e.preventDefault();
      step();
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, onNext, onPrevious, back]);

  return (
    <Base.Root open={open} onOpenChange={(o) => onOpenChange(o)} modal={false} disablePointerDismissal>
      <Base.Portal container={portalContainer}>
        <Base.Popup
          data-slot="side-panel"
          data-ns-layer=""
          finalFocus={returnFocus}
          data-ns-moves=""
          style={width ? ({ "--ns-panel-w": width } as React.CSSProperties) : undefined}
          className={cn(
            "fixed top-0 right-0 bottom-0 z-20 flex w-[min(var(--ns-panel-w),100vw)] flex-col bg-[var(--ns-sheet)] text-[var(--ns-ink)] shadow-[-24px_0_48px_-24px_rgba(0,0,0,.28),-1px_0_0_var(--ns-line)] outline-hidden",
            "[animation:ns-panel-in_var(--ns-time-panel)_var(--ns-ease-panel)] data-ending-style:opacity-0 data-ending-style:[transition:opacity_180ms_var(--ns-ease)] max-[600px]:w-full",
            className,
          )}
        >
          <div className="flex h-[52px] shrink-0 items-center gap-2 border-b border-[var(--ns-line)] pr-3 pl-4">
            {back && <BackButton label={back.label} onClick={back.onBack} className="ml-[-8px] shrink-0" />}
            <Base.Title className="m-0 flex min-w-0 flex-1 items-baseline gap-2 truncate text-[length:var(--ns-text-strong)] font-[number:var(--ns-weight-strong)]">
              {kind && <span className="shrink-0 text-[length:var(--ns-text-small)] font-[number:var(--ns-weight-medium)] text-[var(--ns-ink-3)]">{kind}</span>}
              {position && <span className="shrink-0 text-[length:var(--ns-text-small)] text-[var(--ns-ink-3)] tabular-nums max-[600px]:hidden">{position}</span>}
              <span className={cn("truncate", kind && "max-[600px]:sr-only")}>{title}</span>
            </Base.Title>
            {actions}
            {!back && onPrevious && (
              <IconButton label="Previous (K)" size="sm" variant="ghost" onClick={onPrevious} className="max-[600px]:hidden">
                <ChevronUp />
              </IconButton>
            )}
            {!back && onNext && (
              <IconButton label="Next (J)" size="sm" variant="ghost" onClick={onNext} className="max-[600px]:hidden">
                <ChevronDown />
              </IconButton>
            )}
            <Base.Close render={<IconButton label="Close (Esc)" size="sm" variant="ghost"><X /></IconButton>} />
          </div>
          <div className="flex min-h-0 flex-1 flex-col gap-5 overflow-auto overscroll-contain px-[18px] pt-5 pb-8 max-[600px]:pb-[calc(96px+env(safe-area-inset-bottom,0px))]">{children}</div>
        </Base.Popup>
      </Base.Portal>
    </Base.Root>
  );
}

export { SidePanel, type SidePanelProps };
```

### lib/notchset/portal.ts

```tsx
// Notchset side-panel 2.1.0 · https://notchset.dev/changelog
import * as React from "react";

/*
 * Where overlays (dialogs, menus, popovers, tooltips, toasts, sheets) portal to. By default the document body; a
 * host that themes or scopes a region (a preview stage, a shadow root, a tile in another theme) wraps it in
 * PortalContainer with an element inside that scope, so its overlays keep the region's roles and light or dark.
 */
const PortalContainerContext = React.createContext<HTMLElement | null>(null);

const PortalContainer = PortalContainerContext.Provider;

/** The element overlays portal into here, or undefined for the document body. */
function usePortalContainer(): HTMLElement | undefined {
  return React.useContext(PortalContainerContext) ?? undefined;
}

export { PortalContainer, PortalContainerContext, usePortalContainer };
```

### lib/notchset/return-focus.ts

```tsx
// Notchset side-panel 2.1.0 · https://notchset.dev/changelog
"use client";
import * as React from "react";

/*
 * Focus goes back to whatever opened a layer when it closes, even when the layer is opened from state (a menu
 * item, a shortcut, a row) rather than its own trigger. One document listener remembers the last control
 * focused or pressed outside any layer (marked data-ns-layer); pressing counts too because Safari does not focus
 * a clicked button. Pass the result to a Base UI Popup's finalFocus.
 */
let last: HTMLElement | null = null;
let watching = false;

function watch() {
  if (watching || typeof document === "undefined") return;
  watching = true;
  const note = (e: Event) => {
    const t = e.target as HTMLElement | null;
    if (!t?.closest || t.closest("[data-ns-layer]")) return;
    /* a table row that opens on press is its own opener: it takes focus back (as a J/K row does), so closing
       what it opened never leaves focus on the page's body, where the next Tab can leave the app */
    const row = t.closest<HTMLElement>("tr[data-row]");
    const ctl = t.closest<HTMLElement>("button,a[href],input,select,textarea,[tabindex]");
    if (row && (!ctl || !row.contains(ctl)) && !row.hasAttribute("tabindex")) row.tabIndex = -1;
    last = (row && (!ctl || !row.contains(ctl)) ? row : ctl) ?? last;
  };
  document.addEventListener("focusin", note, true);
  document.addEventListener("pointerdown", note, true);
}

export function useReturnFocus() {
  React.useEffect(watch, []);
  return React.useCallback(() => (last && last.isConnected ? last : true), []);
}
```

The foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://notchset.dev/r/notchset-foundation.json.
