Skip to main content
Normal UI

Themes

A theme is Normal UI with different values. It lists only what it changes and what it adds, in themes/<id>/tokens.json, plus a few element rules in themes/<id>/theme.css. Everything it does not mention stays Normal UI. Set it with data-theme:

<!-- Follows the device's light or dark -->
<html data-theme="govuk">
<!-- Forced dark -->
<html data-theme="govuk" data-color-scheme="dark">
<!-- One part of a page -->
<section data-theme="mcmaster">…</section>

Use the Theme switch at the top of any docs page to see every component, template and page in a theme; the previews follow it.

What a theme can change

  • Every token, and the React components read them all, so they follow the theme:
    • colours in each mode, including button variants (button-primary-*, button-warning-*), hover and pressed fills, the button edge, status colours (status-error…), the focus inset, and the page and masthead fills;
    • font stacks, and web fonts through type.fonts; the root size, with breakpoints (type.rootSize);
    • spacing and radius;
    • shape: border widths, target-min, button padding, edge, shadow and press offset, focus width and offset, checkbox and radio size, the sheet shadow;
    • text: heading sizes for content and app UI, heading weight, control text, eyebrows, the small floor, error weight, and the link underline in navigation.
  • Its modes. "modes": ["light"] keeps a theme light whatever the device or data-color-scheme says.
  • Element rules in theme.css, in the normal-ui-theme layer: after normal-ui, before StyleX. For what tokens cannot say: a dotted heading rule, catalog tables, drawn checkboxes. They restyle plain HTML but never beat a component's own styles.
  • Added tokens (marked added below) for things Normal UI has no role for, such as a bevel. In StyleX import them from src/themes.stylex.ts.

Normal UI itself keeps its rules: every variant and status draws the same, links in navigation stay underlined, and the page, masthead and sheet look like the plain page. The tokens default to those values.

The contrast gate

pnpm tokens checks every theme, in both modes, against the same pairs as Normal UI (text 4.5:1, control edges, focus ring and accent 3:1), plus any pairs the theme adds. A failure stops the build. A theme may waive a pair only with a written reason, listed under the theme.

GOV.UK data-theme="govuk" · light only

Public-service clarity: 19px Arial, grey buttons with a solid bottom edge, green for the action a page leads with, red for destructive ones, 2px black field borders, square corners, the blue header band and the yellow-and-black focus state. Light only, like GOV.UK. Inspired by design-system.service.gov.uk.

Open the GOV.UK specimen on its own page

Deliberate departures

  • Light only: GOV.UK has no dark mode, so this theme stays light whatever the device or data-color-scheme says.
  • Text is 16px on screens up to 640px and 19px above (root 100% / 118.75%).
  • Arial stands in for GDS Transport, which is licensed for GOV.UK services only; never add it to type.fonts. No crown, no logotype.
  • Focus is a 3px yellow outline with a black inset on fields and list items, so it holds 3:1 on white; focused links take the yellow fill with a black bar.
  • Secondary buttons are light grey on white: their edge is the 2px grey bottom shadow, as on GOV.UK, not a 3:1 border.
  • Table and section rules use the lighter GOV.UK border grey, below 3:1 (waived below). Fields keep full-strength borders.
  • rule on canvas (light) is 2.08:1, under 3:1. Table rows and section rules are decorative; content is separated by spacing as well. Control borders stay at full strength.

Tokens

