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.vue
<script setup lang="ts">
import { Toolbar, group, item } from '@embedpdf/vue/toolbar';
import Icon from './Icon.vue';

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' })],
  },
};
</script>

<template>
  <Toolbar :bar>
    <template #command="{ command, variant, run }">
      <button
        :disabled="!command.enabled"
        :aria-pressed="command.active"
        :title="command.label"
        @click="run()"
      >
        <Icon :name="command.icon" />
        <template v-if="variant === 'icon+label'">{{ command.label }}</template>
      </button>
    </template>
  </Toolbar>
</template>

Loading live preview…

<script setup lang="ts">
import { ref } from 'vue';
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { interactionPlugin } from '@embedpdf/vue/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/vue/selection';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/vue/annotation';
import { commandsPlugin, standardCommands } from '@embedpdf/vue/commands';
import { localEngine } from '@embedpdf/engine';
import MainToolbar from './MainToolbar.vue';

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

// Narrow the toolbar to watch it make room: labels go first, then the tools fold, then "More".
const width = ref(100);
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <label class="width">
        Toolbar width
        <input v-model.number="width" type="range" min="30" max="100" />
        <output>{{ width }}%</output>
      </label>
      <div class="frame" :style="{ width: `${width}%` }">
        <MainToolbar />
      </div>
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <SelectionLayer />
          <AnnotationLayer />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>

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.vue
<script setup lang="ts">
import { Toolbar, group } from '@embedpdf/vue/toolbar';
import Icon from './Icon.vue';

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

<template>
  <Toolbar :bar="rail" orientation="vertical" aria-label="Tools" style="height: 100%">
    <template #command="{ command, run }">
      <button
        :disabled="!command.enabled"
        :aria-pressed="command.active"
        :title="command.label"
        @click="run()"
      >
        <Icon :name="command.icon" />
      </button>
    </template>
  </Toolbar>
</template>

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.vue
<script setup lang="ts">
import { Toolbar, custom, group } from '@embedpdf/vue/toolbar';
import ToolbarButton from './ToolbarButton.vue';
import PageNumberInput from './PageNumberInput.vue';

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

<template>
  <Toolbar :bar>
    <template #command="{ command, variant, run }">
      <ToolbarButton :command :variant @click="run()" />
    </template>
    <template #custom="{ name, variant }">
      <PageNumberInput v-if="name === 'page-number'" :compact="variant === 'compact'" />
    </template>
  </Toolbar>
</template>

Loading live preview…

<script setup lang="ts">
import { ref } from 'vue';
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { commandsPlugin, standardCommands } from '@embedpdf/vue/commands';
import { Toolbar } from '@embedpdf/vue/toolbar';
import { localEngine } from '@embedpdf/engine';
import GoToPage from './GoToPage.vue';
import PageNumber from './PageNumber.vue';
import { ICONS, bar } from './bar';

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

const width = ref(100);
</script>

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <label class="width">
        Toolbar width
        <input v-model.number="width" type="range" min="30" max="100" />
        <output>{{ width }}%</output>
      </label>
      <div class="frame" :style="{ width: `${width}%` }">
        <Toolbar :bar="bar">
          <template #command="{ command, variant, run }">
            <button
              type="button"
              class="button"
              :title="command.label"
              :aria-label="command.label"
              :disabled="!command.enabled"
              @click="run()"
            >
              <span aria-hidden="true">{{ ICONS[command.icon ?? ''] ?? '…' }}</span>
              <span v-if="variant === 'icon+label'">{{ command.label }}</span>
            </button>
          </template>
          <template #custom="{ name, variant }">
            <PageNumber v-if="name === 'page-number'" :compact="variant === 'compact'" />
          </template>
        </Toolbar>
      </div>
      <GoToPage />
      <Stage class="stage">
        <template #page><RenderLayer /></template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>

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.

The #custom slot draws every custom item, by name. Where it draws nothing for one (a v-if that doesn’t match), the item’s command shows as a button instead. Without the slot, each item 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
#commandA button, for a command and its variant
#customYour own items, by name
#collapsedA folded group: its button and its menu
#separatorThe line between groups
#overflow-triggerThe “More” button
#overflow-menuThe “More” menu, with its sections, items and the call to run each one

Every slot you leave out has a plain default, so you can start with #command and add the rest as you style them. A slot that draws nothing for a part leaves that part to the default. gap and separator-width set the spacing, and class 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.vue
<script setup lang="ts">
import { computed } from 'vue';
import { useInteractionState } from '@embedpdf/vue/interaction';
import { Toolbar } from '@embedpdf/vue/toolbar';
import { formBar, mainBar } from './bars';
import ToolbarButton from './ToolbarButton.vue';

const { activeToolId } = useInteractionState();
const bar = computed(() => (activeToolId.value === 'form-edit' ? formBar : mainBar));
</script>

<template>
  <Toolbar :bar>
    <template #command="{ command, variant, run }">
      <ToolbarButton :command :variant @click="run()" />
    </template>
  </Toolbar>
</template>

Loading live preview…

<script setup lang="ts">
import { DocumentGate, Viewer } from '@embedpdf/vue/runtime';
import type { OpenInput } from '@embedpdf/vue/runtime';
import { Stage, stagePlugin } from '@embedpdf/vue/stage';
import { RenderLayer, renderPlugin } from '@embedpdf/vue/render';
import { interactionPlugin } from '@embedpdf/vue/interaction';
import { SelectionLayer, selectionPlugin } from '@embedpdf/vue/selection';
import { AnnotationLayer, annotationPlugin } from '@embedpdf/vue/annotation';
import { commandsPlugin, standardCommands } from '@embedpdf/vue/commands';
import { localEngine } from '@embedpdf/engine';
import ModeToolbar from './ModeToolbar.vue';

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

<template>
  <Viewer :engine="engine" :plugins="plugins" :initial-documents="[{ source: ebook }]">
    <DocumentGate>
      <template #fallback><p class="loading">Loading…</p></template>
      <ModeToolbar />
      <Stage class="stage">
        <template #page>
          <RenderLayer />
          <SelectionLayer />
          <AnnotationLayer />
        </template>
      </Stage>
    </DocumentGate>
  </Viewer>
</template>

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.