Theme tokens
Everything the SDK paints is driven by CSS custom properties. You restyle it by setting those properties — never by overriding our class names, which are not a stable contract and change between releases. There are four ways in, in increasing order of effort: match your brand color with theme.accentColor (one line); match your whole design system by overriding the semantic tokens (~15 lines); retune one component by overriding its component tokens (a few lines); or replace our chrome entirely with toolbar.layout: 'none' and the viewer API (you own the UI).
The three token layers. Primitives (the raw ramp, #4f46e5) → semantic (names a role, --ks-accent) → component (names a part, --ks-thumbnail-active-border, the current page's marker; #ffffff → --ks-surface → --ks-toolbar-bg). 1. Primitives are the raw ramp. They are not part of the public contract and may change in any release. Do not reference them. 2. Semantic tokens name a role, not a color. These are the contract. Set --ks-surface and every surface in the SDK follows — toolbar, panels, popovers, dialogs. 3. Component tokens each fall back to a semantic token, so you can retune one part without redefining the system. --ks-toolbar-bg defaults to var(--ks-surface); set it and only the toolbar moves. Work at the highest layer that solves your problem. Overriding --ks-surface once is better than overriding eight component backgrounds, because it also catches the components added in the next release.
Example
/* Our whole design system: override the semantic tokens once. Everything — toolbar, ribbon, panels, dialogs, popovers, and whatever we add next release — follows. */ :root { --ks-accent: #e91e63; --ks-accent-hover: #c2185b; --ks-accent-subtle: #fce4ec; --ks-accent-text: #ffffff; --ks-surface: #ffffff; --ks-surface-raised: #fafafa; --ks-surface-sunken: #f1f1f4; --ks-text: #1a1a2e; --ks-text-muted: #6b6b80; --ks-border: #e4e4ec; --ks-font: 'Inter', system-ui, sans-serif; --ks-radius: 4px; --ks-radius-sm: 3px; } /* One viewer instance, if you host several. */ #invoice-viewer { --ks-accent: #e91e63; } /* A dark theme of your own (theme.mode: 'auto'). */ :root[data-ks-theme='dark'] { --ks-surface: #11131a; --ks-surface-raised: #171a22; --ks-surface-sunken: #0c0e13; --ks-text: #e8e8f0; --ks-text-muted: #9a9ab0; --ks-border: #252833; --ks-accent: #f48fb1; /* lighter in dark — contrast, not hue */ --ks-accent-text: #11131a; --ks-canvas-bg: #07080b; } @media (prefers-color-scheme: dark) { :root:not([data-ks-theme='light']) { /* same block */ } }
Three ways in
| You want to | Do this | Effort |
|---|---|---|
| Match your brand color | theme.accentColor | One line |
| Match your whole design system | Override the semantic tokens | ~15 lines |
| Retune one component | Override its component tokens | A few lines |
| Replace our chrome entirely | toolbar.layout: 'none' + the viewer API | You own the UI |
Structural classes (a contract: for placing, sizing and hiding a region, not for repainting it)
| Class | On |
|---|---|
.ks-viewer | The viewer's root, inside your container; carries the data attributes |
.ks-toolbar | The toolbar, in either layout (the ribbon's root carries .ks-ribbon too) |
.ks-nav-bar | The navigation bar |
.ks-body | What is under the toolbar: the sidebar and the pages |
.ks-sidebar | The sidebar: its rail and the open panel |
.ks-page-container | The scrolling area the pages sit in |
.ks-page | One page: its canvas, text layer, fields and marks |
.ks-portal | Every root the SDK renders outside the viewer: dialogs, sheets, popovers |
.ks-dialog-backdrop | The veil behind a dialog |
.ks-dialog | A dialog |
.ks-toast | A toast |
.ks-loading-overlay | The document loader PDFViewer.showLoadingIndicator shows, in your container |
.ks-busy-overlay | The veil shown while a tool prepares its UI, in your container |
Semantic tokens: surfaces
| Token | Light | Dark | Where it lands |
|---|---|---|---|
--ks-surface | #ffffff | #1c1c1f | Toolbar, panels, dialogs, popovers |
--ks-surface-raised | #fcfcfd | #202024 | Raised panels: dialogs, the ribbon, cards |
--ks-surface-sunken | #f4f4f6 | #141417 | Button hover, ribbon tab strip, inset wells |
--ks-surface-overlay | rgba(24,24,27,.42) | rgba(0,0,0,.6) | Modal and panel backdrops |
--ks-canvas-bg | #f4f4f6 | #0e0e10 | The ground the pages sit on |
Semantic tokens: ink
| Token | Light | Dark | Where it lands |
|---|---|---|---|
--ks-text | #18181b | #f4f4f5 | Body and primary labels |
--ks-text-muted | #71717a | #a1a1aa | Tool labels, secondary text |
--ks-text-subtle | #a1a1aa | #71717a | Placeholders, disabled hints |
--ks-text-inverted | #fafafa | #18181b | Text on a filled accent surface |
Semantic tokens: lines
| Token | Light | Dark | Where it lands |
|---|---|---|---|
--ks-border | #e8e8ea | #2e2e33 | Dividers, panel edges, toolbar rule |
--ks-border-strong | #d4d4d8 | #3f3f46 | Input outlines, scrollbar thumbs |
--ks-select-caret | gray caret image | same | The chrome's own dropdowns' caret. A url(...): a data URI cannot read a var(), so the image is the token |
Semantic tokens: accent (one accent, used for every selected, active and primary state)
| Token | Light | Dark | Where it lands |
|---|---|---|---|
--ks-accent | #4f46e5 | #a5b4fc | Active tool, selected tab, primary button |
--ks-accent-hover | #4338ca | #c7d2fe | Hover on the above |
--ks-accent-subtle | #eef2ff | #312e5c | Active-tool wash, selected row |
--ks-accent-text | #ffffff | #18181b | Text on a filled accent |
Semantic tokens: status
| Token | Light | Dark | Where it lands |
|---|---|---|---|
--ks-danger | #b91c1c | #fca5a5 | Destructive actions, validation errors |
--ks-danger-subtle | #fee2e2 | #3f1d1d | Error banner background |
--ks-danger-border | #fca5a5 | #b91c1c | Error banner edge |
--ks-warning | #b45309 | #fde68a | Required-field and permission notices |
--ks-warning-subtle | #fffbeb | #3a2e12 | Warning banner background |
--ks-warning-border | #fde68a | #b45309 | Warning banner edge |
--ks-success | #15803d | #4ade80 | Save and export confirmations |
--ks-guide | #db2777 | #f472b6 | Snap guides while an object is moved or resized |
--ks-success-subtle | #dcfce7 | #14321f | Success banner background |
Semantic tokens: elevation
| Token | Light | Dark | Where it lands |
|---|---|---|---|
--ks-shadow-sm | 0 1px 3px rgba(16,16,20,.07) | 0 1px 3px rgba(0,0,0,.5) | Resting chrome, primary buttons |
--ks-shadow-md | 0 6px 20px rgba(16,16,20,.15) | 0 6px 20px rgba(0,0,0,.55) | Popovers, dropdowns, option bars |
--ks-shadow-lg | 0 12px 36px rgba(16,16,20,.18) | 0 12px 36px rgba(0,0,0,.6) | Modals |
Type (does not change between themes)
| Type token | Value |
|---|---|
--ks-font | system sans stack |
--ks-font-mono | system mono stack |
--ks-text-2xs | 10px |
--ks-text-xs | 11px |
--ks-text-sm | 12px |
--ks-text-base | 13px |
--ks-text-md | 14px |
--ks-text-lg | 16px |
--ks-text-xl | 20px |
--ks-text-2xl | 24px |
Space and shape (does not change between themes)
| Space & shape token | Value |
|---|---|
--ks-space-1 | 4px |
--ks-space-2 | 8px |
--ks-space-3 | 12px |
--ks-space-4 | 16px |
--ks-space-5 | 24px |
--ks-space-6 | 32px |
--ks-radius-sm | 6px |
--ks-radius | 9px |
--ks-radius-lg | 13px |
--ks-radius-full | 999px |
Interaction and motion
| Interaction & motion token | Value |
|---|---|
--ks-touch-target | 44px |
--ks-focus-ring | 2px solid var(--ks-accent) |
--ks-focus-offset | 2px |
--ks-ease-out | cubic-bezier(.2,0,0,1) |
--ks-ease-in | cubic-bezier(.4,0,1,1) |
--ks-duration-instant | 80ms |
--ks-duration-fast | 140ms |
--ks-duration-base | 200ms |
--ks-duration-slow | 280ms |
Component tokens: toolbar (layout: 'bar')
| Token | Default | Controls |
|---|---|---|
--ks-toolbar-bg | var(--ks-surface) | Bar background |
--ks-toolbar-border | var(--ks-border) | Bottom rule |
--ks-toolbar-height | 64px | Bar minimum height (48px compact, 46px narrow/tiny) |
--ks-btn-hover-bg | var(--ks-surface-sunken) | Tool button hover |
--ks-btn-disabled-opacity | 0.35 | Disabled tool opacity |
Component tokens: ribbon (layout: 'ribbon'; only injected when the ribbon is the chosen layout)
| Token | Default | Controls |
|---|---|---|
--ks-ribbon-bg | var(--ks-surface) | Tool-row background |
--ks-ribbon-tabs-bg | var(--ks-surface-sunken) | Tab-strip background |
--ks-ribbon-border | var(--ks-border) | Strip and bottom rules |
--ks-ribbon-tabs-height | 40px | Tab strip height (34px at tiny) |
--ks-ribbon-row-height | 64px | Tool row height (56px compact, 48px narrow/tiny) |
--ks-ribbon-tool-min-width | 64px | Tool button width (52px compact, 40px narrow/tiny) |
--ks-ribbon-disabled-opacity | 0.35 | Disabled tool opacity |
Component tokens: navigation bar
| Token | Default | Controls |
|---|---|---|
--ks-nav-bg | var(--ks-surface) | Pill background |
--ks-nav-text | var(--ks-text) | Pill text and icons |
--ks-nav-height | 44px | Pill height |
Component tokens: thumbnails
| Token | Default | Controls |
|---|---|---|
--ks-thumbnail-active-border | var(--ks-accent) | The current page's marker bar |
Component tokens: dialogs (the shared dialog: Download, Print, "Leave the document?" and every confirmation — .ks-dialog; a confirmation also carries ks-confirm and data-ks-confirm="<action>")
| Token | Default | Controls |
|---|---|---|
--ks-dialog-bg | var(--ks-surface) | Dialog background |
--ks-dialog-border | var(--ks-border) | Dialog edge |
--ks-dialog-radius | var(--ks-radius-lg) | Corner radius |
--ks-dialog-shadow | var(--ks-shadow-lg) | Drop shadow |
--ks-dialog-width | 440px | Width of the Download and Print dialogs; the viewport's on a phone |
--ks-dialog-sm-width | 360px | Width of a small dialog — every confirmation; the viewport's on a phone |
Component tokens: page area
| Token | Default | Controls |
|---|---|---|
--ks-viewer-bg | var(--ks-canvas-bg) | Ground behind the pages |
Page-layer tokens (one value for both themes; they do not follow mode)
| Token | Default | Controls |
|---|---|---|
--ks-paper | #ffffff | The page itself, and thumbnails |
--ks-field-bg | rgba(195,222,255,.42) | Form field tint |
--ks-field-bg-hover | rgba(195,222,255,.55) | Form field tint, hovered |
--ks-field-border-focus | #1a5fa8 | A checked box's tick and a selected radio's dot |
--ks-field-text | #18181b | What is typed into a field |
--ks-field-text-muted | #71717a | A read-only field's value |
--ks-field-text-subtle | #a1a1aa | A signature field's "Sign here" |
--ks-field-text-negative | #ff0000 | A negative number a field's format shows in red |
--ks-field-readonly-bg | #f4f4f6 | A read-only field, under its hatch |
--ks-field-required-bg | rgba(180,83,9,.16) | Required, still-empty field tint |
--ks-field-required-border | #b45309 | Required field edge and corner dot |
--ks-field-select-caret | a gray caret image | A form dropdown's caret (a url(...)) |
--ks-search-hit | rgba(255,235,59,.4) | Every search match |
--ks-search-hit-active | rgba(255,152,0,.55) | The current match |
--ks-note-pin | #f9a825 | Sticky-note pin on the page |
--ks-block-mark | #6366f1 | The outline Edit Text draws round an editable text block |
Stacking order
| Token | Value | Layer |
|---|---|---|
--ks-z-page | 1 | The base the annotation layer stacks on |
--ks-z-field | 10 | Form field widgets |
--ks-z-mark | 20 | Selection, search hits, editable-block marks |
--ks-z-object | 30 | Placed-object and text-block overlays |
--ks-z-handle | 40 | Resize grips and rotation handles |
--ks-z-contextBar | 50 | Floating bars anchored to a selection |
--ks-z-chrome | 100 | Toolbar, sidebar, navigation bar |
--ks-z-popover | 200 | Popovers, dropdowns, color pickers |
--ks-z-backdrop | 300 | Modal backdrops |
--ks-z-modal | 310 | Modal surfaces |
--ks-z-question | 320 | A confirmation asked over a modal |
--ks-z-toast | 400 | Toasts and transient confirmations |
--ks-z-drag | 500 | Drag ghosts |
Notes
Where to put your overrides. Tokens are declared on :root and inherited, so a value you set on :root reaches the viewer and the surfaces that render into document.body (the signature dialog, the security modal, every popover), and a value on your own container reaches the viewer inside it: :root { --ks-accent: #e91e63; } for everything, including portalled dialogs (usually what you want); #invoice-viewer { --ks-accent: #e91e63; } for one viewer instance, if you host several. Your stylesheet loads after ours, so an override at equal specificity wins without !important. Two :root rules tie on specificity and source order decides — yours is later, so yours applies.
theme.mode and theme.accentColor are applied to the viewer's own root element inside your container, and mirrored to the portal layer so dialogs follow. With mode: 'light' (the default) your :root and container values reach the viewer and its dialogs; only on a dark page — a dark OS, or data-ks-theme="dark" on <html> — does the viewer declare the SDK's light values on itself, so it stays light. mode: 'dark' always declares the SDK's dark values on the viewer, over the page and over your :root values (your accentColor still applies). For a dark palette of your own, use mode: 'auto' (see the dark-theme recipe below). To style portalled surfaces directly, target .ks-portal — it is on every root the SDK renders outside the viewer. Dialogs, sheets and popovers live in one portal layer shared by the whole page. With several viewers in different themes or accents, they take the theme of the viewer created last. See Theme.
What you may target. Three things in the SDK's markup are a contract, kept across releases: the tokens (--ks-*): the colors, type, spacing and stacking, listed in the tables above — they are how a theme is written; the data attributes on the viewer element: data-ks-theme, data-ks-size, data-ks-pointer, data-ks-height, data-ks-toolbar, data-ks-nav, data-ks-panels, data-ks-touch, data-ks-text-edit, data-ks-phone, data-ks-rails, data-ks-tablet, data-ks-tool, data-ks-touch-drag and data-ks-print — see Layout; and the structural classes in the table above, for placing, sizing and hiding a region, not for repainting it. Every other class (.ks-tb-btn, say) is internal: it may change in any release without notice. Set the tokens instead; they are what we promise not to break.
Semantic tokens — the contract. These are the names to set. Every value in the tables is the light-theme default; the dark column is what the same token becomes under mode: 'dark' or a dark OS. The dark accent gets lighter, not darker: indigo-600 on near-black fails contrast, the same hue at 300 passes. If you override the accent for dark mode, do the same. With theme.accentColor, the other shades are derived for you from the theme itself: hover mixes toward --ks-text (darker on light, lighter on dark), and the wash mixes toward --ks-surface, so one color works in both themes.
Type, space, shape, motion do not change between themes. Spacing is a 4px step. Most type sizes the chrome draws are --ks-text-* steps — set --ks-font or a step to retune them in one place. A few stay fixed on purpose: the 16px an input needs on a touch screen (below it iOS zooms the page), text that depicts content such as a typed signature's preview, and a handful of optical sizes tuned to a specific cell (the phone dock and toolbar strip labels, some signature-dialog captions). --ks-text-xl, --ks-text-2xl, --ks-space-6 and --ks-ease-in are part of the scale but no component uses them yet; setting them has no visible effect today. --ks-accent-border (derived from accentColor) and --ks-field-border are still declared, but nothing reads them either.
Component tokens — per-component control. Each falls back to a semantic token. Set one to move a single component. The toolbar, ribbon, navigation bar, thumbnails, dialogs and page-area tables above list them.
What the theme does not touch. Some colors belong to the document, not to your UI, and deliberately ignore mode and your overrides of the chrome tokens. The page stays white in dark mode. A PDF is white because the document is white. Inverting it would change the content, turn a scan into a negative, and break the match between what is on screen and what will print. Only the ground around the page darkens. The page-layer tokens exist in one value for both themes. You can still retune them; they just do not follow mode. A focused or hovered field is outlined in --ks-accent. Also unthemed, because they become part of the saved file: annotation ink, highlight colors and stamp presets. What the user draws is content. Those are chosen in the tool's own options bar, and exporting the document keeps them exactly as drawn.
Stacking order. Every layer the SDK paints has a named z-index, so you can predict what sits above what and place your own chrome around it. To float your own header above the viewer's chrome but below its dialogs, sit between --ks-z-chrome and --ks-z-popover. The docked chrome, popovers, modals, toasts and the on-page marks, objects and handles use these layers; --ks-z-field and --ks-z-drag are reserved and not read by any layer. The signature dialog and Edit Text's floating sheet and hints sit at fixed values of 10000–10002, so only a value above 10002 guarantees your own chrome sits above everything of ours — except the license-required notice, which always stays on top.
Accessibility the SDK already handles. Keep these if you restyle; they are cheap and they are what the viewer is designed around so that everyone can use it — with a skip link, a roving toolbar, focus-trapped dialogs, live regions, the text layer and 44 px touch targets. Focus: every control shows a :focus-visible ring built from --ks-focus-ring and --ks-focus-offset. If you override the accent, the ring follows automatically. Do not set outline: none. The rule is scoped to .ks-viewer and .ks-portal, so it reaches dialogs too but never leaks into your own page. Reduced motion: under prefers-reduced-motion: reduce transitions drop to 1ms — not 0, so transitionend handlers still fire and nothing that waits on one stalls. Forced colors: under forced-colors: active the focus ring switches to the system Highlight color, and the ribbon re-states its selected tab and active tool with system keywords — a background wash alone disappears in that mode. If you restyle a selected state with color only, add a border or outline for this case. Contrast: the defaults are designed for WCAG AA contrast against their own surfaces. If you change the accent, check --ks-accent-text against it — a light accent needs dark text on it.
Recipe: just our brand color. new PDFViewer(container, doc, { theme: { accentColor: '#e91e63' } }); — hover, subtle and border shades are derived from it. Nothing else to set.
Recipe: follow the OS. new PDFViewer(container, doc, { theme: { mode: 'auto' } });
Recipe: switching theme live. Keep theme.mode: 'auto' and set the attribute on <html> from your own theme toggle. The viewer and its dialogs switch immediately, with no rebuild: document.documentElement.dataset.ksTheme = isDark ? 'dark' : 'light';. Remove the attribute to follow the OS again.
Recipe: our whole design system. Override the semantic tokens once, as in the example above. With the default mode: 'light' these values apply wherever the page around the viewer is light; with mode: 'auto' they apply in both themes. To give dark its own values, add a dark block too.
Recipe: a dark theme of your own. Leave theme.mode at 'auto' and redefine the tokens under both selectors, as in the example above: the first (:root[data-ks-theme='dark']) for a page that sets data-ks-theme="dark" on <html> itself, the second (@media (prefers-color-scheme: dark) { :root:not([data-ks-theme='light']) { … } }) for a dark OS. (An explicit theme.mode: 'dark' uses the SDK's own dark palette — your accentColor still applies.)
Recipe: a denser toolbar. :root { --ks-toolbar-height: 56px; --ks-space-2: 6px; }. In a compact or narrow viewer the toolbar uses its own smaller heights.
Recipe: replace our chrome entirely. new PDFViewer(container, doc, { toolbar: { layout: 'none' }, sidebar: { rail: false }, navigation: { pageControls: false, zoomControls: false } }) (rail: false for your own Pages button, not ours beside it), then your buttons, our SDK: activateTool and download are async — viewer.activateTool('highlight').catch(...) rejects with INVALID_ARGUMENT (the tool is off here), FEATURE_NOT_LICENSED (not in the license) or PERMISSION_DENIED (the document forbids it); myHighlightButton.hidden = !viewer.isToolEnabled('highlight'); myUndoButton.onclick = () => void viewer.undo(); mySaveButton.onclick = () => void viewer.createExporter().download(). Which button is pressed comes from toolChanged: between tools the resting tool runs — Select by default — so it reports 'selectText' from the first paint, and Search and Manage Pages are panels (the exported PANEL_TOOL_NAMES): activating them leaves activeTool as it was. Your Pages button opens or closes through viewer.openPanel(viewer.activePanel === 'pages' ? null : 'pages') and follows panelChanged, whoever made the change — the drawer's backdrop, Escape inside the drawer, a resize. The page area, panels and every tool still work; only our chrome is gone. On a phone the active tool's options still rise in the inspector sheet above your own bar (see Layout). See PDFViewer for what you can drive, and Bring your own chrome.