Dark mode & colour themes
@nextgensoftwares/folio-plugin-dark-mode gives documents a dark view whose colours adapt the way Google Docs' and Word's dark modes do: not a filter that inverts everything, but a per-colour mapping that keeps hues, keeps code blocks dark, keeps highlighters recognisable and guarantees readable contrast. Beyond dark mode it is a small document colour theme system: tweak the light or dark theme, add sepia, high contrast or a brand palette, and let readers follow the system setting.
It is display only. The document's stored colours never change, and PDF and DOCX exports print the document's own colours.
import { composePlugins } from '@nextgensoftwares/folio-editor';
import { colorThemesPlugin, localStorageThemes, PRESET_THEMES } from '@nextgensoftwares/folio-plugin-dark-mode';
const themes = colorThemesPlugin({
theme: 'system', // or 'light', 'dark', any theme id
themes: PRESET_THEMES, // sepia, night-blue, high-contrast
storage: localStorageThemes(), // remember the reader's themes and choice
toolbar: true, // a compact picker in the "toolbar" slot
});
const composed = composePlugins(standardSchema, [/* ... */ themes]);<FolioView editor={editor} painters={composed.painters} colors={composed.colors} />Only want a switch? darkModePlugin({ mode: 'off' | 'on' | 'system' }) is the same plugin with setMode().
What changes on screen
<FolioView colors> sends every colour it paints through the adapter with a role: text, links, inline code, highlights, table fills and borders, code blocks, quote borders, list markers, headers/footers, the page itself, the canvas behind it, the caret, the selection and collaborators' cursors. Images and video get the theme's mediaFilter (dark: a slight dimming). When the page is dark the view adds .folio-doc-dark, swaps the paper shadow for a rim plus a deeper shadow, and KaTeX math inherits the adapted text colour.
Document dark mode is independent of the UI theme (setFolioTheme): a light toolbar with dark pages works, and so does the reverse. With both dark, the canvas is derived from the page colour, so the two read as one surface.
Custom painters read the same function: usePaintEnv().color(color, role, on?) (identity when no theme is active).
The algorithm
Everything happens in OKLab/OKLCH, a perceptual space where lightness, chroma and hue are independent, so changing lightness never shifts hue.
- Parse every CSS form a document holds: hex (3/4/6/8),
rgb[a](),hsl[a]()in both syntaxes, named colours,transparent. Anything else (var(),currentColor, garbage) passes through unchanged. - Ink → paper ramp. A colour's lightness picks a point on a ramp from the theme's ink (text colour) to its paper (page colour). On a dark base that inverts lightness around the page: black lands on near-white ink, white on the
#1e1e1epage, mid-greys in between, in the same order. Greys take the ramp's tint (sepia greys turn brown, night-blue greys blue); coloured inputs keep their hue and roughly their chroma. Above 0.12 chroma the excess is halved on dark pages, so saturated reds and blues don't glow. - Contrast guarantee. For text the ramp ends at the faintest grey that still meets the theme's
contrastMin(4.5:1 by default), so light greys stay distinct instead of collapsing onto the limit; then each colour's lightness is bisected (hue fixed, chroma trimmed to stay in sRGB) until it meets the minimum. Links get at least 3:1. The check is against the lightest adapted fill, so text in a shaded table header passes too. - Fills (table cells, inline code, paragraph backgrounds) become slightly raised dark tones with their hue kept faintly; fills that are already dark stay dark, and code blocks that are already dark stay dark with their syntax colours untouched.
- Highlighters stay light and recognisable but dimmed (yellow is still yellow); the text on them is drawn from its original dark colour and pushed to 4.5:1 against the adapted highlight (
adaptOn). - Cache. Results are memoized per (role, colour) in a bounded
Map; a book paints a few dozen distinct colours, so painting is a lookup.
A light base (sepia, cream, tweaked light) re-anchors neutrals between ink and paper without inverting; coloured text keeps its own lightness, dark fills and the white text on them stay as they are.
Themes are data
themes.defineTheme({
id: 'brand',
label: 'Brand night',
base: 'dark', // inherit the adaptive dark algorithm
page: '#101820',
text: '#f0e6d2',
link: '#ffb000',
highlightMap: { '#fde68a': '#5c4d00' },
map: { '#ff0000': '#ff6b6b' }, // exact overrides, any role
contrastMin: 7,
});
themes.setTheme('brand');| Field | Effect (anything left out comes from the base's algorithm) |
|---|---|
base | light (re-tint) or dark (adaptive) |
page, text | paper and ink: the anchors of the ramp |
link, codeBackground, caret, selection | explicit role colours |
codeText, tableBorder | applied to neutral colours only (syntax colours and coloured accents keep adapting) |
highlightMap, map | exact document → display colours (highlights only / every role) |
contrastMin (1–21), chromaScale (0–2), lightnessCurve (0.25–4) | adaptation knobs |
mediaFilter | numeric CSS filter functions only, e.g. brightness(.9) |
Users tweak built-ins with updateTheme('dark', { page: '#000000' }) (stored as a patch; resetTheme('dark') drops it). exportThemes() / importThemes(json) move themes between machines; every import, stored state and defineTheme call is validated as untrusted JSON: unknown keys dropped, colours must parse (and are normalized), numbers clamped, at most 50 themes, 256 overrides each and 256 KB of JSON.
UI
By default the plugin contributes a "Document colours" section to the settings slot: the picker (Follow system / Light / Dark / custom), the system light/dark pair, and an editor with colour inputs per role, knobs, overrides, a live preview, WCAG warnings (themeContrastReport), duplicate, reset, delete and JSON import/export. toolbar: true adds a compact picker to the toolbar slot; settings: false lets a host build its own UI from ThemePicker, ThemeEditor and the API. See Customizing the UI.
Performance
Toggling a theme notifies the view once: the paint environment changes identity, every mounted (virtualized) page repaints once, and off-screen pages cost nothing. Typing never recomputes colours: the environment is stable and each colour is a cache hit. On the 7,000-page stress book a switch repaints in about 25–35 ms.