---
title: "Commands & shortcuts — React"
description: "Give every action in your viewer one name, with its label, icon, state and keyboard shortcut, and run it from buttons, menus and keys."
framework: "React"
source: "https://www.embedpdf.com/docs/headless/react/ui/commands"
---

# Commands & shortcuts

A **command** is one action of your viewer, like "zoom in" or "download", defined once: its label, its
icon, when it's enabled or active, what it does, and its keyboard shortcut. Toolbars, menus, a command
palette and the keyboard all run the same command, so they always agree. Register the plugin with the
commands you need:

**`commands-plugin.tsx`**

```tsx
import { AnnotationToken } from '@embedpdf/react/annotation';
import { commandsPlugin, standardCommands } from '@embedpdf/react/commands';
import { DocumentsToken } from '@embedpdf/react/runtime';
import { approveDocument } from './review';

export const plugins = [
  /* … */
  commandsPlugin({
    commands: [
      ...standardCommands,
      {
        id: 'review:approve',
        label: 'Approve',
        icon: 'check',
        shortcut: 'Mod+Enter',
        enabled: ({ get }) => get(AnnotationToken).canCreate(),
        run: ({ get }) => approveDocument(get(DocumentsToken)),
      },
    ],
  }),
];
```

## Show a command

`useCommand(id)` gives you everything a button needs, and re-renders when any of it changes:

**`command-button.tsx`**

```tsx
import { useCommand } from '@embedpdf/react/commands';
import { Icon } from './icon';

export function CommandButton({ id }: { id: string }) {
  const command = useCommand(id);
  if (!command.visible) return null;

  return (
    <button onClick={command.run} disabled={!command.enabled} aria-pressed={command.active}>
      <Icon name={command.icon} />
      {command.label}
      {command.shortcut && <kbd>{command.shortcut}</kbd>}
    </button>
  );
}
```

**`buttons.tsx`**

```tsx
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 { commandsPlugin, standardCommands, useCommand } from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';

import './buttons.css';

const engine = localEngine();
const plugins = [stagePlugin(), renderPlugin(), commandsPlugin({ commands: standardCommands })];

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

// One button, drawn from its command: the label, the shortcut, and whether it can run now.
function CommandButton({ id }: { id: string }) {
  const command = useCommand(id);
  if (!command.visible) return null;

  return (
    <button
      type="button"
      className="button"
      title={command.label}
      disabled={!command.enabled}
      aria-pressed={command.active}
      onClick={command.run}
    >
      {command.label}
      {command.shortcut && <kbd className="shortcut">{command.shortcut}</kbd>}
    </button>
  );
}

export default function App() {
  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <div className="toolbar">
          <CommandButton id="page:previous" />
          <CommandButton id="page:next" />
          <CommandButton id="zoom:out" />
          <CommandButton id="zoom:in" />
          <CommandButton id="zoom:fit-width" />
          <CommandButton id="view:rotate-clockwise" />
          <CommandButton id="document:download" />
        </div>
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}
```

**`buttons.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 buttons, above the pages; they wrap on a phone */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}

.button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  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: 8px 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:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* A command that is active, such as the current fit, shows as pressed */
.button[aria-pressed='true'] {
  color: var(--accent);
  background: var(--tint);
  border-color: var(--line-strong);
}

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

.shortcut {
  font-family: inherit;
  font-size: 11px;
  font-weight: 500;
  color: var(--muted);
  padding: 2px 5px;
  border: 1px solid var(--line);
  border-radius: 5px;
  background: var(--canvas);
}

/* 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);
}
```

`shortcut` is formatted for the user's platform: `⌘K` on a Mac, `Ctrl+K` elsewhere. The
[toolbar](toolbar) draws commands the same way.

There's always a command, so a button never checks for a missing one: an id no plugin registered
comes back labelled with its id, neither visible nor enabled, the way a turned-off command does,
and in development it warns once, with the closest id there is. Checking `visible` covers a
command that's turned off, not allowed or missing. `resolveCommand(id)` is the lookup for code,
and says `null` for an id it doesn't have.