TokenLightDarkUsage
--canvas oklch(100% 0 none) (no dark mode) Page background; the CSS system color Canvas. Every text token below is checked against it.
--canvas-text oklch(15.32% 0.002 197) (no dark mode) Body copy and headings on canvas. 21:1 light, 18.7:1 dark.
--surface-soft oklch(97.7% 0.006 239.8) (no dark mode) The faintest tint, for light grouping (a soft card). Everything on canvas keeps its contrast here: gray-text 4.87:1 light, 7.59:1 dark; control-border 4.28:1, 6.06:1.
--surface-muted oklch(96.16% 0.002 67.8) (no dark mode) A quieter panel for secondary content: muted and filled cards, recessed wells, separated bands. The darkest light tint that keeps gray-text at 4.5:1 (4.62:1; dark 6.74:1); control-border 4.06:1, 5.38:1.
--surface-raised oklch(100% 0 none) (no dark mode) A panel set on a tinted surface (the body of a muted card): white in light, one step lighter in dark. gray-text 5.17:1 light, 5.88:1 dark; control-border 4.54:1, 4.69:1.
--link oklch(49.672% 0.1254 249.8) (no dark mode) Unvisited links on canvas, always underlined. 9.4:1 light, 7.8:1 dark. The browser's LinkText, unchanged.
--link-visited oklch(42.21% 0.168 292.3) (no dark mode) Visited links on canvas. 11:1 light, 9.7:1 dark. The browser's VisitedText, unchanged.
--link-active oklch(15.32% 0.002 197) (no dark mode) A link while it is being pressed, on canvas. 4.53:1 light (passes AA by a hair; never use it for resting text), 9.5:1 dark.
--button-face oklch(96.16% 0.002 67.8) (no dark mode) Button fill. Dark is darkened from Chromium's #6b6b6b so white labels reach 6.9:1.
--button-text oklch(15.32% 0.002 197) (no dark mode) Button labels on button-face. 18.3:1 light, 6.9:1 dark.
--button-border transparent (no dark mode) Border of buttons. Same as control-border in Normal UI; a theme whose buttons are a solid fill may set it to button-face, as long as the face itself holds 3:1 against canvas.
--button-border-hover transparent (no dark mode) Button border under the pointer: full-strength text colour.
--button-border-disabled oklch(76.81% 0.004 236.5) (no dark mode) Dashed border of a disabled button. Same as button-border; a theme whose buttons have no border gives it one here so the button still has an edge.
--button-face-hover oklch(88.89% 0.002 67.8) (no dark mode) Button fill under the pointer. Same as button-face in Normal UI, where hover only darkens the border.
--button-edge oklch(61.92% 0.001 197.1) (no dark mode) Colour of the solid edge under a button (button-edge-width tall). None in Normal UI.
--button-primary-face oklch(51.37% 0.11 161) (no dark mode) Fill of the one button a form leads with (variant="primary"). Normal UI draws every variant the same: order and wording carry the emphasis. A theme may set it apart.
--button-primary-text oklch(100% 0 none) (no dark mode) Label on button-primary-face.
--button-primary-face-hover oklch(42.151% 0.0884 161.7) (no dark mode) Primary button fill under the pointer.
--button-primary-edge oklch(26.16% 0.062 157.4) (no dark mode) Edge under a primary button.
--button-warning-face oklch(55.79% 0.186 25.6) (no dark mode) Fill of a button that destroys or cannot be undone (variant="warning"). Identical to a plain button in Normal UI; the label says what it does.
--button-warning-text oklch(100% 0 none) (no dark mode) Label on button-warning-face.
--button-warning-face-hover oklch(47.52% 0.157 25.5) (no dark mode) Warning button fill under the pointer.
--button-warning-edge oklch(30.32% 0.096 31.7) (no dark mode) Edge under a warning button.
--field oklch(100% 0 none) (no dark mode) Fill of text inputs, textareas and selects.
--field-text oklch(15.32% 0.002 197) (no dark mode) Typed text on field. 21:1 light, 11.2:1 dark.
--control-border oklch(15.32% 0.002 197) (no dark mode) 1px border of buttons, fields, selects and fieldsets. At least 3:1 against canvas, field and button-face in both themes (light 4.5:1 on canvas, 3.95:1 on button-face; dark 6.7:1 on canvas, 4:1 on field).
--placeholder oklch(46.4% 0 none) (no dark mode) Placeholder text on field. 5.2:1 light, 5:1 dark. Replaces opacity-faded placeholders that drop under 4.5:1.
--gray-text oklch(40.45% 0.001 197.1) (no dark mode) Disabled labels and secondary notes on canvas. 5.2:1 light, 8.4:1 dark: disabled text stays readable; the dashed border says it is disabled.
--accent oklch(15.32% 0.002 197) (no dark mode) accent-color for checkboxes, radios, range, progress and the switch track. A non-text mark, but kept at text strength: 5.3:1 on white (light), 10.8:1 on dark canvas. Deepened from Chromium's #0075ff (4.2:1).
--focus-ring oklch(89.869% 0.1857 97.9) (no dark mode) The 2px solid :focus-visible outline, offset 2px onto canvas. 6:1 light, 10.8:1 dark; also 5.2:1 on light button-face.
--focus-inset oklch(15.32% 0.002 197) (no dark mode) A second ring drawn just inside a focused field or list item, for themes whose focus-ring is light (a yellow ring needs a dark inset to hold 3:1 on white). None in Normal UI.
--mark oklch(89.869% 0.1857 97.9) (no dark mode) highlight fill, the same yellow in both themes.
--mark-text oklch(15.32% 0.002 197) (no dark mode) Text on mark, black in both themes (19.6:1). Never let inherit white dark-theme text.
--rule oklch(76.81% 0.004 236.5) (no dark mode)
and table cell borders. Same as control-border, so dividers stay visible at 3:1+.
--status-error oklch(55.79% 0.186 25.6) (no dark mode) Border and lead word of error messages and invalid fields. Text colour in Normal UI: the words say it is an error, never colour alone. A theme may give it a hue at 4.5:1.
--status-success oklch(51.37% 0.11 161) (no dark mode) Border and lead word of success messages.
--status-info oklch(53.47% 0.136 249.9) (no dark mode) Border and lead word of notes.
--masthead oklch(53.47% 0.136 249.9) (no dark mode) Fill of a .pd-masthead band. Same as canvas in Normal UI; a theme may make it a brand colour.
--masthead-text oklch(100% 0 none) (no dark mode) Text and links on masthead.
--link-hover added oklch(33.28% 0.078 249.1) (no dark mode) A link under the pointer, with a 3px underline.
--font-sansArial, Helvetica, sans-serif
--font-size-root100%, 118.75% from 40.0625emRoot text size; every rem follows it.
--space-xs5px
--space-sm10px
--space-md20px
--space-lg30px
--space-indent20px
--radius-control0
--control-border-width2px
--button-padding-block8px
--button-padding-inline10px
--button-edge-width2px
--button-press-offset2px
--focus-width3px
--focus-offset0px
--choice-size2.105rem
--heading-12.5rem
--heading-21.875rem
--heading-31.25rem
--heading-41rem
--heading-51rem
--heading-61rem
--app-heading-11.875rem
--app-heading-21.25rem
--error-weight700

