Authoring a theme
Compile a curated theme input into a static Luke UI theme stylesheet.
Author a custom theme when the bundled identities do not suit the product. defineTheme turns a
small, curated ThemeInput into static CSS for one identity with light and dark modes. It does not
need a browser or a Luke UI release.
Define the input
A basic theme authors an accent colour and a neutral character; everything else defaults. A
ThemeInput includes:
- a kebab-case
name color.accent, the required brand or interaction accentcolor.neutralorcolor.neutralStyle('cool','neutral', or'warm') for the neutral canvas anchor- optional
color.backgroundto separate the page canvas from the neutral family's hue and chroma character. It defaults to the resolved neutral canvas anchor. - optional
color.info,color.success,color.warning,color.danger,color.focus, andcolor.scrim - optional
typography,radius,depth, andactionControlFinishoverrides
Each colour accepts one string, adapted independently for light and dark, or an explicit
{ light, dark } pair where either side can be omitted to fall back to that role's default. Luke UI
supplies accessible mode-specific defaults for every optional colour. radius is a generative
base and multiplier scale with explicit per-step overrides. depth and actionControlFinish
are optional and deep-partial per mode over curated, extremely-subtle defaults. The type scale and
motion values are source-owned and are not authored.
Prop
Type
The minimal theme authors one colour and a neutral character:
import { defineTheme } from '@luke-ui/react/theme';
const css = defineTheme({
name: 'product',
color: { accent: '#3b82f6', neutralStyle: 'cool' },
});Generate a stylesheet
Call defineTheme from an application build script and write the returned CSS to a stylesheet your
framework can load.
import { defineTheme } from '@luke-ui/react/theme';import type { ThemeInput } from '@luke-ui/react/theme';import { writeFile } from 'node:fs/promises';export async function writeTheme(input: ThemeInput) { await writeFile('src/product-theme.css', defineTheme(input));}Apply themeRootClassName and themeClassName('product') to the same root. The supplied name must
match the input's name. Colour mode uses the same data-color-mode attribute as bundled themes.
import { themeClassName, themeRootClassName } from '@luke-ui/react/theme';import { cx } from '@luke-ui/react/utils';import type { PropsWithChildren } from 'react';type AppProps = PropsWithChildren<{ themeStylesheetHref: string }>;export function App({ children, themeStylesheetHref }: AppProps) { return ( <> <link href={themeStylesheetHref} rel="stylesheet" /> <div className={cx(themeRootClassName, themeClassName('product'))}>{children}</div> </> );}Applications are responsible for loading any non-system font files selected by their typography.
Contrast validation
defineTheme calculates the generated colour values in OKLCH, maps them to the sRGB gamut, and runs
a WCAG 2.2 validation matrix over the result. Some pairs are hard requirements, some are measured
only, and one category is not checked.
Guaranteed at 4.5:1
Every text/surface pair the compiler emits is a hard gate: color.text.primary and
color.text.secondary against all four elevation surfaces (canvas, recessed, floating,
overlay), color.text.primary against the neutral subtle trio, each action or border/text
intent's text (and accent's textHover) against the base surfaces and its own subtle trio,
feedback intents' text against the base surfaces and their subtle surface, and every intent's
onSolid text against its solid, solidHover, and solidPressed surfaces.
Guaranteed at 3:1
Two non-text pairs are also hard gates: color.border.focus and color.border.control must both
reach 3:1 against the canvas and recessed surfaces. border.control is a dedicated solved colour
for this boundary, not a scale-step alias that happens to pass.
If any hard gate fails, defineTheme throws ThemeContrastError. Its failures array identifies
each colour mode and token pair, so the stylesheet is never written with an invalid combination.
Measured only, cannot fail the build
The five color.intent.*.border pairs (accent, danger, info, success, warning) are
checked against the base surfaces at 3:1, but only as advisory diagnostics. A miss is recorded and
reported, but never thrown. These borders use the same subtle scale step as ordinary separators, so
an author can choose a neutral character whose intent borders read as gentle separators rather than
assertive boundaries. Diagnostics record whether each check is a hard gate or advisory-only
(ContrastCheck.hard), so tooling can tell the two apart without guessing from the token path.
Because these pairs are not guaranteed to reach 3:1, do not rely on an intent border alone to communicate an error, warning, or success state. Pair it with text, an icon, or a guaranteed-contrast surface change so the state is still legible for a theme whose intent border happens to measure under 3:1.
Never checked
color.border.decorative has no contrast check at all, in any mode. Treat it as a pure visual
separator, never as the sole signal for state or meaning.
When generation itself fails
Separately from contrast validation, compileTheme (and so defineTheme) can throw
ThemeGenerationError when a role that must guarantee on-solid text cannot reach an accessible
solid colour. For a single-value accent, defineTheme holds the hue and chroma while searching for
a lightness that clears the same on-solid gate the generator enforces internally. An explicit
{ light, dark } accent skips that adaptation and is used verbatim for each side. If its authored
lightness sits in an on-solid dead zone (no near-white or near-black text reaches 4.5:1 against it),
compileTheme throws ThemeGenerationError naming the failing role and mode. Author an explicit
{ light, dark } accent when you need exact control over its lightness. Otherwise, author a single
value and let defineTheme adapt it.
Next steps
Read Applying a theme for root, mode, and portal setup. The token reference lists the semantic variables every generated theme provides.