On this page

Theming

Colour

Use semantic surfaces, text, and roles instead of palette values.

Luke UI names colours by their job. A theme maps those roles to values for each identity and colour mode. A component then stays consistent, because it does not need to know the underlying palette.

Surfaces and content

Surface roles describe where an element sits in the interface:

  • color.background.surface.base: the default page and control background.
  • color.background.surface.subdued: secondary regions, such as a sidebar or a panel set apart from the page.
  • color.background.surface.raised: elevated panels, cards, menus, popovers, and dialogs. This is an opaque colour shared by inline and detached content.

color.background.backdrop dims content behind a modal. It is not a surface colour.

Choose borders and shadow separately. A raised surface does not add either, and flat cards can use base with a border or subdued.

Text uses primary and secondary roles. Borders distinguish decorative, control, and role uses. color.focus is a standalone colour, not a border, because it paints the focus outline. Text, role foregrounds, color.border.control.{rest,hover,pressed}, color.border.<role>.strong, and color.focus are guaranteed to contrast with all three surfaces. The control borders and color.focus also clear every role's subtle background. color.border.decorative is a separator with no contrast guarantee.

The focus ring paints color.focus, which clears 3:1 against the three surfaces and the six subtle backgrounds. Inside a role's solid Box, such as Box backgroundColor="accent.solid", the ring paints that role's onSolid colour instead, which clears 4.5:1 against the solid. Forced colours use Highlight.

import { vars } from '@luke-ui/react/theme';

<section
	style={{
		backgroundColor: vars.color.background.surface.subdued,
		borderColor: vars.color.border.decorative,
		color: vars.color.foreground.primary,
	}}
/>;

Choose the role that describes the element's purpose. Do not copy a resolved colour into application CSS. It will not adapt when the identity or colour mode changes.

Semantic roles

Six roles cover the meanings a component needs beyond plain surfaces and text: neutral, accent, info, success, warning, and danger. All six provide the same background, foreground, and border tokens. Choose a role for what the colour communicates:

  • neutral: no accent or status meaning, distinct from ordinary surfaces.
  • accent: preferred, selected, or emphasised. Not a promise of a brand colour.
  • info: noteworthy factual context.
  • success: completed, valid, or a positive result.
  • warning: needs attention but has not failed and is not dangerous.
  • danger: harm, failure, invalidity, or a critical condition.

Select a role to compare the same semantic token pairs. Change the document's colour mode to see the pairs in light and dark mode.

Colour: Roles
Account statusWarning
This notice uses the selected semantic role.
import { Box } from '@luke-ui/react/box';
import { Button } from '@luke-ui/react/button';
import { Cluster } from '@luke-ui/react/cluster';
import { Stack } from '@luke-ui/react/stack';
import { Text } from '@luke-ui/react/text';
import { useState } from 'react';

const roles = {
	neutral: 'Neutral',
	accent: 'Accent',
	info: 'Info',
	success: 'Success',
	warning: 'Warning',
	danger: 'Danger',
} as const;

type Role = keyof typeof roles;

export default () => {
	const [role, setRole] = useState<Role>('warning');

	return (
		<Stack gap="space.16">
			<Cluster aria-label="Semantic role" gap="space.8" role="group">
				{(Object.keys(roles) as Array<Role>).map((option) => (
					<Button
						aria-pressed={role === option}
						key={option}
						onPress={() => setRole(option)}
						prominence={role === option ? 'high' : 'standard'}
					>
						{roles[option]}
					</Button>
				))}
			</Cluster>
			<Box
				backgroundColor="surface.base"
				borderRadius="surface"
				display="grid"
				gap="space.8"
				padding="space.12"
			>
				<Box
					backgroundColor="surface.subdued"
					borderColor="decorative"
					borderRadius="surface"
					borderStyle="solid"
					borderWidth="thin"
					display="grid"
					gap="space.8"
					padding="space.8"
				>
					<Box
						backgroundColor={`${role}.subtle`}
						borderColor={`${role}.strong`}
						borderRadius="control"
						borderStyle="solid"
						borderWidth="thin"
						display="grid"
						gap="space.4"
						padding="space.12"
					>
						<Cluster alignItems="center" gap="space.4">
							<Text color={role} elementType="strong" fontWeight="emphasis">
								Account status
							</Text>
							<Box
								backgroundColor={`${role}.solid`}
								borderRadius="full"
								display="inline-flex"
								elementType="span"
								paddingBlock="space.4"
								paddingInline="space.8"
							>
								<Text elementType="span" fontWeight="emphasis" typography="caption">
									{roles[role]}
								</Text>
							</Box>
						</Cluster>
						<Text color={role}>This notice uses the selected semantic role.</Text>
					</Box>
				</Box>
			</Box>
		</Stack>
	);
};