## The standard commands

`standardCommands` covers what every viewer has, with labels in [every language you load](translations):

| Command                                                 | Does                                          | Shortcut                |
| :------------------------------------------------------ | :-------------------------------------------- | :---------------------- |
| `zoom:in`, `zoom:out`                                   | Zoom in or out                                | `Mod+=`, `Mod+-`        |
| `zoom:fit-width`, `zoom:fit-page`                       | Fit the page's width, or the whole page       |                         |
| `page:next`, `page:previous`, `page:first`, `page:last` | Move through pages                            | `→`, `←`, `Home`, `End` |
| `view:rotate-clockwise`, `view:rotate-counterclockwise` | Turn the view                                 |                         |
| `tool:pointer`, `tool:pan`                              | Switch tools                                  | `V`, `H`                |
| `tool:<id>`                                             | Any registered tool, such as `tool:highlight` |                         |
| `selection:copy`                                        | Copy the selected text                        | `Mod+C`                 |
| `annotation:delete`                                     | Delete the selected annotations               | `Delete`                |
| `document:download`, `document:print`                   | Download or print the document                | `Mod+S`, `Mod+P`        |

Each one is enabled only when it can run: `selection:copy` needs a selection and permission to copy,
`annotation:delete` a selection the user may delete.

## Keyboard shortcuts

`useCommandShortcuts()` turns every command's shortcut into a working key. Call it once, near the top
of your viewer:

**`command-shortcuts.tsx`**

```tsx
import { useCommandShortcuts } from '@embedpdf/react/commands';

export function KeyboardShortcuts() {
  useCommandShortcuts();
  return null;
}
```

