Skip to content

Design tokens

Every value is a CSS variable in tokens.css: colours, spacing and font sizes alike.

import "@lo-tek/interfaces/tokens.css"
.my-component {
color: var(--lt-color-accent-fill);
padding: var(--lt-spacing-4);
font-size: var(--lt-font-size-m);
}

One colour layer, not two. A colour is a point in a matrix of family, what it means, and role, what it does. The name is --lt-color-<family>-<role>.

Every family has the same roles. That structure is what makes them match: a danger area and a brand area look like siblings because they are built from the same roles at the same lightnesses.

FamilyFor
accentLinks, primary button, focus, selected state
visitedVisited links
brand1, brand2, brand3Surfaces, sections and categories. They mean nothing in themselves
neutralThe page, body text, borders
dangerErrors, deletion, rejection
warningNeeds attention
successCompleted, approved

Nine roles, the same for every family. Text and border have three steps each, mirroring each other.

RoleForPromise
surfaceTinted surface: a badge, an alert, a sectionnone
border-subtleDecorative line, such as between two rowsnone
borderThe border around a field or a surface3.1:1 against the page, against raised and against its own surface
border-strongThe field border on hover, and a table rulefollows from border, which it lies outside
fillFilled surface: a button, a checked checkbox3.1:1 against the page and against raised
contentThe text on top of fill, and on top of text on a button on hover4.6:1 against fill and against text
text-subtleMuted text: help text, placeholders4.6:1 against the page, against neutral-surface and against raised
textThe family’s colour as text, and the button on hoverthe same three, and against its own surface
text-strongBody text, headings and the link on hoverthe same three

The neutral family has two extra roles, because it carries the page itself:

TokenForPromise
--lt-color-neutral-canvasThe pageevery text role in every family holds against it
--lt-color-neutral-raisedA surface lying above a cardthe same

Text has to hold against all three layers: the page, neutral-surface and neutral-raised. A plain card has the page background (canvas), a filled card has neutral-surface, and a surface above a card has neutral-raised. If text only held against the page, a card on a card would become unreadable without anything saying so.

Each row is a family, each square a role, in the order above:

The hue is yours, the lightness is the role’s. The lightnesses are not picked by hand: they are computed against a sweep around the whole colour wheel at the highest chroma sRGB can show. That is why the promises hold whatever brand colour you give, and there is no adjustment pass that moves your colour afterwards.

If you write your own values, keeping the promises is up to you. lotek check-theme theme.css tells you which cell breaks and why.

TokenFor
--lt-color-disabled-surface, --lt-color-disabled-textDisabled controls. Exempt from the contrast requirement in WCAG 1.4.3, and muted on purpose: a control that is off should look off
--lt-color-overlayThe surface behind a modal
--lt-shadow-overlayThe shadow under a panel lying above the page
--lt-focus-ringThe whole ring around whatever has focus, as an outline shorthand. The width and style apply everywhere, the colour does not (see below the table)
--lt-icon-search, --lt-icon-check, --lt-icon-dash, --lt-icon-calendar, --lt-icon-clockIcons drawn into a background

The colour in --lt-focus-ring does not apply everywhere. It is replaced on purpose in the invalid state (Input, Textarea, Select, Checkbox, Radio), in the success state (Input, Textarea, Select), in Skip Link and Error Summary, and on the selected option in Select with a styled list and in Toggle Group, where the ring sits on a coloured surface. If you change the colour in the token, it does not apply there.

--lt-color-disabled-surface, --lt-color-disabled-text and --lt-focus-ring are built from other tokens and declared again at every theme boundary, so they follow the element’s theme. If you override one of them on :root, the override does not apply inside a data-theme. Write it on [data-theme="light"] and [data-theme="dark"] as well, the way the colours are written in Customising.

The icons do not follow the matrix. An icon drawn into a background cannot read a CSS variable: the stroke is painted into the image itself. They therefore have a dark and a light version, and switch with the theme. If you use them yourself, set them as background-image.

disabled or neutral? They look alike, but the requirements differ. disabled is for controls that are switched off. neutral is for muted information the user is meant to read or press, and keeps its promise. If the element is switched off, use disabled.

One scale, --lt-spacing-*, for everything spatial: padding, margin, gap, width, height. Four pixels as the base unit, as in Tailwind, so --lt-spacing-4 matches p-4.

VariableValueTailwind
--lt-spacing-px1pxpx
--lt-spacing-0-50.125rem0.5
--lt-spacing-10.25rem1
--lt-spacing-20.5rem2
--lt-spacing-30.75rem3
--lt-spacing-41rem4
--lt-spacing-51.25rem5
--lt-spacing-61.5rem6
--lt-spacing-71.75rem7
--lt-spacing-82rem8
--lt-spacing-102.5rem10
--lt-spacing-123rem12
--lt-spacing-164rem16

