---
title: "Panels & dialogs — Svelte"
description: "Keep track of which sidebars, dialogs and menus are open, open them from commands, and restore them next time."
framework: "Svelte"
source: "https://www.embedpdf.com/docs/headless/svelte/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.svelte`**

```svelte
<script lang="ts">
  import { Viewer } from '@embedpdf/svelte/runtime';
  import { shellPlugin } from '@embedpdf/svelte/shell';
  import { engine } from './pdf';

  const plugins = [/* … */ shellPlugin()];
</script>

<Viewer {engine} {plugins}>
  <!-- your toolbar, panels and pages -->
</Viewer>
```

Then ask for a panel by name:

**`comments-panel.svelte`**

```svelte
<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';
  import CommentList from './CommentList.svelte';

  const panel = useSurface('comments');
</script>

<button onclick={() => panel.toggle({ exclusive: 'right' })}>Comments</button>

{#if panel.isOpen}
  <aside>
    <button onclick={panel.close}>Close</button>
    <CommentList />
  </aside>
{/if}
```

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

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { renderPlugin } from '@embedpdf/svelte/render';
  import { stagePlugin } from '@embedpdf/svelte/stage';
  import { searchPlugin } from '@embedpdf/svelte/search';
  import { shellPlugin } from '@embedpdf/svelte/shell';
  import { localEngine } from '@embedpdf/engine';
  import Workspace from './Workspace.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <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);
}
```

**`NotesPanel.svelte`**

```svelte
<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';

  let { notes = $bindable() }: { notes: string } = $props();

  const panel = useSurface('notes');
</script>

