PDF Keyboard Shortcuts

Two keyboard presets for the viewer — browser-viewer chords and design-tool annotation editing — plus an optional ?-triggered cheatsheet.

"use client";

import { createPluginRegistration } from "@embedpdf/core";

About

Keyboard bindings for a <PdfViewer />, in two presets:

  • pdfChromeShortcutPreset — what the browser's own PDF viewer does. Zoom, page navigation, find, print, save, undo/redo, plus ⌘ \ to clear the panels away.
  • pdfAnnotationShortcutPreset — what a design tool does. V and H plus a letter per tool, Backspace to delete the selection, Esc to back out, arrows to nudge, ⌘ G to group.

Both are bound by default, and they layer rather than compete. Every command declares what it needs, so Backspace and the arrows belong to the editor only while something is selected — otherwise they fall through to the browser — and a tool key with no annotation plugin behind it is never bound at all. See What each app gets.

Built on @embedpdf/plugin-commands: the shortcuts are real commands with a global keydown listener that ignores typing in inputs. Every chord binds both its Ctrl and ⌘ variant.

Installation

pnpm dlx shadcn@latest add @pdfviewcn/keyboard-shortcuts

The cheatsheet dialog is a separate, optional install.

Usage

Register the preset from module scope — a fresh array on every render tears the engine down and rebuilds it — and pass it to the viewer:

import { createKeyboardShortcutsRegistration } from "@/lib/pdf-keyboard-shortcuts";
 
const plugins = [createKeyboardShortcutsRegistration()];
<PdfViewer documents={[{ url: "/sample.pdf" }]} plugins={plugins}>
  <PdfViewerContent />
</PdfViewer>

The shortcuts are live. To make them discoverable, add the cheatsheet dialog: it lists whatever this registration bound, and opens on ?.

The chrome preset

ActionShortcutCategory
Next pagePgDnNavigation
Previous pagePgUpNavigation
First pageHomeNavigation
Last pageEndNavigation
FindCtrl FNavigation
Zoom inCtrl +Zoom
Zoom outCtrl -Zoom
Fit pageCtrl 0Zoom
Show/hide panelsCtrl \View
UndoCtrl ZEditing
RedoCtrl Shift ZEditing
PrintCtrl PDocument
SaveCtrl SDocument
Keyboard shortcuts?Help

On macOS, substitute ⌘ for Ctrl. Every one of these rides a plugin <PdfViewer /> registers by default — zoom, scroll, print, export, history — so the whole table is live out of the box. Strip a plugin out and its rows go with it.

Find

Ctrl F is the one chord here that takes a key the browser already had a use for. Pages are painted as images, so there's no document text in the DOM for find-in-page to reach — left alone, ⌘ F over a viewer opens a find bar that reports no matches on a page full of them.

So the command is gated on there being somewhere to put the key. With no search UI mounted it reports itself invisible rather than disabled: the chord goes straight back to the browser, the cheatsheet doesn't list it, and a viewer dropped into a page of ordinary prose keeps find-in-page for that prose. <PdfSearchPanel /> and <PdfSearchPopover /> register themselves as targets when they mount.

Show/hide panels

⌘ \ is Figma's chord for the same idea, and it's free to take — ⌘ \ and Ctrl \ are unbound in Chrome, Safari, and Firefox on both platforms.

Both side panels move together on one press, and the second press restores what was actually open rather than opening everything. The toolbar deliberately stays put: Figma's ⌘ \ hides the entire UI, but the toolbar is where the panel toggles live, and hiding it too leaves the way back living on a key alone.

Gated like Find. Unless a layout has registered itself as able to answer, the command is invisible — the chord belongs to the host page and the cheatsheet has no View section. The app blocks register themselves; registerPdfPanelsTarget() is there for a layout of your own.

Rotate and fullscreen

Both are bound in every other PDF viewer and deliberately absent here — not for want of a binding, but for want of one binding worth taking a key for. Rotate is spelled four ways by the four viewers people arrive from, and every candidate costs something already spoken for:

ViewerRotateCosts
ChromeCtrl [ / ]Back and Forward, on macOS. Chrome itself mis-fires it on non-US layouts.
pdf.jsR / Shift RSquare, in the annotation preset.
AcrobatCtrl Shift + / -Nothing here — but nobody outside Acrobat knows it.
macOS Preview⌘ L / RNothing here — but it's a Mac-only muscle memory.

Fullscreen is worse: F11 and ⌃ ⌘ F already mean fullscreen and already work. Binding them intercepts a working gesture to do something subtly different — one element rather than the window — and binding anything else leaves two fullscreen gestures with two results.

Both are once-a-document operations already one click away, on <PdfRotateButton /> and <PdfFullscreenToggle />. If your users disagree, add either as your own command — rotate is the worked example below.

Fixed zoom levels, and Space

Acrobat's ⌘ 1 (actual size) and ⌘ 2 (fit width) are out: the digit row switches browser tabs on both platforms, and unlike Find there's no "nothing is listening" state that could hand the key back. Fit width is one click away on <PdfZoomControls />.