Its own rhythm rather than a numeric scale, so the name says how large the text is relative to body text.

--lt-font-size-m is body text. --lt-font-size-reference is 16px, the base size the rem values are computed from.

The values are the ones the components already had written out. They became tokens because an organisation adopting the system often has its own typography, and then the difference should be set in one place rather than in twenty stylesheets.

TokenValueUsed by
--lt-font-weight-regular400the markers “(required)” and “(optional)” on label and fieldset
--lt-font-weight-medium500button, error text
--lt-font-weight-semibold600accordion, alert, avatar, badge, breadcrumbs, card, dialog, error summary, fieldset, label, pagination, session timeout, table, tabs, toggle group
--lt-font-weight-bold700heading, and the asterisk marking a required field
--lt-line-height-default1.525 stylesheets: button, input, textarea, select, table, tabs, dialog and the rest of the controls
--lt-line-height-heading1.2heading
--lt-line-height-article1.6paragraph, list, card text
--lt-line-height-compact1.4help text, error text, tooltip, tag, card title, dialog title, the error summary’s title, the connection status bar

The button, the heading and the paragraph read them through their own component variable, so a theme can change every button without changing all text: font-weight: var(--lt-button-font-weight, var(--lt-font-weight-medium)). The others read the token directly. The split is deliberate: a separate variable per component is worth it where you want to tell them apart, and pure noise where you do not.

Three line heights are still plain numbers in the stylesheets. 1 in pagination, avatar and the close button on the toast is not typography but a way to centre a number or a cross in a circle. 1.3 and 1.55 appear in one component each.

Every variable name exists as a typed lookup table:

import {
colorTokens,
cssTokens,
type CssToken,
} from "@lo-tek/interfaces/tokens"
const token: CssToken = "--lt-color-accent-fill"
element.style.setProperty(token, "red")
colorTokens["--lt-color-accent-fill"] // "#226dba"
cssTokens["--lt-font-size-mega"] // "2.625rem"

If you write --lt-color-accent-fil, you get an error in the editor instead of a var() that silently falls back to nothing. The same goes for a role that does not exist: --lt-color-accent-fill-hover is a type error, not an empty value.

The colours are in colorTokens and darkColorTokens, the rest in cssTokens. The split exists because the colours are computed by the contract and are therefore not written in the source. CssToken covers both, since the colour names are derived from the two axes.

Build-time values, not CSS variables, and therefore exported as constants:

import { Breakpoints, Containers } from "@lo-tek/interfaces/tokens"
Breakpoints.md // "768px"
Containers.aside // "384px"

The breakpoints follow Tailwind: sm 640px, md 768px, lg 1024px, xl 1280px, 2xl 1536px.

The theme is a CSS file, not a JavaScript preset. The package therefore has no dependency on Tailwind at all, and two Tailwind versions cannot conflict: the version in your app is the one that reads the file.

app.css
@layer theme, base, lotek, lotek-theme, components, utilities;
@import "tailwindcss";
@import "@lo-tek/interfaces/tokens.css";
@import "@lo-tek/interfaces/tailwind.css";

The first line is the most important one. See Customising for why.

Everything the theme adds is named lt-, like the other classes in the system. That way bg-neutral-100 is still Tailwind’s own colour.

Every cell in the matrix becomes a class with the same name: bg-lt-accent-fill reads --lt-color-accent-fill.

ClassValue
bg-lt-accent-fill--lt-color-accent-fill
text-lt-accent-text--lt-color-accent-text
bg-lt-danger-surface--lt-color-danger-surface
text-lt-neutral-text-subtle--lt-color-neutral-text-subtle
border-lt-neutral-border--lt-color-neutral-border
text-lt-mega--lt-font-size-mega
max-w-lt-aside384px
shadow-lt-overlay--lt-shadow-overlay

The spacing is tied to the base unit: Tailwind computes p-4 as calc(var(--spacing) * 4), and --spacing is set to --lt-spacing-1. p-4 is thus four times the unit, which is --lt-spacing-4. The steps are separate values and are not computed from the unit, so if you change the scale, rewrite every step and not just --lt-spacing-1. Otherwise the Tailwind classes, and whatever in the components uses the unit directly, follow along, while the rest of the components stay where they were. Tailwind shows how.

The breakpoints are left out on purpose. LO-TEK’s sm to 2xl have the same values Tailwind already uses, so md: works as before.

The classes are not written from memory: every pull request compiles the theme with a real Tailwind and checks that every colour in the matrix comes out as a class with its token in it.

The full setup, with examples mixed freely with the components, is on Tailwind.

To change colours, shape or size, see Customising.