McMaster-Carr data-theme="mcmaster"

A dense industrial catalog: 10.5pt Arial, mostly greyscale, quiet underlines, hairline table rules, a yellow row highlight, and catalog green for the masthead and the leading action. Inspired by mcmaster.com.

Open the McMaster-Carr specimen on its own page

Deliberate departures

  • Text is set at 14px (10.5pt, root 87.5%), the catalog's density. It stays in rem, so zoom and text-size settings still work; stops at the 13px floor.
  • Link underlines in running text are drawn at 35% strength until hover. They are still there, so links are never marked by colour alone (1.4.1). Links in
  • Table rules are hairlines below 3:1. They separate rows for the eye; no information depends on seeing them (waived below).
  • rule on canvas (light) is 1.61:1, under 3:1. Hairline row rules are decorative: rows are also separated by spacing and the hover highlight.
  • rule on canvas (dark) is 1.68:1, under 3:1. Hairline row rules are decorative: rows are also separated by spacing and the hover highlight.

Tokens

TokenLightDarkUsage
--canvas oklch(100% 0 none) oklch(19.57% 0 none) Page background; the CSS system color Canvas. Every text token below is checked against it.
--canvas-text oklch(0% 0 none) oklch(94.31% 0 none) Body copy and headings on canvas. 21:1 light, 18.7:1 dark.
--surface-soft oklch(97.61% 0 none) oklch(22.64% 0 none) The faintest tint, for light grouping (a soft card). Everything on canvas keeps its contrast here: gray-text 4.87:1 light, 7.59:1 dark; control-border 4.28:1, 6.06:1.
--surface-muted oklch(94.91% 0 none) oklch(26.03% 0 none) A quieter panel for secondary content: muted and filled cards, recessed wells, separated bands. The darkest light tint that keeps gray-text at 4.5:1 (4.62:1; dark 6.74:1); control-border 4.06:1, 5.38:1.
--surface-raised oklch(100% 0 none) oklch(28.5% 0 none) A panel set on a tinted surface (the body of a muted card): white in light, one step lighter in dark. gray-text 5.17:1 light, 5.88:1 dark; control-border 4.54:1, 4.69:1.
--link oklch(40.922% 0.1065 250.2) oklch(76.88% 0.084 251.1) Unvisited links on canvas, always underlined. 9.4:1 light, 7.8:1 dark. The browser's LinkText, unchanged.
--link-visited oklch(42.88% 0.132 299.4) oklch(77.82% 0.096 303) Visited links on canvas. 11:1 light, 9.7:1 dark. The browser's VisitedText, unchanged.
--link-active oklch(47.55% 0.195 29.2) oklch(79.78% 0.116 20) A link while it is being pressed, on canvas. 4.53:1 light (passes AA by a hair; never use it for resting text), 9.5:1 dark.
--button-face oklch(96.12% 0 none) oklch(32.9% 0 none) Button fill. Dark is darkened from Chromium's #6b6b6b so white labels reach 6.9:1.
--button-face-hover oklch(92.49% 0 none) oklch(37.15% 0 none) Button fill under the pointer. Same as button-face in Normal UI, where hover only darkens the border.
--button-primary-face oklch(52.38% 0.134 144) oklch(52.38% 0.134 144) Fill of the one button a form leads with (variant="primary"). Normal UI draws every variant the same: order and wording carry the emphasis. A theme may set it apart.
--button-primary-text oklch(100% 0 none) oklch(100% 0 none) Label on button-primary-face.
--button-primary-border oklch(42.9% 0.107 144.3) oklch(78.39% 0.132 145.3) Border of a primary button.
--button-primary-face-hover oklch(46.88% 0.12 144.1) oklch(46.88% 0.12 144.1) Primary button fill under the pointer.
--field oklch(100% 0 none) oklch(23.93% 0 none) Fill of text inputs, textareas and selects.
--field-text oklch(0% 0 none) oklch(94.31% 0 none) Typed text on field. 21:1 light, 11.2:1 dark.
--control-border oklch(57.95% 0 none) oklch(64.01% 0 none) 1px border of buttons, fields, selects and fieldsets. At least 3:1 against canvas, field and button-face in both themes (light 4.5:1 on canvas, 3.95:1 on button-face; dark 6.7:1 on canvas, 4:1 on field).
--placeholder oklch(48.55% 0 none) oklch(72.52% 0 none) Placeholder text on field. 5.2:1 light, 5:1 dark. Replaces opacity-faded placeholders that drop under 4.5:1.
--gray-text oklch(48.55% 0 none) oklch(72.52% 0 none) Disabled labels and secondary notes on canvas. 5.2:1 light, 8.4:1 dark: disabled text stays readable; the dashed border says it is disabled.
--accent oklch(52.38% 0.134 144) oklch(78.39% 0.132 145.3) accent-color for checkboxes, radios, range, progress and the switch track. A non-text mark, but kept at text strength: 5.3:1 on white (light), 10.8:1 on dark canvas. Deepened from Chromium's #0075ff (4.2:1).
--focus-ring oklch(40.922% 0.1065 250.2) oklch(76.88% 0.084 251.1) The 2px solid :focus-visible outline, offset 2px onto canvas. 6:1 light, 10.8:1 dark; also 5.2:1 on light button-face.
--mark oklch(95.47% 0.094 99.4) oklch(95.47% 0.094 99.4) highlight fill, the same yellow in both themes.
--highlight oklch(95.47% 0.094 99.4) oklch(38.12% 0.053 98.3) ::selection background.
--highlight-text oklch(0% 0 none) oklch(100% 0 none) Selected text on highlight, links included. 13.9:1 light, 8.5:1 dark.
--rule oklch(84.52% 0 none) oklch(36% 0 none)
and table cell borders. Same as control-border, so dividers stay visible at 3:1+.
--status-error oklch(47.55% 0.195 29.2) oklch(79.78% 0.116 20) Border and lead word of error messages and invalid fields. Text colour in Normal UI: the words say it is an error, never colour alone. A theme may give it a hue at 4.5:1.
--status-success oklch(52.38% 0.134 144) oklch(78.39% 0.132 145.3) Border and lead word of success messages.
--masthead oklch(52.38% 0.134 144) oklch(39.41% 0.097 144.1) Fill of a .pd-masthead band. Same as canvas in Normal UI; a theme may make it a brand colour.
--masthead-text oklch(100% 0 none) oklch(100% 0 none) Text and links on masthead.
--font-sansArial, "Helvetica Neue", Helvetica, sans-serif
--font-size-root87.5%Root text size; every rem follows it.
--radius-control3px
--heading-11.5rem
--heading-21.25rem
--heading-31.05rem
--app-heading-11.3rem
--app-heading-21.1rem
--nav-link-decorationnone

