Dark Mode
Learn how to render PDFs in native dark mode with Lector's render-time color remapping
Lector renders dark mode natively at the PDF.js level. Colors are remapped inside the canvas pixels at render time — not approximated with CSS filters — so black text becomes light, white paper becomes dark, colors keep their hue, and images stay pixel-perfect.
Quick Start
Pass the colorScheme prop to Root:
The prop is reactive — changing it re-renders every page in the new scheme. To follow your app's theme (for example with next-themes):
Runtime Toggle
Any component under Root can read and switch the scheme through the usePdf store:
Rendered pages are cached per scheme as bitmaps, so toggling back and forth is instant.
Custom Palette
Override the dark palette with darkModeColors. The defaults are background #141210 (a dark warm gray) and foreground #eae6e0 (exported as DEFAULT_DARK_MODE_COLORS):
You can also pass colors when toggling at runtime: setColorScheme("dark", { background, foreground }).
Any CSS color works (hsl(), named colors, etc.), but not var(--token) — resolve CSS variables yourself first. If you do, don't read them from document.documentElement during render: when the theme toggles, React can re-render before your dark class lands on <html>, and you'll capture the light values and pass a light palette as the "dark" one. Resolve the dark tokens from a probe element that always carries the class instead:
The palette only applies while colorScheme is "dark", so it's safe to compute once and pass unconditionally.
Matching Your UI
If you draw your own overlays — highlight rects, custom annotations — you can recolor them with the exact same mapping the pages use. createDarkModeColorMap returns a memoized (color: string) => string function:
A yellow highlight passed through the map lands on the same ramp as the page content, so it reads as a highlight against the dark paper instead of clashing with it.
How It Works
Lector intercepts draw calls on the canvas at render time and remaps each fill and stroke color in OKLab color space. Perceived lightness is flipped onto the background↔foreground ramp — its position follows BT.709 luma, the same measure the "luminance inversion" night modes in PSPDFKit and MuPDF use — while hue and chroma are preserved:
- Black text → palette foreground; white paper → palette background
- Red stays warm (becomes pink, not cyan like CSS
invert); blue links become light blue - Grays pick up the palette's tint
A custom PDF.js CanvasFactory extends the recoloring to PDF.js-internal scratch canvases (transparency groups, soft masks, patterns). Images are left pixel-perfect — photos are not inverted, which is the big win over CSS filters. And because everything happens once at render time, there is zero per-frame cost during scroll and zoom, unlike CSS filters which the compositor re-evaluates continuously.
Compared to the CSS filter approach this also means text selection and highlights composite correctly against real dark pixels, and thumbnails and the high-zoom detail layer follow the scheme automatically.
Limitations
- Luminosity soft-mask fades survive (the mask is luma-corrected during composition), but midtones can land a few percent off the original opacity
- Mesh-gradient shadings (rare) keep their original colors
- Scanned PDFs, where the whole page is one image, intentionally stay light since images are preserved
- Annotations rendered into the DOM by
AnnotationLayer(link borders, form widgets) keep their original colors — style them with your own CSS if needed - Overlays you draw with blend modes tuned for a light page (e.g.
mix-blend-multiply) need a dark variant such asdark:mix-blend-screen - Passing your own
CanvasFactoryviadocumentOptionsdisables scratch-canvas recoloring
Legacy: CSS Filter Approach
Deprecated. Use the
colorSchemeprop instead. This recipe is kept for consumers on older versions of Lector without native dark mode.
Before native dark mode, the recommended approach was a chain of CSS filters on the Pages container:
This inverts photos, shifts hues (CSS hue-rotate is a linear approximation), and adds per-frame GPU filter cost — all of which the native colorScheme rendering avoids.
