Light/Dark Mode
Three ways to add light/dark theme switching to syntax-highlighted code with Lumis.
The multi-themes formatter renders one HTML block with CSS variables for each theme. You control which theme is active with CSS.
1. CSS light-dark() function
light-dark() selects colors using the element's color-scheme, which is inherited from its parent. Lumis leaves that property unset so code follows the page's theme, including a manual light/dark toggle. If the page has not enabled a color scheme, the light colors are used.
To follow the OS preference throughout the page, set:
html {
color-scheme: light dark;
}For a manual toggle, set color-scheme: light or color-scheme: dark on the page or the section containing the code:
[data-theme="light"] {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}A page-wide rule is optional. To follow the OS preference for one code block, use the block attributes below. A local color-scheme: light dark makes that block follow the OS even when its parent selects only light or dark, so omit it when the block should follow the page's toggle.
This switches color and background-color on every runtime that supports HTML Multi-Themes output. lumis4j does not support this formatter, and the CLI generates HTML for a browser to render.
light-dark() is a color function, so those two properties are all it covers. font-weight, font-style and text-decoration are handled by whether your two themes agree:
- They agree — the shared value is written as an ordinary declaration. It is right in both schemes, and there is nothing to switch. Most theme pairs are entirely in this case, which is why the CSS above is usually all you need.
- They disagree — the light theme italicises comments and the dark one does not, say. Nothing in CSS can switch a font style off the color scheme, so the formatter writes no declaration at all and emits
--lumis-light-font-styleand--lumis-dark-font-styleinstead. Add these rules to use them:
.lumis span {
font-style: var(--lumis-light-font-style);
font-weight: var(--lumis-light-font-weight);
text-decoration: var(--lumis-light-text-decoration);
}
@media (prefers-color-scheme: dark) {
.lumis span {
font-style: var(--lumis-dark-font-style);
font-weight: var(--lumis-dark-font-weight);
text-decoration: var(--lumis-dark-text-decoration);
}
}If your app sets color-scheme independently of the OS preference, replace this media query with selectors that follow the app's theme setting so these properties switch with the colors.
No !important, and you should not add one. A disputed property is deliberately left out of the style attribute, so these rules have nothing inline to outrank, and they stay in your cascade where a print or forced-colors rule of yours can still win. They are safe to apply to every span: on a token whose themes agreed, the inline declaration outranks them and the shared value stands; on a token neither theme styled, the variable was never set, so the declaration drops out and your own styling of the block is what renders.
The trade-off is that a disputed property renders in neither scheme until you add those rules. That is the same choice Shiki makes, and it is the only one that keeps the generated HTML from dictating your cascade.
Opt a block into OS switching
These examples add color-scheme: light dark to the block's <pre> through pre_attrs (preAttrs in JavaScript). It applies to the block and its scrollbar without changing form controls or scrollbars elsewhere on the page.
import {highlight} from '@lumis-sh/lumis'
import {htmlMultiThemes} from '@lumis-sh/lumis/formatters'
import javascript from '@lumis-sh/wasm-javascript'
import latte from '@lumis-sh/themes/catppuccin_latte'
import frappe from '@lumis-sh/themes/catppuccin_frappe'
const html = await highlight(
'const x = 1',
htmlMultiThemes({
language: javascript,
themes: {light: latte, dark: frappe},
defaultTheme: 'light-dark()',
preAttrs: {style: 'color-scheme: light dark;'},
})
)Inline code
With structure: "inline", Lumis emits only token spans and ignores pre_attrs and code_attrs. The spans inherit color-scheme from the element you put them in. Leave that element's scheme unset to follow the page, or opt it into OS switching:
<code style="color-scheme: light dark;">
<!-- Insert the inline spans from Lumis here. -->
</code>Setting the scheme does not add the theme's text or background color to your element. See Keeping the theme's colors for the attribute helpers that supply them.
2. @media (prefers-color-scheme) with CSS variables
Use this when you need more control than light-dark() provides, or when targeting browsers that don't support it yet. The inline styles render the light theme by default, and a media query overrides with dark theme variables.
Required CSS for the rendered HTML output:
@media (prefers-color-scheme: dark) {
.lumis,
.lumis span {
color: var(--lumis-dark) !important;
background-color: var(--lumis-dark-bg) !important;
font-style: var(--lumis-dark-font-style) !important;
font-weight: var(--lumis-dark-font-weight) !important;
text-decoration: var(--lumis-dark-text-decoration) !important;
}
}For runtimes that support HTML Multi-Themes, default_theme: "light" makes the light theme's colors render as inline styles. The media query then swaps to the dark theme's CSS variables when the OS is in dark mode. lumis4j does not support this formatter, and the CLI only generates the HTML.
The !important is unavoidable here: a declaration in your stylesheet cannot outrank one in a style attribute without it. If you would rather keep control of the cascade, drop default_theme entirely. Every theme is then a variable, nothing is inline, and the same rules work without !important — at the cost of a block that renders unstyled until your CSS loads.
import {highlight} from '@lumis-sh/lumis'
import {htmlMultiThemes} from '@lumis-sh/lumis/formatters'
import javascript from '@lumis-sh/wasm-javascript'
import latte from '@lumis-sh/themes/catppuccin_latte'
import frappe from '@lumis-sh/themes/catppuccin_frappe'
const html = await highlight(
code,
htmlMultiThemes({
language: javascript,
themes: {light: latte, dark: frappe},
defaultTheme: 'light',
})
)3. Manual switching with JavaScript
Use this when you want a toggle button or user preference that's independent of the OS setting. Add a class to the root element and target it in CSS.
Required CSS for the rendered HTML output:
html.dark .lumis,
html.dark .lumis span {
color: var(--lumis-dark) !important;
background-color: var(--lumis-dark-bg) !important;
font-style: var(--lumis-dark-font-style) !important;
font-weight: var(--lumis-dark-font-weight) !important;
text-decoration: var(--lumis-dark-text-decoration) !important;
}Toggle JavaScript:
function setTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}This pattern works anywhere you render the generated HTML. In Phoenix LiveView, you'd wire the button to a phx-click event that toggles a class via a JS command. lumis4j does not support HTML Multi-Themes, and the CLI only generates the HTML for another renderer to display.
import {highlight} from '@lumis-sh/lumis'
import {htmlMultiThemes} from '@lumis-sh/lumis/formatters'
import javascript from '@lumis-sh/wasm-javascript'
import latte from '@lumis-sh/themes/catppuccin_latte'
import frappe from '@lumis-sh/themes/catppuccin_frappe'
const html = await highlight(
code,
htmlMultiThemes({
language: javascript,
themes: {light: latte, dark: frappe},
defaultTheme: 'light',
})
)Which approach to use
| Approach | When to use |
|---|---|
light-dark() | Follows the element's color-scheme: OS preference with light dark, or a page-selected light or dark. Extra CSS handles any font properties the themes disagree on. |
@media prefers-color-scheme | OS-driven but you need the CSS override pattern (wider browser support). |
| Manual switching | User-controlled toggle independent of OS setting. |