{#if panel.isOpen}
  <aside class="panel" aria-label="Notes">
    <header class="panel-header">
      <h3 class="panel-title">Notes</h3>
      <button type="button" class="close" aria-label="Close" onclick={panel.close}>×</button>
    </header>
    <textarea
      class="field notes"
      aria-label="Notes"
      placeholder="Your notes on this document…"
      bind:value={notes}
    ></textarea>
  </aside>
{/if}
```

**`PanelButton.svelte`**

```svelte
<!-- Both panels share the right side: opening one closes the other. -->
<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';

  let { id, label }: { id: string; label: string } = $props();

  const panel = useSurface(() => id);
</script>

<button
  type="button"
  class="button"
  aria-pressed={panel.isOpen}
  onclick={() => panel.toggle({ exclusive: 'right' })}
>
  {label}
</button>
```

**`SearchPanel.svelte`**

```svelte
<script lang="ts">
  import { useSearch, useSearchHits } from '@embedpdf/svelte/search';
  import { useSurface } from '@embedpdf/svelte/shell';

  const panel = useSurface('search');
  const search = useSearch();
  const hits = useSearchHits();
  let text = $state('PDF');

  $effect(() => {
    void search.search({ text });
  });
</script>

{#if panel.isOpen}
  <aside class="panel" aria-label="Search">
    <header class="panel-header">
      <h3 class="panel-title">Search</h3>
      <button type="button" class="close" aria-label="Close" onclick={panel.close}>×</button>
    </header>
    <input class="field" type="search" aria-label="Search" bind:value={text} />
    <ol class="list">
      {#each hits.current as hit (`${hit.page.objectNumber}:${hit.start}`)}
        <li>
          <button type="button" class="item" onclick={() => search.goToHit(hit)}>
            <span class="item-page">Page {hit.pageIndex + 1}</span>
            {#if hit.snippet}
              <span class="item-text"
                >…{hit.snippet.before}<mark>{hit.snippet.match}</mark>{hit.snippet.after}…</span
              >
            {/if}
          </button>
        </li>
      {/each}
    </ol>
  </aside>
{/if}
```

**`Workspace.svelte`**

```svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { Stage } from '@embedpdf/svelte/stage';
  import { SearchLayer } from '@embedpdf/svelte/search';
  import { useShell } from '@embedpdf/svelte/shell';
  import NotesPanel from './NotesPanel.svelte';
  import PanelButton from './PanelButton.svelte';
  import SearchPanel from './SearchPanel.svelte';

  const shell = useShell();
  // Your app's own data: it stays when the panel closes.
  let notes = $state('');

  // The search panel is open when the document is.
  onMount(() => {
    shell.open('search', { exclusive: 'right' });
  });
</script>

<div class="toolbar">
  <PanelButton id="search" label="Search" />
  <PanelButton id="notes" label="Notes" />
</div>
<div class="workspace">
  <Stage class="stage">
    <RenderLayer />
    <SearchLayer />
  </Stage>
  <SearchPanel />
  <NotesPanel bind:notes />
</div>
```

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

```svelte
<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';
  import CommentList from './CommentList.svelte';

  const panel = useSurface('comments'); // panel.props is { focus: … }
</script>

<CommentList focus={panel.props.focus} />
```

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

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { renderPlugin } from '@embedpdf/svelte/render';
  import { stagePlugin } from '@embedpdf/svelte/stage';
  import { searchPlugin } from '@embedpdf/svelte/search';
  import { shellPlugin } from '@embedpdf/svelte/shell';
  import { localEngine } from '@embedpdf/engine';
  import Workspace from './Workspace.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <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);
}
```

**`MatchPanel.svelte`**

```svelte
<!-- The panel shows the match its props name, and steps to the next one without reopening. -->
<script lang="ts">
  import { useSearch, useSearchHits } from '@embedpdf/svelte/search';
  import { useShell, useSurface } from '@embedpdf/svelte/shell';

  const panel = useSurface('match');
  const shell = useShell();
  const search = useSearch();
  const hits = useSearchHits();
  const index = $derived(typeof panel.props.index === 'number' ? panel.props.index : 0);
  const hit = $derived(hits.current[index]);

  function show(next: number) {
    shell.updateSurfaceProps('match', { index: next });
    search.goToHit(hits.current[next]);
  }
</script>

{#if panel.isOpen}
  <aside class="panel" aria-label="Match">
    <header class="panel-header">
      <h3 class="panel-title">Match {index + 1} of {hits.current.length}</h3>
      <button type="button" class="close" aria-label="Close" onclick={panel.close}>×</button>
    </header>
    {#if hit}
      <p class="page">Page {hit.pageIndex + 1}</p>
      {#if hit.snippet}
        <p class="snippet">
          …{hit.snippet.before}<mark>{hit.snippet.match}</mark>{hit.snippet.after}…
        </p>
      {/if}
    {:else}
      <p class="page">Searching…</p>
    {/if}
    <div class="steps">
      <button type="button" class="button" disabled={index === 0} onclick={() => show(index - 1)}>
        ← Previous
      </button>
      <button
        type="button"
        class="button"
        disabled={index >= hits.current.length - 1}
        onclick={() => show(index + 1)}
      >
        Next →
      </button>
    </div>
  </aside>
{/if}
```

**`Workspace.svelte`**

```svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { Stage } from '@embedpdf/svelte/stage';
  import { SearchLayer, useSearch, useSearchHits } from '@embedpdf/svelte/search';
  import { useShell } from '@embedpdf/svelte/shell';
  import MatchPanel from './MatchPanel.svelte';

  const shell = useShell();
  const search = useSearch();
  const hits = useSearchHits();

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

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

## 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 the `surfaceFocus(id)` attachment:

**`panel-focus.svelte`**

```svelte
<script lang="ts">
  import { surfaceFocus } from '@embedpdf/svelte/shell';
</script>

<!-- The cursor goes in the field when the panel asks for it, its text selected. -->
<input {@attach surfaceFocus('search', { select: true })} 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.

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { renderPlugin } from '@embedpdf/svelte/render';
  import { stagePlugin } from '@embedpdf/svelte/stage';
  import { interactionPlugin } from '@embedpdf/svelte/interaction';
  import { selectionPlugin } from '@embedpdf/svelte/selection';
  import { shellPlugin } from '@embedpdf/svelte/shell';
  import { localEngine } from '@embedpdf/engine';
  import Workspace from './Workspace.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <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);
}
```

**`ViewMenu.svelte`**

```svelte
<script lang="ts" module>
  const TOOLS = [
    { id: 'pointer', label: 'Select text' },
    { id: 'pan', label: 'Scroll with the hand' },
  ];
</script>

<!-- A menu with a submenu: opening the submenu leaves its menu open. -->
<script lang="ts">
  import { useInteraction, useInteractionState } from '@embedpdf/svelte/interaction';
  import { useShell, useShellState } from '@embedpdf/svelte/shell';

  const shell = useShell();
  const interaction = useInteraction();
  const shellState = useShellState();
  const tools = useInteractionState();
  let bar: HTMLDivElement | undefined = $state();
  const openMenus = $derived(shellState.openMenus);
  const anyOpen = $derived(openMenus.length > 0);

  // A press outside the menus, or Escape, closes all of them.
  $effect(() => {
    if (!anyOpen) return;
    const outside = (event: PointerEvent) => {
      if (!bar?.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);
    };
  });
</script>

<div class="toolbar">
  <div class="menubar" bind:this={bar}>
    <button
      type="button"
      class="button"
      aria-haspopup="menu"
      aria-expanded={openMenus.includes('view')}
      onclick={() => shell.toggleMenu('view')}
    >
      View ▾
    </button>
    {#if openMenus.includes('view')}
      <div class="menu" role="menu">
        <button
          type="button"
          role="menuitem"
          class="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"
          class="menu-item"
          onclick={() => shell.closeAllMenus()}
        >
          Close menus
        </button>
        {#if openMenus.includes('view-tool')}
          <div class="menu submenu" role="menu">
            {#each TOOLS as tool (tool.id)}
              <button
                type="button"
                role="menuitemradio"
                aria-checked={tool.id === tools.activeToolId}
                class="menu-item"
                onclick={() => {
                  interaction.activateTool(tool.id);
                  shell.closeAllMenus();
                }}
              >
                {tool.label}
              </button>
            {/each}
          </div>
        {/if}
      </div>
    {/if}
  </div>
  <output class="readout">Open menus: {anyOpen ? openMenus.join(' › ') : 'none'}</output>
</div>
```

**`Workspace.svelte`**

```svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { Stage } from '@embedpdf/svelte/stage';
  import { SelectionLayer } from '@embedpdf/svelte/selection';
  import { useShell } from '@embedpdf/svelte/shell';
  import ViewMenu from './ViewMenu.svelte';

  const shell = useShell();

  // The menu and its submenu are open when the document is.
  onMount(() => {
    shell.openMenu('view');
    shell.openMenu('view-tool');
  });
</script>

<ViewMenu />
<Stage class="stage">
  <RenderLayer />
  <SelectionLayer />
</Stage>
```

## Remember the layout

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

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

**`App.svelte`**

```svelte
<script lang="ts" module>
  import { DocumentGate, Viewer, type OpenInput } from '@embedpdf/svelte/runtime';
  import { renderPlugin } from '@embedpdf/svelte/render';
  import { stagePlugin } from '@embedpdf/svelte/stage';
  import { shellPlugin } from '@embedpdf/svelte/shell';
  import { localEngine } from '@embedpdf/engine';
  import Workspace from './Workspace.svelte';

  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()) };
  };
</script>

<Viewer {engine} {plugins} initialDocuments={[{ source: ebook }]}>
  <DocumentGate>
    {#snippet fallback()}
      <p class="loading">Loading…</p>
    {/snippet}
    <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);
}
```

**`NotesPanel.svelte`**

```svelte
<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';

  const panel = useSurface('notes');
</script>

{#if panel.isOpen}
  <aside class="panel" aria-label="Notes">
    <h3 class="panel-title">Notes</h3>
    <textarea class="notes" aria-label="Notes" placeholder="Your notes on this document…"
    ></textarea>
  </aside>
{/if}
```

**`PanelButton.svelte`**

```svelte
<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';

  let { id, label, side }: { id: string; label: string; side: 'left' | 'right' } = $props();

  const panel = useSurface(() => id);
</script>

<button
  type="button"
  class="button"
  aria-pressed={panel.isOpen}
  onclick={() => panel.toggle({ exclusive: side })}
>
  {label}
</button>
```

**`TipsPanel.svelte`**

```svelte
<script lang="ts" module>
  const TIPS = [
    'Ctrl or ⌘ and scroll to zoom',
    'Pinch to zoom on a touch screen',
    'Drag between pages to scroll',
  ];
</script>

<script lang="ts">
  import { useSurface } from '@embedpdf/svelte/shell';

  const panel = useSurface('tips');
</script>

{#if panel.isOpen}
  <aside class="panel" aria-label="Tips">
    <h3 class="panel-title">Tips</h3>
    <ul class="tips">
      {#each TIPS as tip (tip)}
        <li>{tip}</li>
      {/each}
    </ul>
  </aside>
{/if}
```

**`Workspace.svelte`**

```svelte
<script lang="ts" module>
  import type { ShellSnapshot } from '@embedpdf/svelte/shell';

  // 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.
    }
  };
</script>

<script lang="ts">
  import { onMount } from 'svelte';
  import { RenderLayer } from '@embedpdf/svelte/render';
  import { Stage } from '@embedpdf/svelte/stage';
  import { useShell, useShellState } from '@embedpdf/svelte/shell';
  import NotesPanel from './NotesPanel.svelte';
  import PanelButton from './PanelButton.svelte';
  import TipsPanel from './TipsPanel.svelte';

  const shell = useShell();
  const shellState = useShellState();
  let saved: ShellSnapshot | null = $state.raw(loadLayout());

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

  function save() {
    const layout = shell.getSnapshot();
    saveLayout(layout);
    saved = layout;
  }

  const open = $derived(shellState.openSurfaces.map((surface) => surface.id).join(', '));
</script>

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

## 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, as one reactive object: read its fields, and your markup updates when they change. 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.svelte`**

```svelte
<script lang="ts">
  import { useShellEvent } from '@embedpdf/svelte/shell';
  import { analytics } from './analytics';

  useShellEvent(
    (shell) => shell.onSurfaceOpened,
    ({ id }) => analytics.track('panel', { id }),
  );
</script>

<!-- your toolbar, panels and pages -->
```

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