# 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`](https://kernstra.com/docs/web-sdk/config/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

```css
/* 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](https://kernstra.com/docs/web-sdk/config/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](https://kernstra.com/docs/web-sdk/config/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](https://kernstra.com/docs/web-sdk/config/layout)). See [`PDFViewer`](https://kernstra.com/docs/web-sdk/api/pdfviewer) for what you can drive, and [Bring your own chrome](https://kernstra.com/docs/web-sdk/recipes/own-chrome).


Web page: https://kernstra.com/docs/web-sdk/guides/theme-tokens