It ignores keys typed into text fields, except a command's whose `shortcutInTextFields` is `true`
(a Find that should work from the search box too), runs commands for the active document, and leaves a key alone
when its command can't run right now. A command that's [turned off](#turn-features-off) or [not
allowed](#commands-and-permissions) still takes its key, so the browser doesn't do something else with
it (`Mod+P` printing the page), and `onRefused(commandId, refusal)` tells you why: `refusal.message`
is a sentence to show. With more than one viewer on a page, pass `target`, a ref to the element
around one: its keys then work only while focus is inside it. `Mod` means Cmd on a Mac and Ctrl elsewhere. A help sheet lists
`listShortcuts()`:

**`shortcuts.tsx`**

```tsx
import { useRef, 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 { interactionPlugin } from '@embedpdf/react/interaction';
import {
  commandsPlugin,
  formatShortcut,
  standardCommands,
  useCommand,
  useCommandShortcuts,
  useCommands,
  useCommandsEvent,
} from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';

import './shortcuts.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  commandsPlugin({ commands: standardCommands }),
];

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

// One row of the sheet: the key, and what it does. A command that can't run now is greyed out.
function ShortcutRow({ id, shortcut }: { id: string; shortcut: string }) {
  const command = useCommand(id);
  if (!command.visible) return null;

  return (
    <li className="row" data-disabled={!command.enabled || undefined}>
      <kbd className="key">{formatShortcut(shortcut)}</kbd>
      <span>{command.label}</span>
    </li>
  );
}

// The sheet lists every shortcut, and shows the last command a key ran.
function KeyboardHelp() {
  const commands = useCommands();
  const [last, setLast] = useState<string | null>(null);

  useCommandsEvent(
    (commands) => commands.onExecuted,
    ({ commandId }) => setLast(commands.resolveCommand(commandId)?.label ?? commandId),
  );

  return (
    <aside className="sheet">
      <p className="status">
        {last ? `Ran: ${last}` : 'Click the viewer, then press a key, such as → for the next page'}
      </p>
      <ul className="rows">
        {commands.listShortcuts().map(({ commandId, shortcut }) => (
          <ShortcutRow key={`${commandId} ${shortcut}`} id={commandId} shortcut={shortcut} />
        ))}
      </ul>
    </aside>
  );
}

// The keys work while focus is inside this viewer, so they leave the rest of the page alone. A
// click anywhere in it gives it focus: `tabIndex={-1}` makes it a place for keys, not a stop in the
// Tab order.
function ShortcutArea() {
  const area = useRef<HTMLDivElement>(null);
  useCommandShortcuts({ target: area });
  return (
    <div ref={area} className="layout" tabIndex={-1}>
      <KeyboardHelp />
      <Stage className="stage">{() => <RenderLayer />}</Stage>
    </div>
  );
}

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

**`shortcuts.css`**

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

/* The sheet beside the pages, and above them on a phone */
.layout {
  container-type: inline-size;
  display: grid;
  grid-template-columns: 1fr;
  gap: 14px;
}

/* The keys work while focus is inside the viewer: show where that is, for the keyboard */
.layout:focus-visible,
.layout:has(:focus-visible) {
  outline: 2px solid var(--focus, #7db6ff);
  outline-offset: 4px;
  border-radius: 14px;
}

@container (min-width: 620px) {
  .layout {
    grid-template-columns: 220px 1fr;
  }
}

.sheet {
  box-sizing: border-box;
  max-height: 420px;
  overflow: auto;
  padding: 12px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: 0 1px 2px var(--shadow);
}

.status {
  margin: 0 0 10px;
  padding: 8px 10px;
  font-size: 12px;
  font-weight: 550;
  color: var(--accent);
  background: var(--tint);
  border-radius: 8px;
}

.rows {
  margin: 0;
  padding: 0;
  list-style: none;
}

.row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 5px 2px;
  font-size: 13px;
  color: var(--ink);
}

.row[data-disabled] {
  color: var(--muted);
  opacity: 0.6;
}

.key {
  min-width: 52px;
  font-family: inherit;
  font-size: 11px;
  font-weight: 600;
  text-align: center;
  color: var(--ink);
  padding: 3px 6px;
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: 6px;
  background: var(--canvas);
}

/* 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);
}
```

## Run a command from code

```ts
await commands.execute('zoom:in');
await commands.execute('review:approve', { args: { note: 'Looks good' } });
```

`execute()` resolves with `{ status: 'executed' }`, or `{ status: 'rejected', reason }` when the command
is disabled, hidden or unknown.

## A command palette

`searchCommands()` finds commands by their label, for a palette like the one in code editors:

```ts
const results = commands.searchCommands(query); // visible commands whose label or id matches
```

A disabled command is still in the results, so show it greyed out.

**`palette.tsx`**

```tsx
import { 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 { interactionPlugin } from '@embedpdf/react/interaction';
import {
  commandsPlugin,
  standardCommands,
  useCommand,
  useCommands,
} from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';

import './palette.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  commandsPlugin({ commands: standardCommands }),
];

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

// A result: its row follows the command, so it greys out the moment the command can't run.
function Result({ id }: { id: string }) {
  const command = useCommand(id);
  if (!command.visible) return null;

  return (
    <li>
      <button type="button" className="result" disabled={!command.enabled} onClick={command.run}>
        <span>{command.label}</span>
        {command.shortcut && <kbd className="shortcut">{command.shortcut}</kbd>}
      </button>
    </li>
  );
}

function CommandPalette() {
  const commands = useCommands();
  const [query, setQuery] = useState('page');
  const results = commands.searchCommands(query);

  return (
    <div className="palette">
      <input
        className="field"
        type="search"
        aria-label="Search commands"
        placeholder="Type a command…"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        onKeyDown={(event) => {
          // Enter runs the first result that can run now.
          const first = results.find((command) => commands.canExecute(command.id));
          if (event.key === 'Enter' && first) void commands.execute(first.id);
        }}
      />
      <ul className="results">
        {results.map((command) => (
          <Result key={command.id} id={command.id} />
        ))}
        {results.length === 0 && <li className="empty">No command matches</li>}
      </ul>
    </div>
  );
}

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

**`palette.css`**

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

/* The palette beside the pages, and above them on a phone */
.layout {
  container-type: inline-size;
  display: grid;
  grid-template-columns: 1fr;
  gap: 14px;
}

@container (min-width: 620px) {
  .layout {
    grid-template-columns: 240px 1fr;
  }
}

.palette {
  box-sizing: border-box;
  max-height: 420px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 14px;
  box-shadow: 0 1px 2px var(--shadow);
}

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

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

.results {
  margin: 0;
  padding: 0;
  list-style: none;
  overflow: auto;
}

.result {
  display: flex;
  width: 100%;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font: inherit;
  font-size: 13px;
  text-align: start;
  color: var(--ink);
  background: transparent;
  border: none;
  border-radius: 8px;
  padding: 7px 8px;
  cursor: pointer;
}

.result:hover,
.result:focus-visible {
  outline: none;
  background: var(--tint);
}

/* A command that can't run now stays in the list, greyed out */
.result:disabled {
  color: var(--muted);
  opacity: 0.55;
  cursor: not-allowed;
  background: transparent;
}

.shortcut {
  font-family: inherit;
  font-size: 11px;
  color: var(--muted);
  padding: 2px 5px;
  border: 1px solid var(--line);
  border-radius: 5px;
  background: var(--canvas);
}

.empty {
  padding: 8px;
  font-size: 13px;
  color: var(--muted);
}

/* 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);
}
```

## Define a command

| Field                    | What it is                                                                                                               |
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------- |
| `id`                     | A unique name, by convention `area:action`                                                                               |
| `label`                  | What the user sees, or `labelKey` for a [translated](translations) label                                                 |
| `icon`                   | Your icon's name; your buttons turn it into an icon                                                                      |
| `shortcut`               | A key such as `'Mod+K'`, or a list of them                                                                               |
| `shortcutInTextFields`   | `true`: its shortcut runs while the cursor is in a text field too                                                        |
| `enabled`                | When it can run, as a function of the viewer's state                                                                     |
| `active`                 | When it shows as pressed, such as the current tool                                                                       |
| `visible`                | When it shows at all                                                                                                     |
| `run`                    | What it does                                                                                                             |
| `requires`               | The [permissions](../concepts/permissions) it needs, such as `'doc.annotate.modify'`. Without them, it's hidden.         |
| `categories`             | The features it belongs to, so you can [turn them off](#turn-features-off) together, such as `['annotations.highlight']` |
| `panel`, `menu`, `modal` | A command that opens a [panel, menu or dialog](panels) needs no `run`                                                    |

`enabled`, `active` and `visible` get the viewer's state through `get(Token)`, and are asked again
whenever anything changes, so a button is always right without events or effects of your own.

## Turn features off

Put commands in categories, and turn a whole category off: its commands are hidden and refused,
wherever they appear. A category turns off every category under it, on whole dotted parts:
`annotations` also turns off `annotations.highlight`. The `disabledCategories` [setting](#settings)
turns categories off from the start, and while the app runs:

```ts
commands.disableCategory('annotations');
commands.enableCategory('annotations');
```

Turn off one command the same way, with `disableCommand(id)` and the `disabledCommands` setting. A
command that's turned off is refused by `execute()` too, and its shortcut does nothing.

A command of your own, a category switch, and a command run from code when the document opens:

**`custom.tsx`**

```tsx
import { useEffect } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, StageToken, stagePlugin } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import {
  commandsPlugin,
  standardCommands,
  useCommand,
  useCommands,
  useCommandsSettings,
} from '@embedpdf/react/commands';
import type { CommandDef } from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';

import './custom.css';

const engine = localEngine();

// A command of your own: pressed while the pages show two at a time.
const twoPages: CommandDef = {
  id: 'layout:two-pages',
  label: 'Two pages',
  categories: ['layout'],
  active: ({ get }) => get(StageToken).getSettings().spread === 'odd',
  run: ({ get }) => {
    const stage = get(StageToken);
    stage.updateSettings({ spread: stage.getSettings().spread === 'odd' ? 'none' : 'odd' });
  },
};

const plugins = [
  stagePlugin(),
  renderPlugin(),
  commandsPlugin({ commands: [...standardCommands, twoPages] }),
];

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

function CommandButton({ id }: { id: string }) {
  const command = useCommand(id);
  if (!command.visible) return null;

  return (
    <button
      type="button"
      className="button"
      disabled={!command.enabled}
      aria-pressed={command.active}
      onClick={command.run}
    >
      {command.label}
    </button>
  );
}

// Turning a category off hides its commands wherever they appear.
function CategorySwitch({ category, label }: { category: string; label: string }) {
  const commands = useCommands();
  const off = useCommandsSettings((settings) => settings.disabledCategories.includes(category));

  return (
    <label className="switch">
      <input
        type="checkbox"
        checked={!off}
        onChange={(event) =>
          event.target.checked
            ? commands.enableCategory(category)
            : commands.disableCategory(category)
        }
      />
      {label}
    </label>
  );
}

function Toolbar() {
  const commands = useCommands();

  // Run from code: the document opens with its whole first page in view.
  useEffect(() => {
    void commands.execute('zoom:fit-page');
  }, [commands]);

  return (
    <div className="toolbar">
      <div className="buttons">
        <CommandButton id="zoom:out" />
        <CommandButton id="zoom:in" />
        <CommandButton id="zoom:fit-page" />
        <CommandButton id="layout:two-pages" />
      </div>
      <div className="switches">
        <CategorySwitch category="zoom" label="Zoom" />
        <CategorySwitch category="layout" label="Layout" />
      </div>
    </div>
  );
}

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

**`custom.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 buttons and the switches, above the pages; they wrap on a phone */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 14px;
}

.buttons,
.switches {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.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: 8px 10px;
  cursor: pointer;
  box-shadow: 0 1px 2px var(--shadow);
}

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

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

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

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

.switch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--ink);
  padding: 6px 10px;
  background: var(--tint);
  border: 1px solid var(--line);
  border-radius: 999px;
  cursor: pointer;
}

