---
title: "Panels & dialogs — React"
description: "Keep track of which sidebars, dialogs and menus are open, open them from commands, and restore them next time."
framework: "React"
source: "https://www.embedpdf.com/docs/headless/react/ui/panels"
---

# Panels & dialogs

A viewer has sidebars (thumbnails, comments, search), dialogs and menus. The shell plugin keeps track of
which are open, so a toolbar button, a keyboard shortcut and your own code can all open the same
panel, and you can remember the layout for next time. You draw the panels; the plugin only holds their
state. Register `shellPlugin()`:

**`shell-plugin.tsx`**

```tsx
import { shellPlugin } from '@embedpdf/react/shell';

export const plugins = [/* … */ shellPlugin()];
```

Then ask for a panel by name:

**`comments-panel.tsx`**

```tsx
import { useSurface } from '@embedpdf/react/shell';
import { CommentList } from './comment-list';

export function CommentsPanel() {
  const panel = useSurface('comments');
  if (!panel.isOpen) return null;

  return (
    <aside>
      <button onClick={panel.close}>Close</button>
      <CommentList />
    </aside>
  );
}

export function CommentsButton() {
  const panel = useSurface('comments');
  return <button onClick={() => panel.toggle({ exclusive: 'right' })}>Comments</button>;
}
```

The names are yours: `'comments'`, `'thumbnails'`, `'settings'`. Each document keeps its own, so every
tab remembers which panels were open.

## One panel per side

Most layouts show one panel per side. Give panels a group with `exclusive`, and opening one closes the
others in that group:

```ts
shell.open('thumbnails', { exclusive: 'left' });
shell.open('outline', { exclusive: 'left' }); // closes thumbnails
shell.open('comments', { exclusive: 'right' }); // the left panel stays
```

**`sidebars.tsx`**

```tsx
import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { shellPlugin, useShell, useSurface } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './sidebars.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// Both panels share the right side: opening one closes the other.
function PanelButton({ id, label }: { id: string; label: string }) {
  const panel = useSurface(id);
  return (
    <button
      type="button"
      className="button"
      aria-pressed={panel.isOpen}
      onClick={() => panel.toggle({ exclusive: 'right' })}
    >
      {label}
    </button>
  );
}

function SearchPanel() {
  const panel = useSurface('search');
  const search = useSearch();
  const hits = useSearchHits();
  const [text, setText] = useState('PDF');

  useEffect(() => {
    void search.search({ text });
  }, [search, text]);

  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Search">
      <header className="panel-header">
        <h3 className="panel-title">Search</h3>
        <button type="button" className="close" aria-label="Close" onClick={panel.close}>
          ×
        </button>
      </header>
      <input
        className="field"
        type="search"
        aria-label="Search"
        value={text}
        onChange={(event) => setText(event.target.value)}
      />
      <ol className="list">
        {hits.map((hit) => (
          <li key={`${hit.page.objectNumber}:${hit.start}`}>
            <button type="button" className="item" onClick={() => search.goToHit(hit)}>
              <span className="item-page">Page {hit.pageIndex + 1}</span>
              {hit.snippet && (
                <span className="item-text">
                  …{hit.snippet.before}
                  <mark>{hit.snippet.match}</mark>
                  {hit.snippet.after}…
                </span>
              )}
            </button>
          </li>
        ))}
      </ol>
    </aside>
  );
}

function NotesPanel({ notes, onChange }: { notes: string; onChange: (notes: string) => void }) {
  const panel = useSurface('notes');
  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Notes">
      <header className="panel-header">
        <h3 className="panel-title">Notes</h3>
        <button type="button" className="close" aria-label="Close" onClick={panel.close}>
          ×
        </button>
      </header>
      <textarea
        className="field notes"
        aria-label="Notes"
        placeholder="Your notes on this document…"
        value={notes}
        onChange={(event) => onChange(event.target.value)}
      />
    </aside>
  );
}

function Workspace() {
  const shell = useShell();
  // Your app's own data: it stays when the panel closes.
  const [notes, setNotes] = useState('');

  // The search panel is open when the document is.
  useEffect(() => {
    shell.open('search', { exclusive: 'right' });
  }, [shell]);

  return (
    <>
      <div className="toolbar">
        <PanelButton id="search" label="Search" />
        <PanelButton id="notes" label="Notes" />
      </div>
      <div className="workspace">
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <SearchLayer />
            </>
          )}
        </Stage>
        <SearchPanel />
        <NotesPanel notes={notes} onChange={setNotes} />
      </div>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}
```

