The keyboard shortcuts playbook: Taking control of keyboard events in Nutrient Web SDK
Table of contents
Implementing custom keyboard shortcuts in web apps can get tricky, especially when you want to have a different way of handling things compared to the default input of Nutrient Web SDK. This guide walks through six reliable patterns for predictable, conflict-free keyboard handling that work across Shadow DOM and iframe setups.
The examples listed here have been tested with Nutrient Web SDK 1.8.0. All code examples are production-ready and available to test immediately in our Playground.
Who this is for
This article is for developers who need to know how to:
- Stop keyboard events from firing twice.
- Choose exactly which keys the viewer reacts to.
- Add their own shortcuts without breaking the viewer’s standard features.
All examples work in both Shadow DOM and iframe setups. The patterns behave the same because they intercept keyboard events before implementation details matter.
Essential concepts: How events actually work
Before diving into the code, it’s important to first establish the foundation that makes these patterns work.
Event propagation: The three phases
When you press a key, the browser processes that event in three distinct phases:
- Capturing phase — The event propagates through the target’s ancestor nodes, starting from the window and diving all the way down to the target element.
- Target phase — The event reaches the actual element that triggered it.
- Bubbling phase — The event is propagated back up through the target’s ancestors in reverse order.
By default, addEventListener listens during the bubbling phase, meaning your handler runs after the event reaches its target. But for keyboard shortcuts, you want the capturing phase (addEventListener(..., true)). This lets your handlers run before the viewer’s handlers, giving you first dibs on every keystroke.
Integration models: Shadow DOM and iframe
Nutrient Web SDK can be embedded in two main ways, each with its own event behavior.
Shadow DOM:
- The viewer renders inside a Shadow DOM tree attached to your page. This isolates its internal DOM from the rest of your app.
- When keyboard events cross this shadow boundary, browsers perform retargeting; they change
event.targetto reference the shadow host instead of the internal element. If you need the actual internal element, useevent.composedPath()[0]instead ofevent.target. - If you attach listeners both to the main
documentand inside the viewer’s shadow root, you may see the same key event fire twice. Keep your listener in one place to avoid duplication.
iframe:
- The viewer runs in its own document context, completely separate from the host page.
- Because the iframe has its own document, your keyboard listeners must be attached to
instance.contentDocument(not the host page).
Choosing your listener target
The critical rule is to attach your listener to one place, never both:
// For Shadow DOM:document.addEventListener("keydown", handler, true);
// For iframe:instance.contentDocument.addEventListener("keydown", handler, true);All patterns shown below work identically for both models — just swap the document reference.
The six essential patterns
Each pattern solves a specific keyboard handling challenge with its relevant code snippet, and you can try every single one right now.
Every example includes a link to the Playground that opens a live Nutrient Web SDK environment prefilled with the code. There’s no setup, no local project, and no fiddling. Just click, run, and start tinkering with the shortcuts handling code directly in your browser.
Whether you want to hijack Control-P, test Escape behavior, or block Delete keys, the Playground makes it effortless to see how each pattern behaves in a real viewer session.
1. Single-listener pattern (no duplicate events)
The problem — The same keystroke can trigger listeners multiple times as events retarget across boundaries.
The solution — Attach exactly one listener in the capturing phase.
// Your viewer configuration.const config = { container: "#viewer", document: "document.pdf", // ... your other options.};
NutrientViewer.load(config).then((instance) => { // For Shadow DOM: use `document`. // For iframe: use `instance.contentDocument`. document.addEventListener( "keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "1") { e.preventDefault(); instance.setViewState((v) => v.set("interactionMode", NutrientViewer.InteractionMode.INK), ); } }, true, // Capture phase. );});When to use — This is your foundation. Start here, and add more shortcuts following the same pattern (Control-2, Control-3, etc.).
Tip — If the viewer is embedded in an iframe, use instance.contentDocument for event handling. Otherwise, use document. Always ensure you only attach listeners to one of these targets to avoid duplicate events.
Try this example in our Playground.
2. Silencing default shortcuts: Override print
The problem — Your app has a custom Export PDF button, but users keep pressing Control-P and triggering the browser’s print dialog instead.
The solution — Intercept Control-P before either the browser or viewer sees it.
NutrientViewer.load(config).then(() => { document.addEventListener( "keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "p") { e.preventDefault(); // Stop browser print. e.stopImmediatePropagation(); // Stop viewer print.
// Optional: Trigger your custom export logic here. // handleCustomExport(); } }, true, );});When to use — When you need to replace standard shortcuts with custom workflows. Keep the logic focused by only blocking the specific keys you’re replacing.
Important — This overrides the default SDK behavior. Ensure you provide proper checks and equivalent functionality before removing standard shortcuts users expect.
Try this example in our Playground.
3. Modal priority: Make escape close your overlay first
The problem — You show a confirmation modal over the viewer, and the user presses Escape, expecting the modal to close, but the viewer’s toolbar collapses instead.
The solution — Intercept Escape when your modal is active, and restore normal behavior when it closes.
NutrientViewer.load(config).then(() => { let modalActive = false; // Set this to `true` when your modal opens.
const handleEscape = (e) => { if (e.key === "Escape" && modalActive) { e.stopImmediatePropagation(); // Viewer never hears it. console.log("Close your modal here"); // modalActive = false; // Optional depending on your UI. } };
document.addEventListener("keydown", handleEscape, true);
// In your UI code: // modalActive = true; // When opening the modal. // modalActive = false; // When closing the modal.});When to use — Any time you layer a user interface (UI) on top of the viewer — modals, dialogs, overlays. This ensures your UI gets priority without permanently breaking the viewer’s escape behavior.
Key detail — Make sure to only hijack Escape when modalActive is true. This conditional approach means the viewer works normally the rest of the time.
Try this example in our Playground.
4. Tool switcher: Keyboard shortcuts without focus loss
The problem — Users want to switch annotation tools via keyboard (like Photoshop’s tool shortcuts), but clicking a toolbar button steals focus from the document. Now they have to click back into the document before annotating.
The solution — Change the tool and immediately blur the active button so focus stays on the canvas.
NutrientViewer.load(config).then((instance) => { const blurActiveBtn = () => { instance.contentDocument .querySelector(".NutrientViewer-Tool-Button-active") ?.blur(); };
document.addEventListener( "keydown", (e) => { if (!e.ctrlKey && !e.metaKey) return; switch (e.key) { case "1": e.preventDefault(); instance.setViewState((v) => v.set("interactionMode", NutrientViewer.InteractionMode.INK), ); setTimeout(blurActiveBtn, 0); break; case "2": e.preventDefault(); instance.setViewState((v) => v.set( "interactionMode", NutrientViewer.InteractionMode.SHAPE_RECTANGLE, ), ); setTimeout(blurActiveBtn, 0); break; case "3": e.preventDefault(); instance.setViewState((v) => v.set("interactionMode", NutrientViewer.InteractionMode.TEXT), ); setTimeout(blurActiveBtn, 0); break; } }, true, );});When to use — For power users who annotate rapidly and want Photoshop-style tool switching. This pattern allows users to switch between annotation tools via keyboard shortcuts without losing focus on a document, streamlining workflows for reviewers who frequently cycle through tools.
Why setTimeout(..., 0)? — The viewer’s internal focus routine runs synchronously. By deferring the blur to the next event loop tick, this lets the viewer finish its focus logic first and then immediately unfocus. This is required for the pattern to work at all: The viewer runs its own synchronous focus logic, and deferring the blur with setTimeout(..., 0) ensures the viewer finishes that routine before removing focus.
Try this example in our Playground.
5. Disable delete keys without breaking the UI
The problem — When you display a modal or overlay on top of the viewer, pressing Escape often collapses the viewer’s toolbar instead of closing your modal. Both your app and the viewer listen for the same key, and the viewer wins the race because its handler fires first. This can confuse users who expect Escape to dismiss what’s visually on top.
The solution —
- Intercept the Escape key while your modal is active so your app closes it before the event reaches the viewer.
- When the modal closes, restore the normal Escape behavior.
- This approach ensures consistent, predictable keyboard interaction; your UI responds first, and the viewer remains unaffected underneath.
NutrientViewer.load(config).then((instance) => { instance.addEventListener("annotations.willChange", (e) => { if (e.reason === "deleteStart") e.cancelAllow(); });
document.addEventListener( "keydown", (e) => { if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); e.stopImmediatePropagation(); } }, true, );});When to use — In workflows where deletion requires authorization (e.g. only managers can delete comments, or deletions must be logged for compliance). This pattern blocks accidental keyboard deletions while keeping your custom Delete with Approval buttons functional.
Important — cancelAllow() blocks all deletion attempts — keyboard, toolbar, and API calls. Use it sparingly to avoid locking legitimate workflows. Moreover, to hide Delete buttons on the toolbar as well, here’s a CSS line you can add to take care of this in the Playground link below:
.PSPDFKit-Annotation-Toolbar [class*="-Toolbar-Button-Delete"] { display: none;}Try this example in our Playground.
6. Pause all input during critical modals
The problem — You’re showing a permissions dialog or payment confirmation and users must make a decision before continuing. But they can still scroll the document, zoom with keyboard shortcuts, or accidentally trigger annotations behind your modal.
The solution — Temporarily block everything — keyboard, mouse, wheel, gestures — and restore it all when the modal closes.
NutrientViewer.load(config).then((instance) => { const getRoot = () => // Viewer root inside Shadow DOM or iframe. instance.contentDocument?.querySelector(".PSPDFKit-Root") || // Fallback for non-shadow setups. document.querySelector(".PSPDFKit-Root") || document;
let paused = false; let cleanupFns = []; let prevTouchAction = null;
// Stop any input reaching the viewer. const kill = (ev) => { ev.preventDefault(); ev.stopImmediatePropagation(); ev.stopPropagation(); };
function addListener(el, type, handler, opts) { el.addEventListener(type, handler, opts); cleanupFns.push(() => el.removeEventListener(type, handler, opts)); }
function pauseViewer() { if (paused) return;
const root = getRoot();
// Block keyboard, mouse, wheel, and touch inside the viewer. const events = [ "mousedown", "mouseup", "click", "dblclick", "contextmenu", "pointerdown", "pointermove", "pointerup", "dragstart", "keydown", "keyup", "keypress", "wheel", "touchstart", "touchmove", "touchend", ];
events.forEach((event) => { addListener(root, event, kill, true); // Capture phase. });
// Disable pinch-zoom and panning gestures. if (root instanceof HTMLElement) { prevTouchAction = root.style.touchAction; root.style.touchAction = "none"; cleanupFns.push(() => { root.style.touchAction = prevTouchAction ?? ""; prevTouchAction = null; }); }
paused = true; }
function resumeViewer() { if (!paused) return;
for (let i = cleanupFns.length - 1; i >= 0; i--) { try { cleanupFns[i](); } catch {} }
cleanupFns = []; paused = false; }
// Optional helpers your modal can call. let criticalModalOpen = false;
function showCriticalModal() { criticalModalOpen = true; pauseViewer(); }
function closeCriticalModal() { criticalModalOpen = false; resumeViewer(); }
// Allow Escape to close your modal if needed. document.addEventListener( "keydown", (e) => { if (e.key === "Escape" && criticalModalOpen) { closeCriticalModal(); } }, true, );
// Prevent browser-level zoom (Control/Command + / - / =) while critical UI is active. document.addEventListener( "keydown", (e) => { if (!criticalModalOpen) return;
const isZoomShortcut = (e.metaKey || e.ctrlKey) && (e.key === "+" || e.key === "=" || e.key === "-");
if (isZoomShortcut) { e.preventDefault(); } }, true, );});When to use — For truly critical UIs that demand user attention, such as payment confirmations, destructive action warnings, and permission requests. This pattern is absolute: It shuts down all input. Use it sparingly.
Why this works — By attaching kill handlers at the root, document, and window levels in the capturing phase, you’re creating a three-layer shield, and nothing gets through. Setting touch-action: none disables built-in browser gestures like pinch-zoom or panning; this ensures touch and pointer events are entirely controlled by your handlers instead of the browser’s gesture recognizer.
Critical reminder — Remember to call resumeViewer() when closing your modal. Forgetting this leaves your viewer permanently frozen.
Debugging checklist
If your shortcuts aren’t working, follow this checklist.
- Are you using capture phase? Check for
trueas the third parameter inaddEventListener. - Did you forget
preventDefault()? Default actions will still fire without it. - Are you listening to both
documentandcontentDocument? Pick one to avoid duplicates. - Did you test in the same setup as production? Differences between development and production setups can affect keyboard behavior in unexpected ways.
- Are other handlers stealing your events? Check your browser DevTools Event Listeners panel.
- Are you attaching listeners after initialization?
instance.contentDocument(or the shadow root) becomes available once the viewer has loaded. Attach viewer-specific shortcuts there when the intent is to affect only the viewer.
Key principle — Always clean up listeners when your component unmounts. Orphaned listeners cause memory leaks and unpredictable behavior.
Reference documentation
- NutrientViewer.setViewState — Update viewer state
- ViewState.interactionMode — Control active tool/mode
- annotations.willChange Event — Intercept annotation changes
- ViewState guide — Comprehensive guide to managing viewer state
- MDN: Event Phases(opens in a new tab) — Deep dive into capturing, target, and bubbling phases
Wrapping up
Understanding event capture and how embedding model boundaries affect event flow gives you complete control over keyboard interactions in Nutrient Web SDK. These six patterns cover the most common scenarios, but the principles extend to any custom keyboard behavior you need.
Five key takeaways:
- Always use the capturing phase (
trueparameter) for reliable keyboard shortcuts. - Choose one listener target (
documentorcontentDocument) to avoid duplicates. - The patterns work identically for both Shadow DOM and iframe implementations.
- Clean up listeners when components unmount to prevent leaks.
- Test with proper checks before overriding default SDK behavior.
Whether you’re building power user annotation workflows or custom modals, or you just need Control-1 to switch to the ink tool, these patterns provide a solid foundation that’s predictable, conflict-free, and easy to extend.
Have questions or unique keyboard handling challenges? Reach out to our Support team.