Space and Shift Space to page down and up is the most universal convention on this page — Chrome's viewer, Acrobat, and pdf.js all ship it — and it's still left out. The commands plugin's key handler steps aside for <input>, <textarea>, and contenteditable, but not for <button>, so a global Space would preventDefault() on a focused button and break the keyboard's primary way of pressing one — an accessibility regression, not a trade-off. PgUp and PgDn do the same job and are bound.

The annotation preset

Arming a tool takes a single key, the way it does in every design tool — and pressing an armed tool's key again puts the pointer back, matching what clicking the armed button in <PdfAnnotationToolbar /> does.

ActionShortcutCategory
SelectVTools
HandHTools
SquareRTools
CircleOTools
LineLTools
ArrowShift LTools
ImageITools
Free TextTTools
CalloutShift TTools
HighlightMTools
UnderlineUTools
StrikeoutSTools
PenPTools
Ink HighlighterShift PTools
CommentCTools

Every letter is its tool's initial except M: H belongs to the hand in every tool that has one, so the highlighter answers to the marker's letter instead.

Where two tools are variants of one idea, they share a letter and Shift picks the second — Figma's own rule: line and arrow on L, pen and pencil on P. Both land here unchanged; callout follows free text and the ink highlighter follows the pen on the same reading.

Thirteen of the annotation plugin's nineteen tools are bound. The rest are left alone on purpose: squiggly, insert-text, and replace-text are review marks reached from the text selection menu rather than armed cold; polygon and polyline are multi-click tools Figma leaves unbound too; link has no URI editor to open yet; and the five form tools belong to an authoring mode with a toolbar of its own. Redaction's marking modes, signature, and form filling arm no tool at all — two switch how a drag is interpreted or open a modal, and one is a once-per-session mode. Bind any of them with a preset of your own.

Then the editing half, which acts on whatever's selected:

ActionShortcutCategory
Delete selectionBackspace or DelEditing
DeselectEscEditing
Nudge 1 pt↑ ↓ ← →Editing
Nudge 10 ptShift ↑ ↓ ← →Editing
GroupCtrl GEditing
UngroupCtrl Shift GEditing

Esc unwinds in the order you built it up: it drops an armed tool first, then the hand, then the open card or the selection — so one press undoes one decision.

That last step covers an unposted comment as well as a selected annotation. A draft isn't in plugin state, so deselectAnnotation can't reach it; Deselect dispatches pdfviewcn:comment-draft:close instead and <PdfCommentDraftLayer> answers. With the layer not installed the branch is inert.

Sharing keys with the page

Backspace, Esc, and the arrow keys all mean something else when nothing is selected — the arrows scroll, Esc closes whatever's open. Every one of these commands is gated on live state, and the commands plugin's keydown handler bails out before preventDefault() when a command resolves disabled — so a gate hands the key back to the browser rather than swallowing it.

The gates read plugin state directly rather than building a document scope, since disabled is re-resolved for every command on every store change.

The same gating covers permissions: on a document whose ModifyAnnotations flag is denied, every editing command stays disabled and its key keeps its ordinary meaning. See Permissions.

Two chords a design tool has are deliberately absent. Select-all (Ctrl A) would take the key from the browser's text selection, a reader's most ordinary gesture; duplicate has no primitive on the annotation plugin to call.

Nudging a multi-selection lands as one history entry per annotation rather than one for the move, so undo walks back through them one at a time. The annotation scope batches updates and deletes but not moves — the plugin has a batch move internally, it just isn't on the capability, and rebuilding it outside would mean reimplementing the move transform that keeps the appearance stream intact.

What each app gets

The preset is fixed; what it produces isn't. Two things can stop a command, and the difference between them is what lets one registration serve a reader and an editor:

GateMeansExampleCheatsheet
disabledNot right nowNothing is selected; the document denies ModifyAnnotationsKeeps a row
visibleNot in this app everThe annotation plugin was never registered; no search UI is mounted to hearDrops a row

The keyboard treats them identically — the keydown handler bails before preventDefault() on either, so a gated chord always falls through to the browser. The distinction is about what a reader is told. A sheet that dropped Delete the instant you clicked empty space would rewrite itself while being read, so transient state keeps its row; a sheet listing fifteen tool keys in a viewer that can't annotate advertises something that doesn't exist, so that gets dropped.

So the same createKeyboardShortcutsRegistration() call reads differently in each app, with nothing to configure:

MountedCheatsheet
<PdfViewer /> aloneNavigation, Zoom, Document, Help — the browser-chord set
… plus the annotation pluginTools and Editing appear
… plus a search UIFind appears
… plus a panel layoutView appears

The demo at the top of this page is the middle case: it registers annotation but mounts neither a search panel nor a panel layout, so it lists thirty-three rows and no View section, against the app blocks' thirty-five.

Dropping a section you do have

