EmbedPDF

Toolbar

A viewer’s toolbar has to fit a phone and a wide screen. <Toolbar> takes a list of what should be in it, measures what fits, and makes room when it has to: labels become icons, a group folds into one button, and what’s left over goes into a “More” menu. You draw every button.

The items are commands, so each one already knows its label, icon, whether it’s enabled, and what it does. These are from the standard commands:

toolbar.tsx
import { Toolbar, group, item } from '@embedpdf/react/toolbar';
import { Icon } from './icon';

const bar = {
  id: 'main',
  sections: {
    start: [group('navigation', ['page:previous', 'page:next'])],
    center: [group('zoom', ['zoom:out', item('zoom:in', { variants: ['icon+label', 'icon'] })])],
    end: [group('tools', ['tool:pointer', 'tool:pan', 'tool:highlight'], { collapse: 'menu' })],
  },
};

export function MainToolbar() {
  return (
    <Toolbar
      bar={bar}
      renderCommand={(command, variant, run) => (
        <button onClick={run} disabled={!command.enabled} aria-pressed={command.active} title={command.label}>
          <Icon name={command.icon} />
          {variant === 'icon+label' && command.label}
        </button>
      )}
    />
  );
}

Loading live preview…

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 { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/react/annotation';
import { commandsPlugin, standardCommands } from '@embedpdf/react/commands';
import { Toolbar, group, item } from '@embedpdf/react/toolbar';
import type { BarSchema } from '@embedpdf/react/toolbar';
import { localEngine } from '@embedpdf/engine';

import './basic.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  selectionPlugin(),
  annotationPlugin(),
  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()) };
};

// Your icons, by the names the standard commands give them.
const ICONS: Record<string, string> = {
  'previous-page': '‹',
  'next-page': '›',
  'zoom-out': '−',
  'zoom-in': '+',
  'fit-width': '↔',
  pointer: '↖',
  pan: '✥',
  highlight: '▍',
  underline: 'U̲',
  ink: '〰',
  download: '↓',
  print: '⎙',
};

const bar: BarSchema = {
  id: 'main',
  sections: {
    start: [group('pages', ['page:previous', 'page:next'])],
    center: [
      group('zoom', [
        'zoom:out',
        item('zoom:in', { variants: ['icon+label', 'icon'] }),
        item('zoom:fit-width', { variants: ['icon+label', 'icon'], importance: 2 }),
      ]),
    ],
    end: [
      group('tools', ['tool:pointer', 'tool:pan', 'tool:highlight', 'tool:underline', 'tool:ink'], {
        collapse: 'menu',
      }),
      group('document', [item('document:download', { importance: 5 }), 'document:print']),
    ],
  },
};

function MainToolbar() {
  return (
    <Toolbar
      bar={bar}
      className="toolbar"
      renderCommand={(command, variant, run) => (
        <button
          type="button"
          className="button"
          title={command.label}
          aria-label={command.label}
          disabled={!command.enabled}
          aria-pressed={command.active}
          onClick={run}
        >
          <span className="icon" aria-hidden>
            {ICONS[command.icon ?? ''] ?? command.label.charAt(0)}
          </span>
          {variant === 'icon+label' && <span>{command.label}</span>}
        </button>
      )}
    />
  );
}

export default function App() {
  // Narrow the toolbar to watch it make room: labels go first, then the tools fold, then "More".
  const [width, setWidth] = useState(100);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <label className="width">
          Toolbar width
          <input
            type="range"
            min={30}
            max={100}
            value={width}
            onChange={(event) => setWidth(Number(event.target.value))}
          />
          <output>{width}%</output>
        </label>
        <div className="frame" style={{ width: `${width}%` }}>
          <MainToolbar />
        </div>
        <Stage className="stage">
          {() => (
            <>
              <RenderLayer />
              <SelectionLayer />
              <AnnotationLayer />
            </>
          )}
        </Stage>
      </DocumentGate>
    </Viewer>
  );
}

Resize the window, and the toolbar adjusts on its own. It measures real rendered widths, so a longer translation or a bigger font just works.

How it makes room#

