- PDF Viewer
- PDF Toolbar
- PDF More Actions Menu
- PDF Floating Toolbar
- PDF Document Tabs
- PDF View
- PDF Document Grid
- PDF Document Info
- PDF Page Navigation
- PDF Zoom Controls
- PDF Undo/Redo Buttons
- PDF Keyboard Shortcuts
- PDF Bookmark Sidebar
- PDF Thumbnail Sidebar
- PDF Search Panel
- PDF Attachment List
- PDF Annotation Layer
- PDF Annotation Toolbar
- PDF Annotation Inspector
- PDF Annotation Selection Menu
- PDF Annotation Sidebar
- PDF Comment
- PDF Comment Draft
- PDF Comment Button
- PDF Comment Sidebar
- PDF Redaction Toolbar
- PDF Capture Button
- PDF Form Fill Toggle
- PDF Signature Button
- PDF Stamp Button
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.
The preset is headless — registering it is all the wiring there is. The cheatsheet is a separate, optional install; the shortcuts work without it.
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#
| 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 |
| Undo | Ctrl Z | Editing |
| Redo | Ctrl Shift Z | Editing |
| Ctrl P | Document | |
| Save | Ctrl S | Document |
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:
| 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, 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.
| Action | Shortcut | Category |
|---|---|---|
| Select | V | Tools |
| Hand | H | Tools |
| Square | R | Tools |
| Circle | O | Tools |
| Image | I | Tools |
| Free Text | T | Tools |
| Highlight | M | Tools |
| Pen | 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.
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 + arrow | 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 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.
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 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.
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 — 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.
| 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. |
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.
| Prop | Type | Default | Description |
|---|---|---|---|
trigger | React.ReactElement | null | keyboard button | Element that opens the dialog. null renders none — ? still opens it. |
categoryOrder | string[] | 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.