US Graphics data-theme="usgraphics"

Engineering graphics: a technical-manual page. Neo-grotesque text set small and dense, black links, square bevelled buttons that press in, dotted section rules, a white sheet on a grey desk, and signal yellow for the leading action. Inspired by usgraphics.com.

Open the US Graphics specimen on its own page

Deliberate departures

  • Text is set at 14px (root 87.5%) for density. It stays in rem, so zoom and text-size settings still work; stops at the 13px floor.
  • Links are black, like the reference. In running text the underline stays, so colour never marks a link on its own (1.4.1); in
  • Univers and Berkeley Mono are commercial: they are used if installed, and nothing is downloaded. To ship them, add the licensed files under themes/usgraphics/fonts and list them in type.fonts.

Tokens

TokenLightDarkUsage
--canvas oklch(100% 0 none) oklch(20.02% 0 none) Page background; the CSS system color Canvas. Every text token below is checked against it.
--canvas-text oklch(0% 0 none) oklch(96.12% 0 none) Body copy and headings on canvas. 21:1 light, 18.7:1 dark.
--surface-soft oklch(97.61% 0 none) oklch(23.5% 0 none) The faintest tint, for light grouping (a soft card). Everything on canvas keeps its contrast here: gray-text 4.87:1 light, 7.59:1 dark; control-border 4.28:1, 6.06:1.
--surface-muted oklch(95.21% 0 none) oklch(26.86% 0 none) A quieter panel for secondary content: muted and filled cards, recessed wells, separated bands. The darkest light tint that keeps gray-text at 4.5:1 (4.62:1; dark 6.74:1); control-border 4.06:1, 5.38:1.
--surface-raised oklch(100% 0 none) oklch(29.31% 0 none) A panel set on a tinted surface (the body of a muted card): white in light, one step lighter in dark. gray-text 5.17:1 light, 5.88:1 dark; control-border 4.54:1, 4.69:1.
--link oklch(0% 0 none) oklch(96.12% 0 none) Unvisited links on canvas, always underlined. 9.4:1 light, 7.8:1 dark. The browser's LinkText, unchanged.
--link-visited oklch(40.91% 0 none) oklch(82.03% 0 none) Visited links on canvas. 11:1 light, 9.7:1 dark. The browser's VisitedText, unchanged.
--link-active oklch(47.55% 0.195 29.2) oklch(79.78% 0.116 20) A link while it is being pressed, on canvas. 4.53:1 light (passes AA by a hair; never use it for resting text), 9.5:1 dark.
--button-face oklch(95.51% 0 none) oklch(34.85% 0 none) Button fill. Dark is darkened from Chromium's #6b6b6b so white labels reach 6.9:1.
--button-face-hover oklch(91.89% 0 none) oklch(39.04% 0 none) Button fill under the pointer. Same as button-face in Normal UI, where hover only darkens the border.
--button-primary-face oklch(83.721% 0.1664 88.4) oklch(83.721% 0.1664 88.4) Fill of the one button a form leads with (variant="primary"). Normal UI draws every variant the same: order and wording carry the emphasis. A theme may set it apart.
--button-primary-text oklch(0% 0 none) oklch(0% 0 none) Label on button-primary-face.
--button-primary-face-hover oklch(78.913% 0.1615 86) oklch(78.913% 0.1615 86) Primary button fill under the pointer.
--control-border oklch(53.82% 0 none) oklch(65% 0 none) 1px border of buttons, fields, selects and fieldsets. At least 3:1 against canvas, field and button-face in both themes (light 4.5:1 on canvas, 3.95:1 on button-face; dark 6.7:1 on canvas, 4:1 on field).
--placeholder oklch(51.03% 0 none) oklch(73.16% 0 none) Placeholder text on field. 5.2:1 light, 5:1 dark. Replaces opacity-faded placeholders that drop under 4.5:1.
--gray-text oklch(51.03% 0 none) oklch(73.16% 0 none) Disabled labels and secondary notes on canvas. 5.2:1 light, 8.4:1 dark: disabled text stays readable; the dashed border says it is disabled.
--accent oklch(38.74% 0.15 266.4) oklch(78.25% 0.11 271.7) accent-color for checkboxes, radios, range, progress and the switch track. A non-text mark, but kept at text strength: 5.3:1 on white (light), 10.8:1 on dark canvas. Deepened from Chromium's #0075ff (4.2:1).
--focus-ring oklch(47.26% 0.198 260.5) oklch(78.05% 0.111 261.1) The 2px solid :focus-visible outline, offset 2px onto canvas. 6:1 light, 10.8:1 dark; also 5.2:1 on light button-face.
--mark oklch(83.721% 0.1664 88.4) oklch(83.721% 0.1664 88.4) highlight fill, the same yellow in both themes.
--field oklch(100% 0 none) oklch(23.5% 0 none) Fill of text inputs, textareas and selects.
--field-text oklch(0% 0 none) oklch(96.12% 0 none) Typed text on field. 21:1 light, 11.2:1 dark.
--status-error oklch(47.55% 0.195 29.2) oklch(79.78% 0.116 20) Border and lead word of error messages and invalid fields. Text colour in Normal UI: the words say it is an error, never colour alone. A theme may give it a hue at 4.5:1.
--page oklch(92.49% 0 none) oklch(14.48% 0 none) Behind a .pd-sheet page. Same as canvas in Normal UI, so a sheet is just the page.
--bevel-light added oklch(100% 0 none) oklch(46.76% 0 none) Top-left highlight of a bevelled button. Decorative; the border carries the 3:1 edge.
--bevel-shadow added oklch(53.82% 0 none) oklch(0% 0 none) Bottom-right shadow of a bevelled button and the sheet's drop shadow.
--font-sans"Univers LT Pro", Univers, "Helvetica Neue", Helvetica, Arial, sans-serif
--font-mono"Berkeley Mono", "TX-02", ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace
--font-size-root87.5%Root text size; every rem follows it.
--radius-control0
--button-shadowinset 1px 1px 0 var(--bevel-light), 1px 1px 0 var(--bevel-shadow)
--button-shadow-activeinset 1px 1px 0 var(--bevel-shadow)
--button-press-offset1px
--sheet-shadow3px 3px 0 var(--bevel-shadow)
--heading-11.5rem
--heading-21.143rem
--heading-31rem
--app-heading-11.25rem
--app-heading-21.071rem
--nav-link-decorationnone