When the items don’t fit, the toolbar takes these steps, in order, until they do:

  1. An item gets smaller: from its first variant to the next, such as 'icon+label' to 'icon'.
  2. A group folds into one button with a menu, when it has collapse: 'menu', or into a dropdown with collapse: 'select'.
  3. An item moves into the “More” menu, the least important first.

importance decides the order, from 1 (goes first) to 5 (never leaves the bar):

item('document:download', { importance: 5 }); // always visible
group('annotate', ['tool:note', 'tool:ink'], { importance: 2 });

Sections#

start, center and end are the toolbar’s left, middle and right. The center stays centered in the space the other two leave. In a right-to-left language, start and end swap sides.

orientation="vertical" stands the toolbar on its side, for a rail beside the pages: start, center and end become its top, middle and bottom, and it fits the height you give it, so give it one. Every command draws at 'icon', so put its label in a tooltip. Groups fold and items move into “More” the same way, and the default menus open beside it.

toolbar-vertical.tsx
import { Toolbar, group } from '@embedpdf/react/toolbar';
import { Icon } from './icon';

const rail = {
  id: 'rail',
  sections: {
    start: [group('tools', ['tool:pointer', 'tool:pan', 'tool:highlight'])],
    end: [group('zoom', ['zoom:in', 'zoom:out'])],
  },
};

export function ToolRail() {
  return (
    <Toolbar
      bar={rail}
      orientation="vertical"
      aria-label="Tools"
      style={{ height: '100%' }}
      renderCommand={(command, _variant, run) => (
        <button onClick={run} disabled={!command.enabled} aria-pressed={command.active} title={command.label}>
          <Icon name={command.icon} />
        </button>
      )}
    />
  );
}

Your own items#

Not everything is a command: a page number input, a search box. Put a custom item in the bar and draw it:

toolbar-custom.tsx
import { Toolbar, custom, group } from '@embedpdf/react/toolbar';
import { PageNumberInput } from './page-number-input';
import { renderCommand } from './render-command';

const bar = {
  id: 'main',
  sections: {
    center: [group('page', [custom('page-number', 'page:go-to', { variants: ['full', 'compact'] })])],
  },
};

export function PageToolbar() {
  return (
    <Toolbar
      bar={bar}
      renderCommand={renderCommand}
      renderCustom={{ 'page-number': (variant) => <PageNumberInput compact={variant === 'compact'} /> }}
    />
  );
}

Loading live preview…

import { useEffect, useState } from 'react';
import { Viewer, DocumentGate } from '@embedpdf/react/runtime';
import type { OpenInput } from '@embedpdf/react/runtime';
import { Stage, stagePlugin, useStage, useStageState } from '@embedpdf/react/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/react/render';
import { commandsPlugin, standardCommands, useCommands } from '@embedpdf/react/commands';
import { Toolbar, custom, group, item } from '@embedpdf/react/toolbar';
import type { BarSchema } from '@embedpdf/react/toolbar';
import type { ResolvedCommand } from '@embedpdf/react/commands';
import { localEngine } from '@embedpdf/engine';

import './custom-item.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()) };
};

// The page number is an item of your own: it gets smaller first, and in the "More" menu it's the
// 'page:go-to' command.
const bar: BarSchema = {
  id: 'main',
  sections: {
    start: [group('zoom', ['zoom:out', 'zoom:in', item('zoom:fit-width', { importance: 2 })])],
    center: [
      group('pages', [
        'page:previous',
        custom('page-number', 'page:go-to', { variants: ['full', 'compact'] }),
        'page:next',
      ]),
    ],
    end: [group('document', [item('document:download', { variants: ['icon+label', 'icon'] })])],
  },
};

const ICONS: Record<string, string> = {
  'previous-page': '‹',
  'next-page': '›',
  'zoom-out': '−',
  'zoom-in': '+',
  'fit-width': '↔',
  download: '↓',
};

const renderCommand = (command: ResolvedCommand, variant: string, run: () => void) => (
  <button
    type="button"
    className="button"
    title={command.label}
    aria-label={command.label}
    disabled={!command.enabled}
    onClick={run}
  >
    <span aria-hidden>{ICONS[command.icon ?? ''] ?? '…'}</span>
    {variant === 'icon+label' && <span>{command.label}</span>}
  </button>
);

