Theming
Authoring a theme
Compile a curated theme input into a static Luke UI theme stylesheet.
Author a custom theme when Tactile and Paper do not suit the product. defineTheme turns a small,
curated ThemeInput into static CSS for one theme with light and dark modes. Run it at build time,
in Node 24 or later.
Import the authoring APIs from @luke-ui/react/theme/compiler, and only in build scripts. The
application imports the compiled stylesheet and @luke-ui/react/theme. Recompile custom themes when
upgrading @luke-ui/react so the stylesheet includes the current token contract.
Define the input
A basic theme authors an accent colour, a neutral character, and a body font. Everything else
defaults. A ThemeInput includes:
- a kebab-case
name typography.fonts.body, the required body fontcolor.accent, the required brand or interaction accentcolor.neutralas a colour or{ style: 'cool' | 'neutral' | 'warm' }for the neutral base anchor- optional
color.surfacecolours for thebase,subdued, andraisedsurfaces - optional
color.info,color.success,color.warning,color.danger,color.focus, andcolor.backdrop - optional
typography.fonts.display,typography.fonts.code,typography.fontWeight,radius,shadow, andcontrolFinish
Most source colours accept one string or a { light, dark } object. Either side of the object may
be omitted. How each colour treats them differs:
accent,info,success,warning,danger,neutral, andsurface.baseadapt one string to each mode: they keep its hue, choose a lightness for the mode, and may reduce its chroma to fit sRGB. A missing side is adapted from the side you set. Omitted optional chromatic roles use curated defaults for both modes. A status role with one authored side previously used a default for the other mode, while a single string was used unchanged in both modes. To preserve either behaviour, set explicitlightanddarkvalues. For a single string, use that value for both.focusandbackdropuse one string as written in both modes. A missing side uses Luke UI's default for that mode.surface.subduedandsurface.raisedtake only the object, because one value cannot suit both modes. A missing side is generated frombase. See Surfaces.
color.backdrop is the one colour that may include alpha. Luke UI emits it as
color.background.backdrop. Hover and pressed colours are generated from the resting semantic
colours. Do not author them.
radius overrides the detail, control, surface, and overlay corners, which default to 4, 8,
12, and 16px. Radius inputs are pixels at a 16px root and emit as rem. shadow and
controlFinish can be set per mode and per rung. An omitted shadow rung uses a subtle default
shadow, and an omitted controlFinish rung is none. Solid and standard Buttons paint the finish
at rest, hover, and pressed. Low Buttons paint it on hover and pressed only over a solid fill, and
paint just the interaction state layer over a page surface. Typography sizes, line heights, letter
spacing, and motion are not authored. Read Fonts for the font input.
This minimal theme, saved as theme.ts, authors one colour, a neutral character, and a body font:
import interMetrics from '@capsizecss/metrics/inter';
import type { ThemeInput } from '@luke-ui/react/theme/compiler';
export const theme: ThemeInput = {
color: { accent: '#3b82f6', neutral: { style: 'cool' } },
name: 'product',
typography: {
fonts: {
body: { family: "'Inter', system-ui, sans-serif", metrics: interMetrics },
},
},
};Fonts
Each body or display font is a ThemeFont: a CSS family stack, and Capsize metrics for the
stack's first font. Luke UI trims each typography style with its font's metrics. The metrics need
familyName, ascent, capHeight, descent, lineGap, and unitsPerEm. Import them from
@capsizecss/metrics, which you install as a development dependency, or read them from a font file
with @capsizecss/unpack.
typography.fonts.body sets caption, label, body, and lead. The optional
typography.fonts.display sets heading4 through heading1 and display. Without a display font,
those styles use the body font.
import frauncesMetrics from '@capsizecss/metrics/fraunces';
import interMetrics from '@capsizecss/metrics/inter';
import type { ThemeInput } from '@luke-ui/react/theme/compiler';
export const theme: ThemeInput = {
color: { accent: '#3b82f6' },
name: 'editorial',
typography: {
fonts: {
body: { family: "'Inter', system-ui, sans-serif", metrics: interMetrics },
display: { family: "'Fraunces', Georgia, serif", metrics: frauncesMetrics },
},
fontWeight: { heading: 700 },
},
};Set typography.fonts.code to a CSS font-family stack for code. It needs no Capsize metrics. When
omitted, Luke UI uses its system monospace stack. This theme composes Paper and sets a code font:
import type { ThemeInput } from '@luke-ui/react/theme/compiler';
import { theme as paperTheme } from '@luke-ui/theme-paper/input';
export const theme: ThemeInput = {
...paperTheme,
name: 'product',
typography: {
...paperTheme.typography,
fonts: { ...paperTheme.typography.fonts, code: "'Fira Code', ui-monospace, monospace" },
},
};A theme only names its fonts. The generated stylesheet sets each style's font-family, but it has
no @font-face rule, so the browser does not download the font. Load every font and weight the
theme uses, with your own @font-face rules or a package such as Fontsource. The family stack
must name the family that the @font-face rule declares. For example, @fontsource-variable/inter
declares 'Inter Variable'. Until a font loads, text renders in the next font of the stack. Read
Typography for how that affects trims.
typography.fontWeight sets the body, label, heading, and emphasis weights, which default
to 400, 500, 600, and 700. Keep label at least 100 heavier than body. A low-prominence text Link
has no underline at rest, and on a solid fill its colour matches the text around it, so the label
weight is what shows it is a link. defineTheme throws when the resolved label weight is less
than 100 above body.
Surfaces
color.surface.base is the default page and control background. It is independent of
color.neutral, which sets the neutral family character. When you omit base, it uses the resolved
neutral colour.
Every other surface is a fixed lightness offset from base for the mode. Set subdued or raised
when a default does not suit the theme. Raised is shared by elevated inline and detached content.
Choose borders and shadow separately. A raised surface does not add either. Luke UI maps an authored
colour into the sRGB gamut but never changes its lightness to pass contrast. If a surface fails
contrast validation, set it explicitly or choose a different value.
Compose a theme
Paper and Tactile export their inputs as theme from ./input. Use ordinary TypeScript to build a
standalone input from one of them:
import type { ThemeInput } from '@luke-ui/react/theme/compiler';
import { theme as paperTheme } from '@luke-ui/theme-paper/input';
export const theme: ThemeInput = {
...paperTheme,
name: 'product',
color: { ...paperTheme.color, accent: '#3b82f6' },
radius: { ...paperTheme.radius, control: 8 },
};Give the new theme its own name: two themes with one name emit a colliding identity class. Spreads
are shallow, so spread each nested object whose other values you want to keep. A replaced property
uses its own values and defaults. The compiled stylesheet stands alone. Load it instead of the
bundled stylesheet. If it still names the bundled fonts, keep importing the package's fonts.css.
Generate a stylesheet
defineTheme returns the stylesheet as a string. Save this script as compile-theme.ts, beside
theme.ts:
import { defineTheme } from '@luke-ui/react/theme/compiler';
import { writeFile } from 'node:fs/promises';
import { theme } from './theme.ts';
await writeFile('src/theme.css', defineTheme(theme));Run it from the project root with Node 24 or later, and again whenever the input changes:
node compile-theme.tsThe script writes src/theme.css. Node runs TypeScript files by stripping their types, so keep the
.ts extension on relative imports and use only the syntax Node supports. Read
Node's TypeScript documentation for the
limits.
Import ./theme.css in place of a theme package's stylesheet.css, and set the theme's identity
class on <html>, as Installation shows for Tactile. The
class is luke-ui-theme- followed by the theme's name, and getThemeClassName from
@luke-ui/react/theme returns it.
Generate with Vite
A Vite application can compile the theme during its build instead. This plugin serves the stylesheet
as a virtual module. Import virtual:theme.css from the application entry.
import { defineTheme } from '@luke-ui/react/theme/compiler';
import type { Plugin } from 'vite';
import { theme } from './theme.ts';
/** Serves the compiled theme as `virtual:theme.css`. Import that id from the application entry. */
export function themePlugin(): Plugin {
const id = 'virtual:theme.css';
const resolvedId = `\0${id}`;
return {
load: (loadId) => (loadId === resolvedId ? defineTheme(theme) : undefined),
name: 'theme',
resolveId: (source) => (source === id ? resolvedId : undefined),
};
}Validation
defineTheme validates the authored fields in its input before compiling. It throws
ThemeValidationError with all input problems together. Each entry in issues names the theme
that contains it, its input-relative path, and a message. For example, an invalid accent side
has path color.accent.light. The input must provide both an accent and a body font.
The rules for colours and materials:
- Every input's
namemust be a kebab-case string. - Source colours other than
backdropmust be#rgb,#rrggbb, oroklch(<l> <c> <h>)strings. OKLCH channels must be finite, with lightness from 0 to 1 (or 0% to 100%) and chroma 0 or greater. - Colour roles accept the shapes described in Define the input. An accent object must set at least one side.
neutralaccepts either a colour or a curated style, not both.shadow,controlFinish, andbackdropvalues must be non-empty strings without;,{, or}. Backdrop values are emitted verbatim and may include alpha.- Radii must be finite numbers, 0 or greater.
The rules for fonts:
familymust be a valid CSSfont-familylist: family names and generic families such assans-serif, separated by commas. Quote a name unless it is plain words, such asNoto Sans, and quote a name that starts with a generic family, such as'Serif Pro'.familymust not contain;,{,},<,\,/*,*/, or control characters, even in quotes.metrics.familyNamemust be a non-empty string.- The other metrics must be finite numbers.
unitsPerEm,capHeight, andascentmust be positive,descent0 or less, andlineGap0 or more. - Font weights must be numbers from 1 to 1000.
- The resolved
labelweight must be at least 100 heavier thanbody. An omitted weight uses the default.
Contrast validation
defineTheme checks the generated colours against WCAG 2.2 in both modes. Content and controls can
sit on any surface, so every surface check covers all three: base, subdued, and raised.
At 4.5:1:
color.foreground.primaryandcolor.foreground.secondaryagainst every standard surface and all six roles' subtle resting backgrounds.- Each role's rest, hover, and pressed foreground against every standard surface and every role's subtle resting backgrounds, including cross-role combinations.
- Each role's
onSolidagainst its solid rest, hover, and pressed backgrounds.
Separately, the compiler guarantees that interaction states are visibly different. Every role's
solid hover and pressed colours differ from the state before them, and the shared interaction tint
does the same over the surfaces and subtle backgrounds. A shadow or controlFinish never counts
toward that, so a flat theme still shows every state. Whether a state looks raised or recessed is up
to your materials. If your surfaces leave no tint that is both visibly different and readable,
defineTheme throws ThemeInteractionTintError, naming the mode and the limiting pair. Move the
surface or subtle background it names. The
generation guide
describes how the states are generated.
At 3:1, against every surface:
color.border.control.rest,color.border.control.hover, andcolor.border.control.pressed, also against every role's subtle background. A failure names the token, the background, and the ratio.color.border.<role>.strongfor all six roles, also against that role's own subtle background.color.border.danger.strongis the boundary of an invalid control.
At 3:1, against every surface and all six roles' subtle backgrounds:
color.focus, the focus ring. A failure says whether the colour was authored or is Luke UI's default for that mode. To fix a default, setcolor.focus. Luke UI never changes an authored focus colour.
At 3:1, for the active Select or Combobox option's inset outline:
color.foreground.accent.onSolidagainstcolor.background.accent.solid.restandcolor.background.accent.solid.pressed. The same pairs already clear 4.5:1 as on-solid text.
color.focus is not checked against solid fills. Inside a role's solid Box, such as
Box backgroundColor="warning.solid", the ring paints that role's onSolid colour, which the
on-solid text checks already hold at 4.5:1 against the solid.
If any pair fails, defineTheme throws ThemeContrastError instead of returning CSS. Its
failures array names the colour mode, foreground and background tokens with their states, achieved
ratio, and required ratio for every failing pair. Validation uses the final rounded colours mapped
into the sRGB gamut. Custom themes that previously compiled may fail the cross-role subtle checks.
Adjust the authored sources or surfaces to satisfy every pairing. The compiler does not repair
cross-role failures.
Validation guarantees the token colours above. It does not see controlFinish. A solid or standard
control paints its finish over its fill and under its text (a hovered or pressed low Button over a
solid fill does too), so a highlight on a dark fill, or a shade on a light one, lowers the text's
contrast. Keep each finish faint enough that the text still reaches 4.5:1 over it.
Luke UI checks the rendered result for Paper and Tactile in a separate test that runs in Chromium.
For all six roles, in light and dark mode, it paints the solid fill, its controlFinish rung, and
the onSolid text for rest, hover, and pressed. It then measures the contrast of the text colour
against every pixel behind the text, so a gradient that fails only in the middle fails the test.
That check is a guarantee for the bundled themes, and the repository's
test guide
shows how to run it on yours from a Luke UI checkout. It also measures high, standard and low
Buttons, at the medium and small sizes, over the page surfaces and every role's solid fill, in rest,
hover and pressed, and requires 4.5:1 for each label. It covers those Button labels only. It does
not cover other layering you apply to a finish, text sizes with different thresholds, disabled
controls, or forced colours, and a pass does not make any other composition accessible.
Role borders, such as color.border.accent, and color.border.decorative never fail validation.
Never rely on one alone to show a required state such as selected, invalid, or checked. Pair it with
text, an icon, or one of the colours above.
When a source colour cannot be used
A narrow band of lightnesses has no near-white or near-black text that reaches 4.5:1. When you
author one chromatic role as one string, defineTheme keeps its hue, searches for a lightness that
works, and may reduce its chroma to fit sRGB. It also adapts a missing mode from the side you set.
When you set a mode explicitly, defineTheme uses that colour for the mode instead of adapting it.
Its solid can still come out lighter or darker than you authored, because the compiler adjusts a
solid's lightness until onSolid text reaches 4.5:1.