On this page

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 font
  • color.accent, the required brand or interaction accent
  • color.neutral as a colour or { style: 'cool' | 'neutral' | 'warm' } for the neutral base anchor
  • optional color.surface colours for the base, subdued, and raised surfaces
  • optional color.info, color.success, color.warning, color.danger, color.focus, and color.backdrop
  • optional typography.fonts.display, typography.fonts.code, typography.fontWeight, radius, shadow, and controlFinish

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, and surface.base adapt 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 explicit light and dark values. For a single string, use that value for both.
  • focus and backdrop use one string as written in both modes. A missing side uses Luke UI's default for that mode.
  • surface.subdued and surface.raised take only the object, because one value cannot suit both modes. A missing side is generated from base. 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.

NameType
color*
{ accent: ColorInput; neutral?: NeutralInput; surface?: { base?: ColorInput; subdued?: ModeColorInput; raised?: ModeColorInput; }; info?: ColorInput; success?: ColorInput; warning?: ColorInput; danger?: ColorInput; focus?: ColorInput; backdrop?: ColorInput; }

Source colours. Each is one string or a { light, dark } object.

typography*
{ fonts: { body: ThemeFont; code?: string; display?: ThemeFont | null; }; fontWeight?: ThemeFontWeights; }

Fonts and weights. Luke UI owns type sizes, line heights, and letter spacing.

controlFinish
{ light?: Partial<ControlFinish>; dark?: Partial<ControlFinish>; } | undefined

Face finish per mode, painted on solid Buttons, standard Buttons, hovered or pressed low Buttons over a solid fill, the checked or indeterminate Checkbox, and the selected Switch track. Optional and deep-partial: an omitted rung falls back to 'none' (a flat control).

shadow
{ light?: Partial<ShadowLadder>; dark?: Partial<ShadowLadder>; } | undefined

Composite box-shadow ladder, per mode. Optional and deep-partial: an omitted rung falls back to the curated extremely-subtle default for that mode.

name*
string

Kebab-case theme identity, for example 'tactile'. The theme's identity class is luke-ui-theme-${name}.

radius
{ detail?: number; control?: number; surface?: number; overlay?: number; } | undefined

Corner radii in pixels, emitted as rem. radius.full is fixed at 9999px.

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.ts

The 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 name must be a kebab-case string.
  • Source colours other than backdrop must be #rgb, #rrggbb, or oklch(<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.
  • neutral accepts either a colour or a curated style, not both.
  • shadow, controlFinish, and backdrop values 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:

  • family must be a valid CSS font-family list: family names and generic families such as sans-serif, separated by commas. Quote a name unless it is plain words, such as Noto Sans, and quote a name that starts with a generic family, such as 'Serif Pro'.
  • family must not contain ;, {, }, <, \, /*, */, or control characters, even in quotes.
  • metrics.familyName must be a non-empty string.
  • The other metrics must be finite numbers. unitsPerEm, capHeight, and ascent must be positive, descent 0 or less, and lineGap 0 or more.
  • Font weights must be numbers from 1 to 1000.
  • The resolved label weight must be at least 100 heavier than body. 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.primary and color.foreground.secondary against 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 onSolid against 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, and color.border.control.pressed, also against every role's subtle background. A failure names the token, the background, and the ratio.
  • color.border.<role>.strong for all six roles, also against that role's own subtle background. color.border.danger.strong is 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, set color.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.onSolid against color.background.accent.solid.rest and color.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.

Continue learning