// The item itself: "Page 3 of 120", or "3 / 120" when there's less room.
function PageNumber({ compact }: { compact: boolean }) {
  const stage = useStage();
  const { currentPageIndex, pageCount } = useStageState();
  const [typed, setTyped] = useState<string | null>(null);

  return (
    <label className="page-number">
      {!compact && 'Page'}
      <input
        className="page-input"
        inputMode="numeric"
        aria-label="Page number"
        value={typed ?? String(currentPageIndex + 1)}
        onFocus={(event) => event.target.select()}
        onChange={(event) => setTyped(event.target.value)}
        onBlur={() => setTyped(null)}
        onKeyDown={(event) => {
          if (event.key !== 'Enter') return;
          const number = Number(typed);
          if (Number.isInteger(number) && number >= 1) stage.goToPage(number - 1);
          setTyped(null);
        }}
      />
      {compact ? `/ ${pageCount}` : `of ${pageCount}`}
    </label>
  );
}

// 'page:go-to', a command of your own: it opens a small "Go to page" form.
function GoToPage() {
  const commands = useCommands();
  const [open, setOpen] = useState(false);

  useEffect(
    () =>
      commands.registerCommand({
        id: 'page:go-to',
        label: 'Go to page…',
        run: () => setOpen(true),
      }),
    [commands],
  );

  if (!open) return null;
  return (
    <div className="go-to" role="dialog" aria-label="Go to page">
      <PageNumber compact={false} />
      <button type="button" className="button" onClick={() => setOpen(false)}>
        Done
      </button>
    </div>
  );
}

export default function App() {
  const [width, setWidth] = useState(100);

  return (
    <Viewer engine={engine} plugins={plugins} initialDocuments={[{ source: ebook }]}>
      <DocumentGate fallback={<p className="loading">Loading…</p>}>
        <label className="width">
          Toolbar width
          <input
            type="range"
            min={30}
            max={100}
            value={width}
            onChange={(event) => setWidth(Number(event.target.value))}
          />
          <output>{width}%</output>
        </label>
        <div className="frame" style={{ width: `${width}%` }}>
          <Toolbar
            bar={bar}
            renderCommand={renderCommand}
            renderCustom={{
              'page-number': (variant) => <PageNumber compact={variant === 'compact'} />,
            }}
          />
        </div>
        <GoToPage />
        <Stage className="stage">{() => <RenderLayer />}</Stage>
      </DocumentGate>
    </Viewer>
  );
}

The second argument, 'page:go-to' (a command of your own that opens a “Go to page” dialog), is what the item becomes in the “More” menu when there’s no room for it. variants lets it get smaller first, like a command’s variants.

renderCustom has one renderer per item name. A renderer that returns undefined draws the item’s command as a button instead. An item with no renderer is a native <slot name> element with that button inside: in a web component, the host’s children for that slot show there.

The first element in your item that takes focus is where the toolbar’s arrow keys stop. The rest of its controls are your item’s own, and a text field keeps Left and Right for its caret.

Draw every part#

PropDraws
renderCommandA button, for a command and its variant
renderCustomYour own items, by name
renderCollapsedA folded group: its button and its menu
renderSeparatorThe line between groups
renderOverflowTriggerThe “More” button
renderOverflowMenuThe “More” menu, with its sections, items and the call to run each one

Every prop you leave out has a plain default, so you can start with renderCommand and add the rest as you style them. renderCollapsed and renderGroupTrigger can return undefined for a group to leave it to the default. gap and separatorWidth set the spacing, and className and style go on the toolbar itself. orientation stands it on its side (Sections), and aria-label names it for a screen reader when the page has more than one toolbar.

Whatever you draw, the toolbar is one stop for the Tab key. The arrow keys along it move between its items, past disabled ones, and Home and End go to its ends. The default menus open on their first row: the arrow keys move, typing a letter jumps to the row that starts with it, Enter picks, and Escape closes the menu and puts focus back on its button.

A toolbar per mode#

