Brio's color system is built on a 12-step palette per family, and mapped to roles 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. Note that there is a
palette.css file that defines all of the radix colors as
oklch colors in the source. This page covers two theming workflows:
- Swap a family to a different ready-made scale (e.g., violet 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 tokens that the components depend on. Theming changes the palette stops the semantic tokens reference, but it shouldn't change the contract of the semantic tokens themselves; unless that's what you want. You can certainly override those too.
Which names are safe to rely on? Class names, markup shapes, and the
--{component}-* tokens are the public surface; internal selectors can
change between releases. A renamed token or class ships as a deprecated alias for one
release before it goes, and the changelog's Breaking section carries the migration notes
for every release. The README.md states the full versioning contract.
The 12-step role table
Every family follows the same step roles, regardless of hue. So, knowing what each step is for is useful when thinking about a theme. These are derived from the Radix Radix Theme.
| 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 are 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 way to theme is to use the src/css/palettes.css
colors in the Brio repo. It's a reference file with all of the original Radix colors
converted to OKLCH. So, you can open that and copy the 12 OKLCH values into your own
theme override. Sorry, there is no fancy theme picker yet.
Here's an example. 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 {
:where(html) {
--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));
}
}
You can wrap the overrides in @layer overrides, but it will likely work
outside as well. Brio's layer order ends with overrides. So, 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 also works: Brio declares its tokens with
:where(html) (zero specificity) so an unlayered override wins on both the
layer rule and specificity, and no override ever needs !important.
All semantic tokens that reference the primary family (--color-primary,
--color-link, --color-focus,
--color-primary-subtle, etc.) automatically pick up the new values.
The same shape applies to accent, danger, success, warning, and neutral.
Watch the -on- colors
There is one token that doesn't follow the family swap automatically:
--color-on-{family} is a fixed literal value (white or a dark tinted), and
usually chosen to clear contrast against step 9 of the default scale. So, 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.
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 work outwardly, 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 faint tint 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 does not compete with 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 some of the accent's hue, so the palette feels more cohesive.
| 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 {
:where(html) {
--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 project who wants to use 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.
Theming one subtree instead of the page
A theme file ships at the page-level: its token block targets the document root, and its
component-layer rules (.button, .card, ...) match every
instance on the page. To theme a single section, take the token block and target your
scope instead. See the tokens page for examples of how to
override the colors.
The color mode describe the light or dark color preferences which can change the
theme from light-theme or dark-theme. You can use the
utility classes .light-theme or .dark-theme to change the
theme from light to dark on the page or within a specific container.
"I have a brand color, now what?"
- 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 table on the Tokens page shows WCAG
ratios and APCA Lc values for every semantic pairing Brio components use. The same
pairings run in CI as pnpm test:tokens, so the shipped defaults stay
checked without a browser. 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 differ 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.