VerifyKitv0.13.3

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:

ts
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: PluginContext is the core engine's context (re-exported from @trexolab/verifykit-core, with .config), and the viewer's context is exported under the name ViewerPluginContext. A plain import { PluginContext } gives you the core one, and ctx.store will 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:

tsx
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:

tsx
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:

PropertyTypeDescription
storeViewerStoreThe reactive viewer state store
getDocument()() => PDFDocumentProxy | nullGet the currently loaded PDF document
scrollToPage(n)(n: number) => voidScroll the viewer to a specific page
getScrollContainer()() => HTMLElement | nullGet the scroll container element
getViewerContainer()() => HTMLElement | nullGet the viewer root container
registerShortcut(s)(s: KeyboardShortcut) => () => voidRegister a keyboard shortcut
onOpenFile((file: File) => void) | undefinedOpen file handler from parent
onRevalidate(() => void) | undefinedRe-verify all signatures on the in-memory buffer
t(key)(key: string) => stringTranslation function

onDocumentLoad(e: DocumentLoadEvent)

Called when a PDF document is successfully loaded. The event contains:

ts
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.

ts
interface PageChangeEvent {
  currentPage: number   // New current page (1-based)
  previousPage: number  // Previous page (1-based)
}

onZoomChange(e: ZoomChangeEvent)

Called when the zoom level changes.

ts
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.

ts
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:

tsx
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 ToolbarSlotPropsstore, 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.

tsx
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 stateisOverflowed(slot)What to do
Registered, visible on toolbarfalseReturn null — do not duplicate it
Registered, hidden for want of roomtrueContribute the menu item
Never registered (consumer removed it)falseReturn 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.

Return any JSX you like, but the four primitives the built-in plugins use are exported so your entries match them without copying styles:

tsx
import { MenuItem, MenuDivider, MenuSection, MenuContainer } from '@trexolab/verifykit-react'
ComponentPropsUse
MenuItemlabel (required), icon?, hint?, active?, disabled?, onClick?A single row. hint is the right-aligned shortcut text.
MenuDividerHorizontal rule between groups. Prefer menuGroup — the menu draws dividers itself.
MenuSectiontitleUppercase group label.
MenuContainerchildren, 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.

tsx
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:

tsx
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:

PropertyTypeDescription
idstringUnique identifier
keystringKey to match (e.g., 'f', 'F3', '=', '-')
ctrlboolean?Require Ctrl/Cmd modifier
shiftboolean?Require Shift modifier
altboolean?Require Alt modifier
handler(e: KeyboardEvent) => voidHandler function
allowInInputboolean?Fire even when focus is in an input/textarea
descriptionstring?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:

tsx
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:

PropertyTypeDescription
idstringUnique tab identifier
labelstringDisplay label shown in the sidebar
iconReact.ReactNodeIcon element for the tab
componentReact.ComponentType<SidebarTabProps>Tab panel component
ordernumberSort order (lower = earlier in the tab list)
canRender(state: ViewerStoreState) => booleanOptional — 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):

tsx
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):

tsx
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:

tsx
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:

tsx
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 through update(), and subscribe() 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:

tsx
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:

tsx
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:

tsx
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:

tsx
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.