**`sidebars.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --mark: #ffe36e;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The panel buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
  border-color: var(--line-strong);
}

.button:focus-visible,
.close:focus-visible,
.item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The pages and the open panel: stacked, and side by side where there's room */
.workspace {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.panel {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-height: 280px;
  padding: 12px;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .panel {
    flex: 0 0 250px;
    max-height: 420px;
  }
}

/* Inside a panel */
.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.panel-title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  color: var(--ink);
}

.close {
  width: 28px;
  height: 28px;
  font: inherit;
  font-size: 18px;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.close:hover {
  color: var(--ink);
  background: var(--tint);
}

.field {
  box-sizing: border-box;
  width: 100%;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 10px;
}

.field:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

.notes {
  flex: 1;
  min-height: 140px;
  resize: none;
}

.list {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  margin: 0;
  padding: 0;
  list-style: none;
}

.item {
  display: flex;
  flex-direction: column;
  gap: 3px;
  width: 100%;
  font: inherit;
  text-align: left;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 9px;
  padding: 8px;
  cursor: pointer;
}

.item:hover {
  background: var(--tint);
}

.item-page {
  font-size: 11px;
  font-weight: 650;
  color: var(--muted);
}

.item-text {
  font-size: 13px;
  line-height: 1.4;
}

.item-text mark {
  background: var(--mark);
  color: inherit;
  border-radius: 2px;
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Open a panel with something in it

A panel can carry data, such as which comment to scroll to:

```ts
shell.open('comments', { exclusive: 'right', props: { focus: thread.root.ref } });
```

The panel reads them:

**`panel-props.tsx`**

```tsx
import { useSurface } from '@embedpdf/react/shell';
import { CommentList } from './comment-list';

