Theming
Brio's color system is built on a 12-step palette per family,
role-mapped at each step (1 = app background, 9 = brand color,
11 = low-contrast text, etc.). The default values are derived
from Radix Colors;
the specific scales brio uses for each family are recorded in
tokens.css. This page covers two theming workflows:
- Swap a family to a different ready-made scale (e.g., violet primary instead of indigo). Twelve token overrides, no custom values.
- Build a custom palette when no existing scale fits. Twelve hand-tuned OKLCH values per family, anchored by the role of each step.
Both approaches preserve brio's semantic-token layer
(--color-primary, --color-on-primary,
--color-{family}-subtle, etc.) -- the layer
component CSS reads from. Theming changes the palette stops the
semantic tokens reference; it doesn't change the contract of
the semantic tokens themselves.
The 12-step role table
Every family follows the same step roles, regardless of hue. Knowing what each step is for is the foundation of theming.
| Step | Role | brio token that uses it |
|---|---|---|
| 1 | App background (lightest in light mode, darkest in dark) | --color-surface for neutral; --color-on-{family}-subtle for dark text |
| 2 | Subtle background | --color-{family}-subtle; --color-surface-sunken, --color-surface-raised for neutral |
| 3 | UI element background (default) | --color-fill, --color-surface-float for neutral |
| 4 | UI element background (hover) | --color-fill-on-float for neutral |
| 5 | UI element background (active) | --color-{family}-soft; --color-fill-strong for neutral |
| 6 | Subtle borders, separators | --color-border-subtle for neutral |
| 7 | UI element borders, focus rings | --color-{family}-border, --color-border for neutral |
| 8 | Hovered UI element borders | --color-border-strong for neutral |
| 9 | Solid background -- the brand stop, highest chroma. Same value in both modes. | --color-{family}, --color-focus, --color-selection-bg |
| 10 | Hovered solid background | (derived in components via color-mix) |
| 11 | Low-contrast text (APCA Lc 60 against step 2) | --color-{family}-text, --color-text-muted for neutral, --color-link in dark mode |
| 12 | High-contrast text (APCA Lc 90 against step 2) | --color-text for neutral, --color-on-{family}-subtle, --color-on-{family}-soft |
Step 9 is the most important for theming: it's the brand color, and it doesn't flip per mode. Whatever you choose for step 9 is what users see as "this is the primary/accent/etc. color" in both light and dark themes. Steps 1-8 walk lighter to darker in light mode and reverse in dark mode; step 9 anchors the middle.
Swap a family to a different scale
The simplest theming move. src/css/palettes.css
in the brio repo is a reference file -- it's not
loaded at runtime by brio.css and shouldn't be loaded by your
project. Open it, find the scale you want (the full Radix
set is included, neutrals and hues across the spectrum), and
copy the 12 OKLCH values into your own theme override.
To swap brio's default --primary-* family from
indigo to violet, copy violet's 12 values from
palettes.css and paste them into your project's
CSS, renaming --violet-N to --primary-N:
/* In your project's CSS, loaded after brio.css. The values below
are violet from palettes.css; rename to --primary-* on paste. */
@layer overrides {
:root {
--primary-1: light-dark(oklch(99.2% 0.003 308.4), oklch(19.1% 0.026 290.8));
--primary-2: light-dark(oklch(98.3% 0.009 299.2), oklch(21.1% 0.032 300.9));
--primary-3: light-dark(oklch(96.2% 0.019 299.1), oklch(27.1% 0.065 294.4));
--primary-4: light-dark(oklch(93.2% 0.037 297.5), oklch(31.2% 0.093 292.1));
--primary-5: light-dark(oklch(90.4% 0.052 295.0), oklch(34.9% 0.099 291.3));
--primary-6: light-dark(oklch(86.4% 0.072 293.7), oklch(38.9% 0.102 292.1));
--primary-7: light-dark(oklch(80.6% 0.090 293.6), oklch(44.5% 0.110 292.0));
--primary-8: light-dark(oklch(73.0% 0.119 292.6), oklch(51.8% 0.131 290.3));
--primary-9: light-dark(oklch(54.2% 0.179 288.0), oklch(54.2% 0.179 288.0));
--primary-10: light-dark(oklch(51.1% 0.177 287.7), oklch(58.9% 0.169 289.4));
--primary-11: light-dark(oklch(50.8% 0.159 288.6), oklch(77.8% 0.125 293.2));
--primary-12: light-dark(oklch(31.3% 0.097 286.6), oklch(91.2% 0.045 292.4));
}
}
Wrap the override in @layer overrides. brio's layer
order ends with overrides -- placing the swap there
makes it beat brio's default tokens (in the tokens
layer) while still being overridable by anything declared later
in the same layer. Bare :root works (unlayered styles
win over layered) but ties the override into a different cascade
rule than the rest of brio expects.
All semantic tokens that reference the primary family
(--color-primary, --color-link,
--color-focus, --color-primary-subtle,
etc.) automatically pick up the new values -- they reference
var(--primary-N), and we just changed what
--primary-N resolves to.
Same shape applies to accent, danger, success, warning, and neutral. Swap any subset; brio's other families stay default.
Watch the on-loud foreground
One token doesn't follow the family swap automatically:
--color-on-{family} is a fixed literal value
(white or a dark family-tinted dark) chosen to clear contrast
against step 9 of the default scale. If you swap to a scale
whose step 9 has very different lightness, you may need to
adjust the foreground:
- Most scales work fine with white text (clears WCAG AA-large or AA).
- High-luminance scales -- like yellow, amber, lime, or mint -- need dark text.
- Cyan is borderline: white fails WCAG AA-large; default brio uses dark family-tinted text on accent (cyan). Same applies if you swap accent to a similarly-light scale.
When in doubt, check the contrast table on the tokens page after applying your overrides -- it computes WCAG and APCA ratings live.
Build a custom palette
When no existing scale matches your brand. The methodology is the same one Radix uses: anchor step 9 at your saturated brand point, then walk outward, tuning each step against APCA contrast targets for its role.
Step by step
-
Pick step 9 -- the brand color.
Choose the most-saturated point you want to represent the
family. This is what "primary" or "danger" actually looks
like to users. Keep it the same in both modes -- step 9 is
mode-invariant. Sketch it in OKLCH:
oklch(L% C H)where L is typically 50-65% and C is the highest chroma you want at that lightness. - Pick step 1 -- the app background. Light mode: very light tint (L ~99%, near-white with a whisper of the family hue). Dark mode: very dark tint (L ~17-19%, near-black with the same hue identity). Both very low chroma so the bg doesn't fight content.
- Pick step 12 -- the high-contrast text. Light mode: dark family-tinted (L ~22-35%, moderate chroma). Dark mode: light family-tinted (L ~90-95%, low chroma). Verify APCA Lc 90 (or close) against step 2.
- Walk steps 2-8 between step 1 and step 9. Lightness ramps progressively. Chroma generally builds toward step 9. Each step has a role -- check the role table above and tune the value for that role.
- Step 10 mirrors step 9 slightly darker (light mode) or slightly lighter (dark mode). Hovered version of the brand. Often just a 5-10% L shift.
- Step 11 walks between step 10 and step 12. Targets APCA Lc 60 against step 2. In light mode, sits at L ~50%; in dark mode, at L ~75-80%.
- Verify in both modes. Apply the palette and check the contrast table. Tune any step that misses its role's contrast target.
Contrast targets per step
The two stops with hard contrast targets are 11 and 12 -- they must clear APCA Lc 60 and Lc 90 against step 2 respectively. The intermediate steps don't have strict targets; they're visually tuned for the role.
| Step | Pairs against | Target |
|---|---|---|
| 11 | step 2 background | APCA Lc 60+ (low-contrast text) |
| 12 | step 2 background | APCA Lc 90+ (high-contrast text) |
| 9 | white text | WCAG AA-large for buttons (3:1+); some scales need dark text instead |
| 7-8 | step 1-2 background | visible borders; no strict ratio but should be perceptible |
Paired neutrals
Radix recommends pairing the neutral with a hue-adjacent scale for cohesion -- the saturated neutral inherits a whisper of the accent's hue, so the whole palette feels like one family.
| Neutral | Pairs naturally with |
|---|---|
| gray | any -- pure neutral, hue-agnostic |
| mauve | tomato, red, ruby, crimson, pink, plum, purple, violet |
| slate | iris, indigo, blue, sky, cyan |
| sage | teal, jade, green, mint |
| olive | grass, lime |
| sand | orange, amber, yellow, brown |
To use a paired neutral instead of pure gray, find the scale
in palettes.css and copy its 12 values, renaming
on paste:
/* Slate values from palettes.css, renamed to --neutral-*. */
@layer overrides {
:root {
--neutral-1: light-dark(oklch(99.1% 0.001 286.4), oklch(17.9% 0.004 286.0));
--neutral-2: light-dark(oklch(98.3% 0.003 286.4), oklch(21.3% 0.004 264.5));
/* ...through 12 */
}
}
The neutral's hue subtly shifts surface, fill, and text colors throughout brio toward the matching accent's hue family.
Ship a theme as a swappable file
For brio's docs site (and any consumer who wants the same
pattern), themes can ship as standalone files in
src/css/themes/. The brio CLI auto-discovers
every .css in that directory and bundles it to
/assets/themes/{name}.css. Themes are not
imported by brio.css; they're loaded via a
runtime <link> override that the
<docs-themepicker> custom element manages.
To add a new theme, drop a CSS file into
src/css/themes/ with the same shape as the
swap example above (wrapped in @layer overrides),
and add a corresponding data-theme button to the
docs theme picker in the header partials.
Themes can override any token, not just palette stops. The
bundled sunset.css stress-test theme, for example,
swaps all six families AND tunes --color-shift,
--color-on-warning, and --radius-scale
to give the theme a coherent character beyond just the colors.
"I have a brand color, now what?"
The most common theming entry point: a stakeholder hands you a hex value or two. Recipe:
- Convert the brand color to OKLCH using a tool like oklch.com. Note the L, C, and H values.
- Find the closest Radix scale. The hue (H) is the strongest signal. Roughly: H ~30 = tomato/red; ~80 = amber/yellow; ~155 = green/jade/grass; ~200 = cyan/sky/teal; ~250 = indigo/blue/iris; ~270 = violet/purple/iris; ~310 = plum/pink. Find the scale whose step 9 has the closest hue.
- If close enough, swap to that scale using the 12 alias lines from the swap section. You'll get a 12-step palette tuned for accessibility "for free."
- If the brand color is too specific to fit a ready scale (custom hue, high or low chroma the scale doesn't reach), build a custom palette with the brand color as step 9. Use the methodology above. Plan to spend an afternoon tuning, then verify in the contrast table.
Verifying your theme
The contrast section of the tokens page shows WCAG ratios and APCA Lc values for every semantic pairing brio components use. Apply your theme override in the dev tools or in your project CSS, reload, and scan the table:
- WCAG AA (4.5:1+) for body-sized text on neutral surfaces.
- WCAG AA-large (3:1+) for button text -- buttons typically qualify as "large" via bold weight and 14pt+ effective size.
- APCA Lc 75+ for body text against subtle bgs.
- APCA Lc 60+ for content text on tinted bgs.
Both contrast models are shown because they sometimes disagree on borderline cases. WCAG can rate dark-on-saturated-mid as passing while APCA flags it as weak (a known WCAG 2 weakness with mid-luminance saturated colors); conversely, some Radix-derived loud bgs pass APCA for body text but only clear WCAG AA-large with white. brio targets WCAG AA-normal where possible; the APCA column is informational and a reference for WCAG 3 readiness.
References and attribution
The 12-step palette model and per-step role mapping are adopted
from Radix Colors v3.x
by WorkOS. Default scale values in tokens.css and
the full reference set in palettes.css are
sRGB-to-OKLCH conversions of Radix's published values.
APCA contrast model: APCA-W3 Bronze Simple Mode by Andrew Somers (lead author on WCAG 2's contrast section). Proposed for WCAG 3.