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: PluginContext): 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>
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.
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: PluginContext)
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 PluginContext 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 |
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: 'page-width' | 'page-fit' | 'custom'
}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.
Registering Keyboard Shortcuts
Register keyboard shortcuts through the PluginContext 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 PluginContext.store:
install(ctx) {
const store = ctx.store
// Read current state
const state = store.getState()
console.log('Current page:', state.currentPage)
console.log('Zoom:', state.scale)
console.log('Fit mode:', state.fitMode)
// Update state
store.setState({ currentPage: 5 })
// Subscribe to state changes
const unsub = store.subscribe((state, prevState) => {
if (state.currentPage !== prevState.currentPage) {
console.log('Page changed to', state.currentPage)
}
})
// Unsubscribe later in destroy()
}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,
PluginContext,
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)
}
const unsub = store.subscribe((s: 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: PluginContext | null = null
let unsubShortcut: (() => void) | null = null
async function countWords(doc: import('pdfjs-dist').PDFDocumentProxy) {
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.setState({ _wordCountData: data } as any)
},
onDocumentUnload() {
ctx?.store.setState({ _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.