.switch input {
  margin: 0;
  accent-color: var(--accent);
}

/* 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);
}
```

## Methods

Everything `useCommands()` gives you.

| Method                                                                               | What it does                                                                                                                                                                                                   |
| :----------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execute(id, { args?, documentId? })`                                                | Runs a command. Resolves `{ status, reason? }`: `'executed'`, or `'rejected'` with why. A `run` that throws rejects. [Run a command from code](#run-a-command-from-code)                                       |
| `canExecute(id, documentId?)`                                                        | Whether a command can run now: it's registered, enabled and visible                                                                                                                                            |
| `resolveCommand(id, documentId?)`                                                    | A command as a button shows it: label, icon, shortcuts, and whether it's enabled, active and visible. `null` for an unknown id. In a component, use `useCommand(id)`. [Show a command](#show-a-command)        |
| `getRefusal(id, documentId?)`                                                        | Why a command won't run whatever the moment, as `{ reason, message }`: `'turned-off'` or `'not-allowed'`, with a sentence for the user. `null` when it may run, or can't only right now, and for an unknown id |
| `listCommands(documentId?)`                                                          | Every command, resolved the same way, in the order they were added                                                                                                                                             |
| `searchCommands(query, documentId?)`                                                 | The visible commands whose label or id contains the query. [A command palette](#a-command-palette)                                                                                                             |
| `listShortcuts()`                                                                    | Every shortcut with its command, as `{ commandId, shortcut }`, for a keyboard help sheet. [Keyboard shortcuts](#keyboard-shortcuts)                                                                            |
| `registerCommand(command, { replace? })`, `registerCommands(commands, { replace? })` | Adds one command, or several, while the app runs, and returns a function that removes them. An id that's taken is refused unless you pass `replace: true`. [Define a command](#define-a-command)               |
| `getCommand(id)`, `hasCommand(id)`                                                   | A command as you defined it, or `null`; whether it's registered                                                                                                                                                |
| `listCommandIds()`                                                                   | Every command's id, in the order they were added                                                                                                                                                               |
| `disableCategory(category)`, `enableCategory(category)`                              | Turns a category off, with every category under it, or on again. [Turn features off](#turn-features-off)                                                                                                       |
| `setDisabledCategories(categories)`                                                  | Replaces the list of categories that are off                                                                                                                                                                   |
| `getDisabledCategories()`, `isCategoryDisabled(category)`                            | The categories that are off; whether one is, itself or through a category it's under                                                                                                                           |
| `disableCommand(id)`, `enableCommand(id)`                                            | Turns one command off, or on again. [Turn features off](#turn-features-off)                                                                                                                                    |
| `setDisabledCommands(ids)`                                                           | Replaces the list of commands that are off one by one                                                                                                                                                          |
| `getDisabledCommands()`, `isCommandDisabled(id)`                                     | The commands that are off one by one; whether a command is off, by its id or through one of its categories                                                                                                     |
| `getSettings()`, `updateSettings(changes)`, `resetSettings()`                        | Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use `useCommandsSettings()`. [Settings](#settings)                                                   |

Leave out `documentId`, and a command runs and resolves for the document in scope: the one a
[`<DocumentScope>`](../documents/tabs#talk-to-a-specific-document) around your component names, or
else the active one. Every method that returns a promise also takes a `signal` in its options, to
cancel it.

## Settings

Set them when you register the plugin, and change them while the app runs with
`commands.updateSettings({ … })`; `commands.resetSettings()` goes back to what you registered, and
`useCommandsSettings()` gives you the current settings.

| Setting              | What it does                                                                                                             | Default  |
| :------------------- | :----------------------------------------------------------------------------------------------------------------------- | :------- |
| `commands`           | The commands your viewer has                                                                                             | none     |
| `disabledCategories` | Categories that start turned off, with every category under them                                                         | none     |
| `disabledCommands`   | Commands that start turned off, by id                                                                                    | none     |
| `notAllowed`         | What a command the scope doesn't allow does: `'hide'` leaves it out, `'disable'` shows it disabled with `disabledReason` | `'hide'` |

## Events

**`command-events.tsx`**

```tsx
import { useCommandsEvent } from '@embedpdf/react/commands';
import { analytics } from './analytics';

export function CommandAnalytics() {
  useCommandsEvent(
    (commands) => commands.onExecuted,
    ({ commandId }) => analytics.track('command', { commandId }),
  );
  return null;
}
```

| Event               | Fires when              | Carries                            |
| :------------------ | :---------------------- | :--------------------------------- |
| `onExecuted`        | A command ran           | `commandId`, `documentId`, `args`  |
| `onExecutionFailed` | A command's `run` threw | `commandId`, `documentId`, `error` |
| `onSettingsChanged` | The settings changed    | `settings`, `changed`              |

## Good to know

### Commands and permissions

A command that needs a permission says so with `requires: ['doc.annotate.modify']`. When the user
doesn't have it, the command isn't visible, so its buttons and menu rows are gone, and its shortcut
does nothing. With the `notAllowed: 'disable'` setting it shows disabled instead, and the resolved
command's `disabledReason` says why, for its tooltip: the i18n string for
`commands.notAllowed.<permission>` (dots and colons as dashes, such as
`commands.notAllowed.doc-print`), else `commands.notAllowed.default`. Keep `enabled` for what can't
run right now, such as **Copy** with nothing selected. The standard commands already say what they
need.
