0

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

Two parts that ship together. keyboard-shortcuts binds the keyboard to a <PdfViewer />, in two presets:

  • pdfChromeShortcutPreset — what the browser's own PDF viewer does. Zoom, page navigation, find, print, save, undo/redo.
  • 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.

<PdfShortcutsDialog /> is the optional ?-triggered cheatsheet that lists whatever's bound, the way Linear and GitHub do.

Both presets are bound by default, and they're built to layer rather than compete: every annotation command is gated on live state, so Backspace and the arrows only belong to the editor while something is actually selected and otherwise fall straight through to the browser. That's what lets one registration serve a reader and an editor alike.

Everything sits on @embedpdf/plugin-commands: the shortcuts are real commands with a global keydown listener that ignores typing in inputs. Presets live in a pdfShortcutPresets map, so you can add your own and swap it in without touching the wiring. Every chord binds both its Ctrl and variant; the cheatsheet renders the right one per platform.

Installation

pnpm dlx shadcn@latest add pdfcn/keyboard-shortcuts

Optional cheatsheet dialog:

pnpm dlx shadcn@latest add pdfcn/shortcuts-dialog

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>

That's it — the shortcuts are live. For a discoverable list, drop the cheatsheet into a toolbar:

<PdfToolbar>
  <div className="ml-auto">
    <PdfShortcutsDialog />
  </div>
</PdfToolbar>

It also opens on ? from anywhere in the viewer. To rely on that alone — no visible button — render it without a trigger:

<PdfShortcutsDialog trigger={null} />

The chrome preset

ActionShortcutCategory
Next pagePgDnNavigation
Previous pagePgUpNavigation
First pageHomeNavigation
Last pageEndNavigation
FindCtrl FNavigation
Zoom inCtrl +Zoom
Zoom outCtrl -Zoom
Fit pageCtrl 0Zoom
UndoCtrl ZEditing
RedoCtrl Shift ZEditing
PrintCtrl PDocument
SaveCtrl SDocument

On macOS, substitute for Ctrl. Undo/redo need the annotation plugin; print and save use the plugins the viewer registers by default.

Find

Ctrl F is the one chord here that takes a key the browser already had a use for, and it's worth taking. Pages are painted as images, so there is 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. It reports itself disabled unless a search UI is mounted, and — as with Sharing keys with the page — a disabled command hands the chord straight back to the browser. 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.

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, so the key was never free. 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 also once-a-document operations already one click away, on <PdfRotateButton /> and <PdfFullscreenToggle />. A chord earns its collision by how often it saves the trip; these don't. If your users disagree, add either as your own command — rotate is the worked example below.

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
ImageITools
Free TextTTools
HighlightMTools
PenPTools
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.

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

ActionShortcutCategory
Delete selectionBackspace or DelEditing
DeselectEscEditing
Nudge 1 pt Editing
Nudge 10 ptShift + arrowEditing
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 pdfcn:comment-draft:close instead and <PdfCommentDraftLayer> answers, which is what makes Esc behave the same on a comment you're writing as on one you've posted. With the layer not installed the branch is inert — nothing registers, and Deselect resolves exactly as it did before.

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 therefore gated on live state, and the commands plugin's keydown handler bails out before preventDefault() when a command resolves disabled. So a gate here doesn't just skip the action, it hands the key back to the browser.

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 away from the browser's text selection, which is a reader's most ordinary gesture; and there's no duplicate, because the annotation plugin exposes no primitive for it.

Nudging a multi-selection lands as one history entry per annotation rather than one for the move — the annotation scope has a batch delete but no batch move, so undo walks back through them.

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 disabled — a predicate re-run on every store change — which is how the annotation preset shares Backspace and the arrows with the page. Keep it to a few property reads.

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 — and that's exactly what a cheatsheet or a find bar is made of. So the command dispatches a window event and the component listens, which keeps the arrow pointing one way: 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.

They live in pdfcn/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, which is where you'd look for them.

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

Reads the live command registry, so it lists exactly what's bound. Extends Dialog, so open, defaultOpen, and onOpenChange pass through.

PropTypeDefaultDescription
triggerReact.ReactElement | nullkeyboard buttonElement that opens the dialog. null renders none — ? still opens it.
categoryOrderstring[]Nav, Zoom, …Category order, top to bottom. Unlisted categories trail after.

Opens on ? whether or not it has a trigger, by listening for the PDF_SHORTCUTS_OPEN_EVENT the preset's help command dispatches.