export function CommentsPanel() {
  const { props } = useSurface('comments'); // { focus: … }
  return <CommentList focus={props.focus} />;
}
```

`shell.updateSurfaceProps('comments', { focus: nextThread.root.ref })` changes them without reopening the
panel.

**`props.tsx`**

```tsx
import { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { SearchLayer, searchPlugin, useSearch, useSearchHits } from '@embedpdf/react/search';
import { shellPlugin, useShell, useSurface } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './props.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), searchPlugin(), shellPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The panel shows the match its props name, and steps to the next one without reopening.
function MatchPanel() {
  const panel = useSurface('match');
  const shell = useShell();
  const search = useSearch();
  const hits = useSearchHits();
  const index = typeof panel.props.index === 'number' ? panel.props.index : 0;
  const hit = hits[index];

  if (!panel.isOpen) return null;

  const show = (next: number) => {
    shell.updateSurfaceProps('match', { index: next });
    search.goToHit(hits[next]);
  };

  return (
    <aside className="panel" aria-label="Match">
      <header className="panel-header">
        <h3 className="panel-title">
          Match {index + 1} of {hits.length}
        </h3>
        <button type="button" className="close" aria-label="Close" onClick={panel.close}>
          ×
        </button>
      </header>
      {hit ? (
        <>
          <p className="page">Page {hit.pageIndex + 1}</p>
          {hit.snippet && (
            <p className="snippet">
              …{hit.snippet.before}
              <mark>{hit.snippet.match}</mark>
              {hit.snippet.after}…
            </p>
          )}
        </>
      ) : (
        <p className="page">Searching…</p>
      )}
      <div className="steps">
        <button
          type="button"
          className="button"
          disabled={index === 0}
          onClick={() => show(index - 1)}
        >
          ← Previous
        </button>
        <button
          type="button"
          className="button"
          disabled={index >= hits.length - 1}
          onClick={() => show(index + 1)}
        >
          Next →
        </button>
      </div>
    </aside>
  );
}

function Workspace() {
  const shell = useShell();
  const search = useSearch();
  const hits = useSearchHits();

  // Every "PDF" on the pages, and the first one open in the panel.
  useEffect(() => {
    void search.search({ text: 'PDF' });
    shell.open('match', { exclusive: 'right', props: { index: 0 } });
  }, [search, shell]);

  return (
    <div className="workspace">
      <Stage className="stage">
        {() => (
          <>
            <RenderLayer />
            {/* A click on a match opens it in the panel. */}
            <SearchLayer
              onHitClick={(hit) =>
                shell.open('match', { exclusive: 'right', props: { index: hits.indexOf(hit) } })
              }
            />
          </>
        )}
      </Stage>
      <MatchPanel />
    </div>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}
```

**`props.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --mark: #ffe36e;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The pages and the panel: stacked, and side by side where there's room */
.workspace {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.panel {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .panel {
    flex: 0 0 250px;
  }
}

/* Inside the panel */
.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.panel-title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.close {
  width: 28px;
  height: 28px;
  font: inherit;
  font-size: 18px;
  line-height: 1;
  color: var(--muted);
  background: transparent;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

.close:hover {
  color: var(--ink);
  background: var(--tint);
}

.page {
  margin: 0;
  font-size: 12px;
  font-weight: 650;
  color: var(--muted);
}

.snippet {
  margin: 0;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink);
}

.snippet mark {
  background: var(--mark);
  color: inherit;
  border-radius: 2px;
}

.steps {
  display: flex;
  gap: 8px;
  margin-top: auto;
}

.button {
  flex: 1;
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 10px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.button:focus-visible,
.close:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Put the cursor in a panel

A panel you type in, such as search, can take the cursor as it opens. Ask for it when you open the
panel:

```ts
shell.open('search', { focus: true });
```

and say which element takes it, with `useSurfaceFocus(id, ref)`:

**`panel-focus.tsx`**

```tsx
import { useRef } from 'react';
import { useSurfaceFocus } from '@embedpdf/react/shell';

export function SearchPanel() {
  const field = useRef<HTMLInputElement>(null);
  // The cursor goes in the field when the panel asks for it, its text selected.
  useSurfaceFocus('search', field, { select: true });
  return <input ref={field} placeholder="Search" />;
}
```

The element takes focus on every open that asks, also when the panel is open already (pressing a Find
key again), and when it appears while the request is up, as a panel whose code loads on its first
open does. A plain `open(id)` leaves the cursor where it is. `select: true` selects the
field's text, so typing replaces it.

The request is the panel's `focusRequest` in [State](#state): how many opens asked since the panel
opened, back to 0 when it closes. Snapshots leave it out.

## From commands

A [command](commands) can open a panel with no code: give it `panel`, and its button toggles the panel
and shows as pressed while it's open:

```ts
{ id: 'panel:comments', label: 'Comments', icon: 'comment', panel: { id: 'comments', exclusive: 'right' } }
```

With `focus: true` in `panel`, opening it asks for the cursor, as above. `modal` does the same for a
dialog, and `menu` for a menu.

## Menus

Menus are tracked separately, since several can be open at once, such as a menu and its submenu:

```ts
shell.openMenu('more');
shell.closeAllMenus(); // for a click outside, or Escape
```

`openMenus` in [State](#state) lists the open menus, the last one opened on top.

**`menus.tsx`**

```tsx
import { useEffect, useRef } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  interactionPlugin,
  useInteraction,
  useInteractionState,
} from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { shellPlugin, useShell, useShellState } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './menus.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  selectionPlugin(),
  shellPlugin(),
];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

const TOOLS = [
  { id: 'pointer', label: 'Select text' },
  { id: 'pan', label: 'Scroll with the hand' },
];

// A menu with a submenu: opening the submenu leaves its menu open.
function ViewMenu() {
  const shell = useShell();
  const interaction = useInteraction();
  const { openMenus } = useShellState();
  const { activeToolId } = useInteractionState();
  const bar = useRef<HTMLDivElement>(null);
  const anyOpen = openMenus.length > 0;

  // A press outside the menus, or Escape, closes all of them.
  useEffect(() => {
    if (!anyOpen) return;
    const outside = (event: PointerEvent) => {
      if (!bar.current?.contains(event.target as Node)) shell.closeAllMenus();
    };
    const escape = (event: KeyboardEvent) => {
      if (event.key === 'Escape') shell.closeAllMenus();
    };
    document.addEventListener('pointerdown', outside, true);
    document.addEventListener('keydown', escape);
    return () => {
      document.removeEventListener('pointerdown', outside, true);
      document.removeEventListener('keydown', escape);
    };
  }, [shell, anyOpen]);

  return (
    <div className="toolbar">
      <div className="menubar" ref={bar}>
        <button
          type="button"
          className="button"
          aria-haspopup="menu"
          aria-expanded={openMenus.includes('view')}
          onClick={() => shell.toggleMenu('view')}
        >
          View ▾
        </button>
        {openMenus.includes('view') && (
          <div className="menu" role="menu">
            <button
              type="button"
              role="menuitem"
              className="menu-item"
              aria-haspopup="menu"
              aria-expanded={openMenus.includes('view-tool')}
              onClick={() => shell.toggleMenu('view-tool')}
            >
              Tool <span aria-hidden="true">▸</span>
            </button>
            <button
              type="button"
              role="menuitem"
              className="menu-item"
              onClick={() => shell.closeAllMenus()}
            >
              Close menus
            </button>
            {openMenus.includes('view-tool') && (
              <div className="menu submenu" role="menu">
                {TOOLS.map((tool) => (
                  <button
                    key={tool.id}
                    type="button"
                    role="menuitemradio"
                    aria-checked={tool.id === activeToolId}
                    className="menu-item"
                    onClick={() => {
                      interaction.activateTool(tool.id);
                      shell.closeAllMenus();
                    }}
                  >
                    {tool.label}
                  </button>
                ))}
              </div>
            )}
          </div>
        )}
      </div>
      <output className="readout">Open menus: {anyOpen ? openMenus.join(' › ') : 'none'}</output>
    </div>
  );
}

function Workspace() {
  const shell = useShell();

  // The menu and its submenu are open when the document is.
  useEffect(() => {
    shell.openMenu('view');
    shell.openMenu('view-tool');
  }, [shell]);

  return (
    <>
      <ViewMenu />
      <Stage className="stage">
        {() => (
          <>
            <RenderLayer />
            <SelectionLayer />
          </>
        )}
      </Stage>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}
```

**`menus.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The menu button, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-bottom: 14px;
}

.menubar {
  position: relative;
  z-index: 1;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

.button:hover,
.button[aria-expanded='true'] {
  background: var(--tint);
  border-color: var(--line-strong);
}

.readout {
  font-size: 13px;
  color: var(--muted);
}

/* A menu below its button, and a submenu beside its item */
.menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  display: flex;
  flex-direction: column;
  min-width: 170px;
  padding: 5px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
  box-shadow: 0 12px 32px rgb(7 32 76 / 0.18);
}

.submenu {
  top: 0;
  left: calc(100% + 4px);
}

.menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  font: inherit;
  font-size: 13px;
  text-align: left;
  white-space: nowrap;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-radius: 8px;
  padding: 8px 10px;
  cursor: pointer;
}

.menu-item:hover,
.menu-item[aria-expanded='true'] {
  background: var(--tint);
}

.menu-item[aria-checked='true'] {
  color: var(--accent);
  font-weight: 600;
}

.menu-item[aria-checked='true']::after {
  content: '✓';
}

.button:focus-visible,
.menu-item:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The pages */
.stage {
  height: 420px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--canvas);
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Remember the layout

```ts
localStorage.setItem('panels', JSON.stringify(shell.getSnapshot()));

// next time
shell.applySnapshot(JSON.parse(localStorage.getItem('panels')!));
```

**`remember.tsx`**

```tsx
import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { shellPlugin, useShell, useShellState, useSurface } from '@embedpdf/react/shell';
import type { ShellSnapshot } from '@embedpdf/react/shell';
import { localEngine } from '@embedpdf/engine';

import './remember.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), shellPlugin()];

const ebook = async (): Promise<OpenInput> => {
  const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
  return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};

// The browser may refuse storage, in a private window: then nothing is remembered.
const loadLayout = (): ShellSnapshot | null => {
  try {
    return JSON.parse(localStorage.getItem('panels') ?? 'null') as ShellSnapshot | null;
  } catch {
    return null;
  }
};
const saveLayout = (snapshot: ShellSnapshot) => {
  try {
    localStorage.setItem('panels', JSON.stringify(snapshot));
  } catch {
    // Not remembered this time.
  }
};

const TIPS = [
  'Ctrl or ⌘ and scroll to zoom',
  'Pinch to zoom on a touch screen',
  'Drag between pages to scroll',
];

function PanelButton({ id, label, side }: { id: string; label: string; side: 'left' | 'right' }) {
  const panel = useSurface(id);
  return (
    <button
      type="button"
      className="button"
      aria-pressed={panel.isOpen}
      onClick={() => panel.toggle({ exclusive: side })}
    >
      {label}
    </button>
  );
}

function TipsPanel() {
  const panel = useSurface('tips');
  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Tips">
      <h3 className="panel-title">Tips</h3>
      <ul className="tips">
        {TIPS.map((tip) => (
          <li key={tip}>{tip}</li>
        ))}
      </ul>
    </aside>
  );
}

function NotesPanel() {
  const panel = useSurface('notes');
  if (!panel.isOpen) return null;
  return (
    <aside className="panel" aria-label="Notes">
      <h3 className="panel-title">Notes</h3>
      <textarea className="notes" aria-label="Notes" placeholder="Your notes on this document…" />
    </aside>
  );
}

function Workspace() {
  const shell = useShell();
  const { openSurfaces } = useShellState();
  const [saved, setSaved] = useState(loadLayout);

  // The layout saved last time, or the tips the first time.
  useEffect(() => {
    const layout = loadLayout();
    if (layout) shell.applySnapshot(layout);
    else shell.open('tips', { exclusive: 'left' });
  }, [shell]);

  const save = () => {
    const layout = shell.getSnapshot();
    saveLayout(layout);
    setSaved(layout);
  };

  const open = openSurfaces.map((surface) => surface.id).join(', ');

  return (
    <>
      <div className="toolbar">
        <PanelButton id="tips" label="Tips" side="left" />
        <PanelButton id="notes" label="Notes" side="right" />
        <span className="spacer" />
        <button type="button" className="button" onClick={save}>
          Save layout
        </button>
        <button
          type="button"
          className="button"
          disabled={!saved}
          onClick={() => saved && shell.applySnapshot(saved)}
        >
          Restore
        </button>
      </div>
      <p className="readout">Open: {open || 'no panels'}</p>
      <div className="workspace">
        <TipsPanel />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
        <NotesPanel />
      </div>
    </>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <Workspace />
      </DocumentGate>
    </Viewer>
  );
}
```

**`remember.css`**

```css
/* Colors */
:root {
  --ink: #1a2748;
  --muted: #5a6b92;
  --line: #e6eaf2;
  --line-strong: #dce8fc;
  --surface: #ffffff;
  --tint: #f3f7fe;
  --canvas: #f7f9fc;
  --accent: #054fb3;
  --accent-tint: #e7f0fe;
  --focus: #7db6ff;
  --shadow: rgb(7 32 76 / 0.04);
}

/* The panel buttons and the layout buttons, above the pages */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.spacer {
  flex: 1;
}

.button {
  font: inherit;
  font-size: 13px;
  font-weight: 550;
  line-height: 1;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 9px 14px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    color 0.15s ease;
}

.button:hover {
  background: var(--tint);
  border-color: var(--line-strong);
}

.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--accent-tint);
  border-color: var(--line-strong);
}

.button:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

.readout {
  margin: 10px 0 12px;
  font-size: 13px;
  color: var(--muted);
}

/* A panel on each side of the pages where there's room, stacked where there isn't */
.workspace {
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.stage,
.panel {
  flex: 1 1 100%;
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow:
    0 1px 2px var(--shadow),
    0 18px 40px -28px rgb(7 32 76 / 0.28);
}

.stage {
  height: 340px;
  background: var(--canvas);
}

.panel {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  background: var(--surface);
}

@container (min-width: 620px) {
  .stage {
    flex: 1 1 0;
    min-width: 0;
    height: 420px;
  }

  .panel {
    flex: 0 0 190px;
  }
}

/* Inside a panel */
.panel-title {
  margin: 0;
  font-size: 14px;
  font-weight: 650;
  color: var(--ink);
}

.tips {
  margin: 0;
  padding-left: 18px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink);
}

.tips li + li {
  margin-top: 6px;
}

.notes {
  flex: 1;
  min-height: 120px;
  box-sizing: border-box;
  width: 100%;
  font: inherit;
  font-size: 13px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 9px;
  padding: 8px 10px;
  resize: none;
}

.notes:focus-visible {
  outline: none;
  border-color: var(--focus);
  box-shadow: 0 0 0 3px rgb(125 182 255 / 0.35);
}

.loading {
  margin: 0;
  padding: 24px 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}
```

## Methods

Everything `useShell()` gives you.

| Method                                            | What it does                                                                                                                                                                                                                                            |
| :------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `open(id, { exclusive?, props?, focus? })`        | Opens a panel or dialog. `exclusive` closes the others in that group, `props` travels with it, and `focus` asks for the cursor, also when it's open. [One panel per side](#one-panel-per-side), [Put the cursor in a panel](#put-the-cursor-in-a-panel) |
| `toggle(id, { exclusive?, props?, focus? })`      | Opens a closed panel, with the options `open` takes, or closes an open one                                                                                                                                                                              |
| `close(id)`, `closeAll()`                         | Closes one panel, or every panel                                                                                                                                                                                                                        |
| `updateSurfaceProps(id, props)`                   | Changes a panel's `props` without reopening it. [Open a panel with something in it](#open-a-panel-with-something-in-it)                                                                                                                                 |
| `openMenu(id)`, `closeMenu(id)`, `toggleMenu(id)` | Opens, closes or toggles one menu. Opening a menu leaves the others open, so a submenu stays above its menu. [Menus](#menus)                                                                                                                            |
| `closeAllMenus()`                                 | Closes every menu, for a click outside or Escape                                                                                                                                                                                                        |
| `getSnapshot()`, `applySnapshot(snapshot)`        | Everything the plugin holds, as plain data you can store; and back. [Remember the layout](#remember-the-layout)                                                                                                                                         |

In a component, `useSurface(id)` gives you one panel: `{ isOpen, props, open, close, toggle }`.

## State

Everything `useShellState()` gives you. Your component re-renders when a value changes. The method in the last column reads the same value once.

| Field          | What it is                                                                                     | Read once                                                          |
| :------------- | :--------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
| `openSurfaces` | The open panels and dialogs, each with its `id`, `exclusive` group, `props` and `focusRequest` | `listOpenSurfaces()`, or `isOpen(id)` and `getSurface(id)` for one |
| `openMenus`    | The open menus' ids, the last one opened on top                                                | `listOpenMenus()`, or `isMenuOpen(id)` for one                     |

## Events

**`panel-events.tsx`**

```tsx
import { useShellEvent } from '@embedpdf/react/shell';
import { analytics } from './analytics';

export function PanelAnalytics() {
  useShellEvent(
    (shell) => shell.onSurfaceOpened,
    ({ id }) => analytics.track('panel', { id }),
  );
  return null;
}
```

| Event             | Fires when               | Carries       |
| :---------------- | :----------------------- | :------------ |
| `onSurfaceOpened` | A panel or dialog opened | `id`, `props` |
| `onSurfaceClosed` | A panel or dialog closed | `id`          |
| `onMenuOpened`    | A menu opened            | `id`          |
| `onMenuClosed`    | A menu closed            | `id`          |

## Good to know

### No document yet

The panel hooks work before a document is open: every panel reads as closed, and opening one does
nothing until there is a document. So a toolbar outside `<DocumentGate>` can use them.