Most viewers change the toolbar with what people do: annotation tools while annotating, field tools while building a form. Keep a bar per mode and pick one from your state:

toolbar-mode.tsx
import { useInteractionState } from '@embedpdf/react/interaction';
import { Toolbar } from '@embedpdf/react/toolbar';
import { formBar, mainBar } from './bars';
import { renderCommand } from './render-command';

export function ModeToolbar() {
  const { activeToolId } = useInteractionState();
  const bar = activeToolId === 'form-edit' ? formBar : mainBar;

  return <Toolbar bar={bar} renderCommand={renderCommand} />;
}

Loading live preview…

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, useInteraction } from '@embedpdf/react/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/react/selection';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/react/annotation';
import { commandsPlugin, standardCommands } from '@embedpdf/react/commands';
import type { ResolvedCommand } from '@embedpdf/react/commands';
import { Toolbar, group } from '@embedpdf/react/toolbar';
import type { BarSchema } from '@embedpdf/react/toolbar';
import { localEngine } from '@embedpdf/engine';

import './modes.css';

const engine = localEngine();
const plugins = [
  stagePlugin(),
  renderPlugin(),
  interactionPlugin(),
  selectionPlugin(),
  annotationPlugin(),
  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 bar per mode.
const viewBar: BarSchema = {
  id: 'view',
  sections: {
    start: [group('tools', ['tool:pointer', 'tool:pan'])],
    center: [
      group('pages', ['page:previous', 'page:next']),
      group('zoom', ['zoom:out', 'zoom:in']),
    ],
  },
};

const annotateBar: BarSchema = {
  id: 'annotate',
  sections: {
    start: [group('markup', ['tool:highlight', 'tool:underline', 'tool:strikeout'])],
    center: [group('draw', ['tool:ink', 'tool:square', 'tool:circle', 'tool:note'])],
    end: [group('edit', ['annotation:delete'])],
  },
};

type Mode = 'view' | 'annotate';
const BARS: Record<Mode, BarSchema> = { view: viewBar, annotate: annotateBar };
const TOOL_OF_MODE: Record<Mode, string> = { view: 'pointer', annotate: 'highlight' };

const renderCommand = (command: ResolvedCommand, _variant: string, run: () => void) => (
  <button
    type="button"
    className="button"
    disabled={!command.enabled}
    aria-pressed={command.active}
    onClick={run}
  >
    {command.label}
  </button>
);

function ModeToolbar() {
  const interaction = useInteraction();
  const [mode, setMode] = useState<Mode>('view');

  // Each mode starts with its own tool.
  const switchTo = (next: Mode) => {
    setMode(next);
    interaction.activateTool(TOOL_OF_MODE[next]);
  };

  return (
    <div className="chrome">
      <div className="modes" role="tablist" aria-label="Mode">
        {(['view', 'annotate'] as const).map((each) => (
          <button
            key={each}
            type="button"
            role="tab"
            className="mode"
            aria-selected={mode === each}
            onClick={() => switchTo(each)}
          >
            {each === 'view' ? 'View' : 'Annotate'}
          </button>
        ))}
      </div>
      <Toolbar bar={BARS[mode]} className="toolbar" renderCommand={renderCommand} />
    </div>
  );
}

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

Good to know#

Commands keep the buttons right#

Because the items are commands, a button disables itself when its command can’t run, shows as pressed when it’s active, and hides when the command isn’t visible, with no code in the toolbar. Change a permission or open a document, and the toolbar follows.

A group with dependent: true, like undo and redo, shows only while something in the bar that isn’t dependent shows: a bar left with only those draws nothing.

Labels in the user’s language#

A command’s label comes through translations, so the toolbar is in the user’s language and re-measures when it changes.

The default look#

When you don’t draw a part yourself, the toolbar draws a plain one. Its colors come from CSS only, because you’re meant to replace them: --epdf-toolbar-surface (the “More” menu and folded groups, #ffffff), --epdf-toolbar-border (rgb(0 0 0 / 0.15)) and --epdf-toolbar-active (a pressed or open button, rgb(0 0 0 / 0.12)).

Was this page helpful?

Your feedback goes directly to the documentation team.