Gating is a statement of fact, not a preference — a command is invisible because its plugin is genuinely absent. To hide a section whose plugin you do register, say so directly: the commands plugin takes a category kill-switch, and the cheatsheet honours it:

import { CommandsPluginPackage } from "@embedpdf/plugin-commands/react";
import { createPluginRegistration } from "@embedpdf/core";
 
// An annotator that keeps undo/redo but arms tools from the toolbar only.
createPluginRegistration(CommandsPluginPackage, {
  commands,
  disabledCategories: ["Tools"],
});

CommandsCapability also exposes disableCategory, enableCategory, and setDisabledCategories to flip one at runtime — a review mode that suspends every editing chord without tearing the registration down.

Binding one preset, or your own

The default binds both presets. Pass an array to choose, or to layer your own on top — later presets win on a shared command id, so reusing one replaces that command:

import {
  createKeyboardShortcutsRegistration,
  pdfChromeShortcutPreset,
} from "@/lib/pdf-keyboard-shortcuts";
 
// A pure reader: browser chords only, no tool keys.
const plugins = [createKeyboardShortcutsRegistration(pdfChromeShortcutPreset)];
// Both defaults, plus your own on top.
const plugins = [
  createKeyboardShortcutsRegistration([
    pdfChromeShortcutPreset,
    pdfAnnotationShortcutPreset,
    myPreset,
  ]),
];

Adding your own shortcuts

A preset is a plain { id, label, commands } object. Each command's action gets the live registry and focused documentId; reach for a plugin's capability the way the defaults do.

import { ROTATE_PLUGIN_ID, type RotatePlugin } from "@embedpdf/plugin-rotate";
 
import {
  createKeyboardShortcutsRegistration,
  pdfAnnotationShortcutPreset,
  pdfChromeShortcutPreset,
  type PdfShortcutPreset,
} from "@/lib/pdf-keyboard-shortcuts";
 
const rotatePreset: PdfShortcutPreset = {
  id: "rotate",
  label: "Rotate",
  commands: {
    "rotate-clockwise": {
      id: "rotate-clockwise",
      label: "Rotate clockwise",
      categories: ["Document"],
      shortcuts: ["ctrl+]", "meta+]"],
      action: ({ registry, documentId }) =>
        registry
          .getPlugin<RotatePlugin>(ROTATE_PLUGIN_ID)
          ?.provides()
          .forDocument(documentId)
          .rotateForward(),
    },
  },
};
 
const plugins = [
  createKeyboardShortcutsRegistration([
    pdfChromeShortcutPreset,
    pdfAnnotationShortcutPreset,
    rotatePreset,
  ]),
];

A command can also carry the two gates. Both are predicates re-run on every store change, so keep each to a few property reads:

// Not part of this app at all — no row in the cheatsheet.
visible: ({ registry }) => registry.getPlugin(ROTATE_PLUGIN_ID) !== null,
// Not right now — greyed by circumstance, but still listed.
disabled: ({ state, documentId }) => !state.core.documents[documentId],

visible is how the tool keys stay out of a reader's sheet; disabled is how the annotation preset shares Backspace and the arrows with the page. Sort your own gates the same way — see What each app gets.

API Reference

createKeyboardShortcutsRegistration

Registers the commands plugin with one or more presets, merged in order. Returns a registration for <PdfViewer />'s plugins prop.

ParameterTypeDefaultDescription
presetsPdfShortcutPreset | PdfShortcutPreset[]Both defaultsThe command sets to bind. Later presets win on a shared command id.

Also exports pdfChromeShortcutPreset, pdfAnnotationShortcutPreset, and pdfShortcutPresets.

Events

A command can reach any plugin, because plugin state is global. What it can't reach is a component's own UI state — whether a dialog is open, which field has focus — which is what a cheatsheet or a find bar is made of. So the command dispatches a window event and the component listens: a preset stays useful with no matching component mounted, a component stays useful with no preset registered, and you can drive either from your own UI by dispatching or listening for the same event.

ExportDescription
PDF_SHORTCUTS_OPEN_EVENTOpens the cheatsheet. Dispatched on ?.
PDF_SEARCH_OPEN_EVENTTakes the search query field. Dispatched on ⌘ F.
registerPdfSearchTarget()Marks a search UI as able to answer ⌘ F. Returns its own remover.
PDF_PANELS_TOGGLE_EVENTCollapses the side panels, or brings them back. Dispatched on ⌘ \.
registerPdfPanelsTarget()Marks a layout as able to answer ⌘ \. Returns its own remover.

They live in @pdfviewcn/events, which carries no dependencies of its own — a search panel shouldn't have to install every plugin this preset touches just to be reachable — and are re-exported here.

Call registerPdfSearchTarget from an effect and return the result as that effect's cleanup. <PdfSearchPanel /> and <PdfSearchPopover /> already do; you only need it when driving search from a UI of your own:

React.useEffect(() => registerPdfSearchTarget(), []);

PdfShortcutsDialog

Documented on its own page — see PDF Shortcuts Dialog.