Background and foreground

Each role has a resting subtle background and a solid background with rest, hover, and pressed states. Adaptive hover and pressed feedback uses the shared interaction tint pair:

import { vars } from '@luke-ui/react/theme';

vars.color.background.warning.subtle;
vars.interaction.tint.hover;
vars.interaction.tint.pressed;
vars.color.background.warning.solid.rest;
vars.color.background.warning.solid.hover;
vars.color.background.warning.solid.pressed;

Use a role's rest, hover, and pressed foreground on any standard surface or any role's subtle background. Use onSolid on that role's solid backgrounds:

vars.color.foreground.warning.rest;
vars.color.foreground.warning.hover;
vars.color.foreground.warning.pressed;
vars.color.foreground.warning.onSolid;

onSolid reaches at least 4.5:1 against solid.rest, solid.hover, and solid.pressed. Each state is visibly different from the one before it, whatever the theme's shadows and finishes.

Built-in controls select these tokens for hover and pressed. Custom interactive elements should do the same.

Borders

Each role has one state-free border, for example vars.color.border.warning.strong. The token describes meaning. The component decides when to apply it, such as only while invalid or as a permanent outline.

strong is the functional border. It reaches at least 3:1 against the three standard surfaces and that role's own subtle background in both modes. It carries no guarantee against other backgrounds. color.border.danger.strong marks an invalid control's boundary. Danger solid backgrounds carry the on-solid text guarantee, but have no 3:1 guarantee against those surfaces.

Roles have no decorative border. Use color.border.decorative for a separator or other decorative boundary. See contrast validation. Use color.border.control.rest when a custom element needs a guaranteed boundary, and its hover and pressed siblings for interaction feedback.

The numeric scale is private

Behind each role, the theme generator builds a numeric colour scale. It is not emitted as CSS and has no public token. Use the semantic roles in your application, and use defineTheme with source colours to author a theme.

Text on a background

A Luke UI background sets the text colour for everything inside it. A Box with backgroundColor is one. So are the surfaces components paint, such as a Button fill, a field, a popover, and a code chip. Each sets the text colour to its readable primary, which an Icon inside inherits, and tells Text and Heading inside it which colours are readable:

Backgroundprimary and secondaryA role, such as color="danger"
A surface, surface.*foreground.primary and foreground.secondaryThe role's foreground
Any role's subtle fillforeground.primary and foreground.secondaryThe role's foreground
A role's solid fillThat role's onSolidThat role's onSolid

A role keeps its hue on another role's subtle fill. The theme compiler checks every pairing in the table at 4.5:1 in both colour modes, and a theme that misses one does not compile. Pairings outside the table are not checked, such as disabled text and text on a background from your own CSS. A nested background sets a new context. A Box without backgroundColor keeps the surrounding one.

Box takes each role's resting subtle and solid fill. backgroundColor="accent.solid" paints vars.color.background.accent.solid.rest, and Box values never carry a state suffix. Interactive components own the hover and pressed fills and pair them for you. Use vars.color.background.accent.solid.hover and .pressed in your own CSS.

Custom surfaces

To make a custom element a Luke UI background, set backgroundColor on a Box that also carries your class. Put both on the same element rather than wrapping one in the other.

<Box backgroundColor="accent.solid" className={styles.card}>
	<Heading>Example heading</Heading>
	<Text>Inherits the fill's readable colour.</Text>
</Box>

A background from your own CSS, such as vars.color.background.accent.solid.rest, sets no text colour and does not change which colours Text reads. Nor does an image, a gradient, a translucent colour, or CSS that overrides a Box background. Luke UI does not detect whether a background is dark. Set the text colour in the same CSS and check its contrast yourself.

Disabled and loading states

Use the component's disabled or loading API when it has one. Those states account for interaction and accessibility as well as colour. Disabled is a state, not a palette: interactive controls fade with reduced opacity, and only field text recolours, through the public foreground.disabled role. On a solid fill, disabled field text uses the fill's onSolid instead. The public background.skeleton role is available when you build an equivalent custom element.

Continue learning