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.
Headless — registering the preset is all the wiring there is. For a visible list, add the cheatsheet dialog.
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#
| Action | Shortcut | Category |
|---|---|---|
| Next page | PgDn | Navigation |
| Previous page | PgUp | Navigation |
| First page | Home | Navigation |
| Last page | End | Navigation |
| Find | Ctrl F | Navigation |
| Zoom in | Ctrl + | Zoom |
| Zoom out | Ctrl - | Zoom |
| Fit page | Ctrl 0 | Zoom |
| Show/hide panels | Ctrl \ | View |
| Undo | Ctrl Z | Editing |
| Redo | Ctrl Shift Z | Editing |
| Ctrl P | Document | |
| Save | Ctrl S | Document |
| 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:
| Viewer | Rotate | Costs |
|---|---|---|
| Chrome | Ctrl [ / ] | Back and Forward, on macOS. Chrome itself mis-fires it on non-US layouts. |
| pdf.js | R / Shift R | Square, in the annotation preset. |
| Acrobat | Ctrl Shift + / - | Nothing here — but nobody outside Acrobat knows it. |
| macOS Preview | ⌘ L / R | Nothing 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.
| Action | Shortcut | Category |
|---|---|---|
| Select | V | Tools |
| Hand | H | Tools |
| Square | R | Tools |
| Circle | O | Tools |
| Line | L | Tools |
| Arrow | Shift L | Tools |
| Image | I | Tools |
| Free Text | T | Tools |
| Callout | Shift T | Tools |
| Highlight | M | Tools |
| Underline | U | Tools |
| Strikeout | S | Tools |
| Pen | P | Tools |
| Ink Highlighter | Shift P | Tools |
| Comment | C | Tools |
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:
| Action | Shortcut | Category |
|---|---|---|
| Delete selection | Backspace or Del | Editing |
| Deselect | Esc | Editing |
| Nudge 1 pt | ↑ ↓ ← → | Editing |
| Nudge 10 pt | Shift ↑ ↓ ← → | Editing |
| Group | Ctrl G | Editing |
| Ungroup | Ctrl Shift G | Editing |
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.
Esc is also how Base UI dismisses a popover or dialog, and neither handler stops the other — so pressing it with a colour picker open closes the picker and deselects. Bind only the chrome preset if that matters more than the deselect.
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:
| Gate | Means | Example | Cheatsheet |
|---|---|---|---|
disabled | Not right now | Nothing is selected; the document denies ModifyAnnotations | Keeps a row |
visible | Not in this app ever | The annotation plugin was never registered; no search UI is mounted to hear | Drops 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:
| Mounted | Cheatsheet |
|---|---|
<PdfViewer /> alone | Navigation, Zoom, Document, Help — the browser-chord set |
| … plus the annotation plugin | Tools and Editing appear |
| … plus a search UI | Find appears |
| … plus a panel layout | View 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.
Prefer picking the presets you want over disabling categories. Composing the registration is the declarative route and reads at the call site; the kill-switch is for a category you need to take away later, or one that spans presets.
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.
The parser splits chords on +, so the literal + key can't be bound — use
= instead, as zoom-in does. There's no mod alias, so spell out both the
ctrl+ and meta+ variant of every chord.
API Reference#
createKeyboardShortcutsRegistration#
Registers the commands plugin with one or more presets, merged in order. Returns
a registration for <PdfViewer />'s plugins prop.
| Parameter | Type | Default | Description |
|---|---|---|---|
presets | PdfShortcutPreset | PdfShortcutPreset[] | Both defaults | The 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.
| Export | Description |
|---|---|
PDF_SHORTCUTS_OPEN_EVENT | Opens the cheatsheet. Dispatched on ?. |
PDF_SEARCH_OPEN_EVENT | Takes the search query field. Dispatched on ⌘ F. |
registerPdfSearchTarget() | Marks a search UI as able to answer ⌘ F. Returns its own remover. |
PDF_PANELS_TOGGLE_EVENT | Collapses 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.
On This Page
AboutInstallationUsageThe chrome presetFindShow/hide panelsRotate and fullscreenFixed zoom levels, and SpaceThe annotation presetSharing keys with the pageWhat each app getsDropping a section you do haveBinding one preset, or your ownAdding your own shortcutsAPI ReferencecreateKeyboardShortcutsRegistrationEventsPdfShortcutsDialog