Plugin Development
A complete guide to creating custom viewer plugins for the VerifyKit React PDF viewer.
This guide covers viewer plugins (
ViewerPlugin) that extend the PDF viewer UI. For core engine plugins (VerifyKitPlugin) that extend the verification engine, see the Plugins guide.
Overview
The VerifyKit viewer uses a plugin architecture where every feature — zoom controls, search, page navigation, toolbar, sidebar — is implemented as a plugin. You can create custom plugins to add your own UI features, keyboard shortcuts, sidebar tabs, toolbar buttons, and document lifecycle handlers.
A viewer plugin is a plain object that implements the ViewerPlugin interface:
interface ViewerPlugin {
name: string
dependencies?: string[]
composedPlugins?: ViewerPlugin[]
// Lifecycle hooks
install?(ctx: ViewerPluginContext): void
onDocumentLoad?(e: DocumentLoadEvent): void
onDocumentUnload?(): void
onPageChange?(e: PageChangeEvent): void
onZoomChange?(e: ZoomChangeEvent): void
onRotationChange?(e: RotationChangeEvent): void
destroy?(): void
// UI contributions
renderToolbarSlot?: Partial<ToolbarSlots>
renderMenuItems?: MenuItemsRender // since 0.7.0 — see Overflow Menu Items
renderContextMenuItems?: ContextMenuItemsRender // unreleased — see Context Menu Items
menuOrder?: number // since 0.7.0 — lower renders earlier (default 50)
menuGroup?: string // since 0.7.0 — section label; divider between sections
sidebarTabs?: SidebarTabDefinition[]
renderOverlay?: (props: OverlayRenderProps) => React.ReactNode
renderRightPanel?: (props: OverlayRenderProps) => React.ReactNode
renderPageOverlay?: (props: PageOverlayRenderProps) => React.ReactNode
transformToolbarSlots?: (slots: ToolbarSlots) => ToolbarSlots
}All properties except name are optional — implement only what your plugin needs.
Import the viewer context as
ViewerPluginContext. The package exports two different context types:PluginContextis the core engine's context (re-exported from@trexolab/verifykit-core, with.config), and the viewer's context is exported under the nameViewerPluginContext. A plainimport { PluginContext }gives you the core one, andctx.storewill not type-check.
Plugin Factory Function
Plugins are typically created using a factory function that accepts options and returns a ViewerPlugin object. This pattern allows users to configure the plugin at creation time:
import type { ViewerPlugin } from '@trexolab/verifykit-react'
interface MyPluginOptions {
greeting?: string
}
function myPlugin(options: MyPluginOptions = {}): ViewerPlugin {
const { greeting = 'Hello' } = options
return {
name: 'my-plugin',
install(ctx) {
console.log(`${greeting} from my-plugin!`)
},
}
}Usage:
import { CoreViewer, VerifyKitProvider } from '@trexolab/verifykit-react'
const plugins = [myPlugin({ greeting: 'Hi' })]
function App() {
return (
<VerifyKitProvider config={{
workerUrl: 'https://unpkg.com/pdfjs-dist@5.5.207/legacy/build/pdf.worker.min.mjs',
}}>
<CoreViewer fileUrl="/doc.pdf" plugins={plugins} />
</VerifyKitProvider>
)
}Lifecycle Hooks
Plugins can respond to viewer events through lifecycle hooks. These are called in the order the plugins are registered.
install(ctx: ViewerPluginContext)
Called once when the plugin is installed into the viewer. This is where you access the store, register shortcuts, and set up any state your plugin needs.
The ViewerPluginContext provides:
| Property | Type | Description |
|---|---|---|
store | ViewerStore | The reactive viewer state store |
getDocument() | () => PDFDocumentProxy | null | Get the currently loaded PDF document |
scrollToPage(n) | (n: number) => void | Scroll the viewer to a specific page |
getScrollContainer() | () => HTMLElement | null | Get the scroll container element |
getViewerContainer() | () => HTMLElement | null | Get the viewer root container |
registerShortcut(s) | (s: KeyboardShortcut) => () => void | Register a keyboard shortcut |
onOpenFile | ((file: File) => void) | undefined | Open file handler from parent |
onRevalidate | (() => void) | undefined | Re-verify all signatures on the in-memory buffer |
t(key) | (key: string) => string | Translation function |
onDocumentLoad(e: DocumentLoadEvent)
Called when a PDF document is successfully loaded. The event contains:
interface DocumentLoadEvent {
document: PDFDocumentProxy // The PDF.js document proxy
numPages: number // Total number of pages
}onDocumentUnload()
Called when the current document is unloaded (e.g., when a new file is opened or the viewer is unmounted).
onPageChange(e: PageChangeEvent)
Called when the visible page changes due to scrolling or navigation.
interface PageChangeEvent {
currentPage: number // New current page (1-based)
previousPage: number // Previous page (1-based)
}onZoomChange(e: ZoomChangeEvent)
Called when the zoom level changes.
interface ZoomChangeEvent {
scale: number
previousScale: number
fitMode: 'none' | 'width' | 'page'
}fitMode is 'none' when the scale is a plain zoom level, 'width' when the page
is fitted to the width of its column, and 'page' when the whole page is fitted.
The same union is exported as the FitMode type.
onRotationChange(e: RotationChangeEvent)
Called when the page rotation changes.
interface RotationChangeEvent {
rotation: 0 | 90 | 180 | 270
previousRotation: 0 | 90 | 180 | 270
}destroy()
Called when the plugin is destroyed (viewer unmount). Clean up any event listeners, timers, or subscriptions here.
Adding Toolbar Slots
Plugins can contribute to named toolbar slots. Each slot is a render function that receives the store and returns React nodes:
function downloadPlugin(): ViewerPlugin {
return {
name: 'download',
renderToolbarSlot: {
Download: ({ store }) => {
const handleDownload = () => {
const { fileUrl, fileName } = store.getState()
if (fileUrl) {
const a = document.createElement('a')
a.href = fileUrl
a.download = fileName || 'document.pdf'
a.click()
}
}
return (
<button onClick={handleDownload} title="Download PDF">
Download
</button>
)
},
},
}
}Available toolbar slots include: SearchPopover, GoToPreviousPage, CurrentPageInput, NumberOfPages, GoToNextPage, ZoomOut, Zoom, ZoomIn, Rotate, CursorTool, OpenFile, Download, Print, ThemeToggle, FontScale, Fullscreen, MoreMenu. You can also define custom slot names.
The slot render function receives ToolbarSlotProps — store, plus
overflowSlots and renderMenuItems (both 0.7.0+, and only meaningful to a
slot that draws the overflow menu).
Overflow Menu Items
Added in 0.7.0. A toolbar has finite width. When a slot does not fit, it is hidden and the More menu is expected to offer the same action — so every plugin declares its own menu entry rather than the menu keeping a hardcoded list.
This matters beyond layout: before 0.7.0 the menu called the download action directly, so disabling the Download plugin still left a working Download entry in the menu. A contribution comes from the plugin itself, so an uninstalled plugin contributes nothing and the entry disappears by construction.
import { MenuItem } from '@trexolab/verifykit-react'
import type { ViewerPlugin, MenuItemRenderProps } from '@trexolab/verifykit-react'
function downloadPlugin(): ViewerPlugin {
return {
name: 'download',
menuOrder: 55, // lower renders earlier; default 50
menuGroup: 'file', // a divider is drawn where the group changes
renderMenuItems({ isOverflowed, onClose }: MenuItemRenderProps) {
// Only offer it in the menu when it is not already on the toolbar.
if (!isOverflowed('Download')) return null
return (
<MenuItem
label="Download"
hint="Ctrl+S"
onClick={() => { doDownload(); onClose() }}
/>
)
},
renderToolbarSlot: { /* … */ },
}
}The isOverflowed rule
| Slot state | isOverflowed(slot) | What to do |
|---|---|---|
| Registered, visible on toolbar | false | Return null — do not duplicate it |
| Registered, hidden for want of room | true | Contribute the menu item |
| Never registered (consumer removed it) | false | Return null — they opted out |
A plugin with no toolbar slot of its own — document properties, shortcut help
— should not call isOverflowed at all and simply always contribute.
renderMenuItems is called during the menu's own render, so return elements.
Anything needing hooks or state belongs in a component that you return instead.
Menu primitives
Return any JSX you like, but the four primitives the built-in plugins use are exported so your entries match them without copying styles:
import { MenuItem, MenuDivider, MenuSection, MenuContainer } from '@trexolab/verifykit-react'| Component | Props | Use |
|---|---|---|
MenuItem | label (required), icon?, hint?, active?, disabled?, onClick? | A single row. hint is the right-aligned shortcut text. |
MenuDivider | — | Horizontal rule between groups. Prefer menuGroup — the menu draws dividers itself. |
MenuSection | title | Uppercase group label. |
MenuContainer | children, style? | The floating panel itself — only needed if you build a menu of your own. |
Context Menu Items
Unreleased. The right-click menu works the same way, and for the same reason: it used to hold its own hardcoded list and call the actions itself, so disabling the Download plugin still left a working Save As… entry behind right-click — the toolbar button, the overflow entry and Ctrl+S all went away and this one did not. It now renders only what the installed plugins contribute.
import { MenuItem } from '@trexolab/verifykit-react'
import type { ViewerPlugin, ContextMenuItemRenderProps } from '@trexolab/verifykit-react'
function downloadPlugin(): ViewerPlugin {
return {
name: 'download',
menuOrder: 55, // the same order and group govern both menus
menuGroup: 'file',
renderContextMenuItems({ store, onClose }: ContextMenuItemRenderProps) {
return (
<MenuItem
label="Save As…"
hint="Ctrl+S"
onClick={() => { doDownload(); onClose() }}
/>
)
},
}
}ContextMenuItemRenderProps is deliberately smaller than MenuItemRenderProps
— just store and onClose. There is no isOverflowed: the context menu sits
over the page rather than in the toolbar, so nothing can overflow into it and a
plugin contributes whenever it is installed. Return the same MenuItem
primitives as above; ordering, grouping and dividers follow the same
menuOrder / menuGroup the plugin already declares, so an action keeps its
relative place on both surfaces.
Read any state your entry displays — an active flag, say — off the store
prop. The menu subscribes on your behalf while it is open.
A plugin that assembles a surface out of other plugins' contributions can reach them with
ctx.getPlugins(), which returns every plugin resolved into the viewer, composed sub-plugins included. The built-in context menu is the only plugin in the box that needs it; everything else should stay ignorant of its neighbours.
Registering Keyboard Shortcuts
Register keyboard shortcuts through the ViewerPluginContext during install:
function myShortcutPlugin(): ViewerPlugin {
let unsubscribe: (() => void) | null = null
return {
name: 'my-shortcuts',
install(ctx) {
unsubscribe = ctx.registerShortcut({
id: 'my-plugin.goto-first',
key: 'Home',
ctrl: true,
description: 'Go to first page',
handler: (e) => {
e.preventDefault()
ctx.scrollToPage(1)
},
})
},
destroy() {
unsubscribe?.()
},
}
}The KeyboardShortcut interface:
| Property | Type | Description |
|---|---|---|
id | string | Unique identifier |
key | string | Key to match (e.g., 'f', 'F3', '=', '-') |
ctrl | boolean? | Require Ctrl/Cmd modifier |
shift | boolean? | Require Shift modifier |
alt | boolean? | Require Alt modifier |
handler | (e: KeyboardEvent) => void | Handler function |
allowInInput | boolean? | Fire even when focus is in an input/textarea |
description | string? | Human-readable description |
The registerShortcut call returns an unsubscribe function. Call it in destroy() to clean up.
Adding Sidebar Tabs
Plugins can contribute sidebar tabs with icons, labels, and panel content:
import { BookOpen } from 'lucide-react' // or any icon library
interface OutlineTabProps {
store: ViewerStore
}
function OutlineTab({ store }: OutlineTabProps) {
const state = store.getState()
return (
<div style={{ padding: '12px' }}>
<h3>Document Outline</h3>
<p>Page {state.currentPage} of {state.numPages}</p>
</div>
)
}
function outlinePlugin(): ViewerPlugin {
return {
name: 'outline',
sidebarTabs: [
{
id: 'outline',
label: 'Outline',
icon: <BookOpen size={18} />,
component: OutlineTab,
order: 10, // Lower numbers appear first
canRender: (state) => state.numPages > 1,
},
],
}
}The SidebarTabDefinition interface:
| Property | Type | Description |
|---|---|---|
id | string | Unique tab identifier |
label | string | Display label shown in the sidebar |
icon | React.ReactNode | Icon element for the tab |
component | React.ComponentType<SidebarTabProps> | Tab panel component |
order | number | Sort order (lower = earlier in the tab list) |
canRender | (state: ViewerStoreState) => boolean | Optional — return false to hide the tab conditionally |
The component receives { store: ViewerStore } as props, giving it full access to the viewer state.
Overlays and Page Overlays
Viewer Overlay
Render a floating overlay above the viewer (useful for find bars, notifications, floating panels):
function notificationPlugin(): ViewerPlugin {
return {
name: 'notification',
renderOverlay: ({ store }) => {
const { numPages } = store.getState()
return (
<div style={{
position: 'absolute', top: 8, right: 8,
padding: '8px 12px', background: '#333', color: '#fff',
borderRadius: 4, fontSize: 12,
}}>
{numPages} pages loaded
</div>
)
},
}
}Right Panel
Render a panel to the right of the document area (e.g., a signature details panel):
renderRightPanel: ({ store }) => {
return (
<div style={{ width: 300, padding: 16, borderLeft: '1px solid #eee' }}>
<h3>Details</h3>
{/* panel content */}
</div>
)
}Per-Page Overlay
Render content on top of each rendered page. This is called for every visible page and receives page-specific information:
renderPageOverlay: ({ store, pageNum, scale, rotation, page }) => {
return (
<div style={{
position: 'absolute', top: 4, left: 4,
fontSize: 10, color: '#999',
}}>
Page {pageNum}
</div>
)
}Accessing the Store
The ViewerStore is a reactive state container that plugins use to read and update viewer state. Access it through ViewerPluginContext.store:
install(ctx) {
const store = ctx.store
// Read current state — whole object, or a single key
const state = store.getState()
console.log('Current page:', state.currentPage)
console.log('Zoom:', store.get('scale'))
console.log('Fit mode:', store.get('fitMode'))
// Update state
store.update({ currentPage: 5 })
// Subscribe to one key; the listener receives that key's new and old value
const unsub = store.subscribe('currentPage', (page, prevPage) => {
console.log(`Page changed from ${prevPage} to ${page}`)
})
// Or watch every change. This listener gets whole state snapshots, not a key —
// compare the fields you care about yourself.
const unsubAll = store.subscribeAll((state, prevState) => {
if (state.scale !== prevState.scale) {
console.log('scale:', prevState.scale, '→', state.scale)
}
})
// Several updates, one notification per key
store.batch(() => {
store.update({ scale: 1.5 })
store.update({ fitMode: 'none' })
})
// Unsubscribe later in destroy()
}The store has no
setState. Writes go throughupdate(), andsubscribe()takes a key first —subscribe(listener)with a single argument does not compile.
Composing Plugins
A plugin can compose other plugins using the composedPlugins property. This is how the defaultLayoutPlugin works — it bundles toolbar, zoom, search, page navigation, and other plugins into a single meta-plugin:
function myLayoutPlugin(): ViewerPlugin {
return {
name: 'my-layout',
composedPlugins: [
zoomPlugin(),
searchPlugin(),
myCustomPlugin(),
],
}
}Dependencies
If your plugin requires another plugin to be installed first, declare it with dependencies:
function advancedSearchPlugin(): ViewerPlugin {
return {
name: 'advanced-search',
dependencies: ['search'], // 'search' plugin must be installed first
install(ctx) {
// Safe to assume search plugin is already installed
},
}
}The viewer will warn if a dependency is not satisfied.
Full Example: Word Count Plugin
Here is a complete plugin that counts words in the current PDF and displays the count in a sidebar tab, with a keyboard shortcut to toggle the sidebar:
import React, { useEffect, useState } from 'react'
import type {
ViewerPlugin,
ViewerPluginContext,
SidebarTabProps,
DocumentLoadEvent,
} from '@trexolab/verifykit-react'
// ── Sidebar tab component ────────────────────────────────────────────────────
function WordCountTab({ store }: SidebarTabProps) {
const [wordCount, setWordCount] = useState<number | null>(null)
const [pageBreakdown, setPageBreakdown] = useState<Map<number, number>>(
new Map()
)
useEffect(() => {
// Read word count from plugin state stored in the store
const state = store.getState() as any
if (state._wordCountData) {
setWordCount(state._wordCountData.total)
setPageBreakdown(state._wordCountData.perPage)
}
// `subscribe` takes a specific key; this plugin stashes its data under a
// key the store does not declare, so watch everything and re-read.
const unsub = store.subscribeAll(() => {
const s = store.getState() as any
if (s._wordCountData) {
setWordCount(s._wordCountData.total)
setPageBreakdown(s._wordCountData.perPage)
}
})
return unsub
}, [store])
if (wordCount === null) {
return <div style={{ padding: 16 }}>Counting words...</div>
}
return (
<div style={{ padding: 16, fontSize: 14 }}>
<h3 style={{ margin: '0 0 12px' }}>Word Count</h3>
<p style={{ margin: '0 0 8px', fontWeight: 600 }}>
Total: {wordCount.toLocaleString()} words
</p>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '4px 0' }}>Page</th>
<th style={{ textAlign: 'right', padding: '4px 0' }}>Words</th>
</tr>
</thead>
<tbody>
{Array.from(pageBreakdown.entries()).map(([page, count]) => (
<tr key={page}>
<td style={{ padding: '2px 0' }}>{page}</td>
<td style={{ textAlign: 'right', padding: '2px 0' }}>
{count.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── Plugin factory ───────────────────────────────────────────────────────────
interface WordCountPluginOptions {
/** Icon for the sidebar tab. Defaults to a "W" character. */
icon?: React.ReactNode
}
function wordCountPlugin(options: WordCountPluginOptions = {}): ViewerPlugin {
const { icon = <span style={{ fontWeight: 700 }}>W</span> } = options
let ctx: ViewerPluginContext | null = null
let unsubShortcut: (() => void) | null = null
// Take the document type from the event rather than importing it from
// 'pdfjs-dist' — pdf.js is bundled since v0.6.0, so it is not a package you
// have installed and cannot be imported from.
async function countWords(doc: DocumentLoadEvent['document']) {
const perPage = new Map<number, number>()
let total = 0
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i)
const content = await page.getTextContent()
const text = content.items
.map((item: any) => item.str)
.join(' ')
const words = text.split(/\s+/).filter((w: string) => w.length > 0).length
perPage.set(i, words)
total += words
}
return { total, perPage }
}
return {
name: 'word-count',
sidebarTabs: [
{
id: 'word-count',
label: 'Word Count',
icon,
component: WordCountTab,
order: 50,
},
],
install(context) {
ctx = context
// Register Ctrl+Shift+W to log the word count
unsubShortcut = context.registerShortcut({
id: 'word-count.log',
key: 'w',
ctrl: true,
shift: true,
description: 'Log word count to console',
handler: (e) => {
e.preventDefault()
const state = context.store.getState() as any
if (state._wordCountData) {
console.log('Word count:', state._wordCountData.total)
}
},
})
},
async onDocumentLoad(e) {
const data = await countWords(e.document)
ctx?.store.update({ _wordCountData: data } as any)
},
onDocumentUnload() {
ctx?.store.update({ _wordCountData: null } as any)
},
destroy() {
unsubShortcut?.()
ctx = null
},
}
}
export { wordCountPlugin }Usage:
import { CoreViewer, VerifyKitProvider, defaultLayoutPlugin } from '@trexolab/verifykit-react'
import { wordCountPlugin } from './word-count-plugin'
const plugins = [defaultLayoutPlugin(), wordCountPlugin()]
function App() {
return (
<VerifyKitProvider config={{
workerUrl: 'https://unpkg.com/pdfjs-dist@5.5.207/legacy/build/pdf.worker.min.mjs',
}}>
<CoreViewer fileUrl="/report.pdf" plugins={plugins} />
</VerifyKitProvider>
)
}Tips
- Keep plugins focused. Each plugin should do one thing well. Compose multiple small plugins rather than building a monolithic one.
- Clean up in
destroy(). Always unsubscribe shortcuts, timers, and event listeners to prevent memory leaks. - Use the factory pattern. Return a fresh plugin object from a factory function so each viewer instance gets its own state.
- Inline styles only. The VerifyKit SDK uses inline styles (no CSS-in-JS libraries, no Tailwind) to keep the bundle self-contained. Follow this convention in your plugins.
- Name plugins uniquely. Plugin names must be unique within a viewer instance. Use a namespace prefix (e.g.,
myorg.word-count) for custom plugins.