Skip to content

Theming

The entire palette is derived from one variable. Set a brand color and the library rebuilds twelve accent steps, a matching secondary ramp, brand-tinted neutrals, and a text color that stays readable on top of your fill — in light mode and dark mode, with no JavaScript, no build step and no theming runtime. This page is the written version of what the Theme Studio lets you try live; the studio's Copy CSS button emits exactly the declarations described here.

The seed

:root {
  --forte-accent-seed: #7c3aed;
}

That is a complete theme. Everything color-shaped that is not a status color follows: solid buttons, soft fills, borders, focus-visible text tones, hover states. Two more inputs refine it:

:root {
  --forte-accent-seed: #7c3aed;
  --forte-secondary-seed: #0e7490; /* a second brand color, same treatment */
  --forte-neutral-tint: 0.5;       /* 0 = pure gray … 1 = brand-tinted grays */
}

--forte-secondary-seed drives everything a component renders with tone="secondary". --forte-neutral-tint controls how much of the accent hue bleeds into the gray ramp — at 1 the neutrals carry a recognizable sliver of the brand, at 0 they are achromatic.

The seeds are registered custom properties with a typed <color> syntax. That buys two things: a malformed value falls back to the registered default instead of silently invalidating every derived token, and the properties are animatable — which is how a theme switch can cross-fade instead of snapping. If you do transition a seed, judge the result from a screen recording rather than a paused inspector: in one Chromium build (148), a border: 1px solid var(--…) shorthand painted currentColor for the length of the fade while the same token through a longhand border-color held, and every frame of a seed transition is a full-page style recalc. This site's own home page cuts between palettes instead.

What the seed derives

Each seed becomes a twelve-step ramp — --forte-accent-1 through --forte-accent-12, and the same for secondary and the gray ramp the neutrals come from. Components never read ramp steps directly; they read semantic aliases, which is what you should override too if you need finer control than the seed gives:

--forte-color-background   /* the page       — gray-1  */
--forte-color-panel        /* raised surface — gray-2  */
--forte-color-primary      /* solid fill     — accent-9 */
--forte-color-primary-soft /* tinted fill    — accent-3 */
--forte-color-primary-text /* accent text    — accent-11 */

The full list is on the Design tokens page. The status colors — danger, success, warning, info — are deliberately not derived from the seed: a brand color must not be able to make an error message look reassuring.

Text on your brand color

--forte-color-on-primary and --forte-color-on-secondary — the text on solid fills — pick white or black automatically. In browsers with contrast-color() the browser decides from the painted color; elsewhere a fitted OKLCH threshold decides, and it clears AA on its own across the entire supported envelope. If you want the decision to be exact in every browser, the Theme Studio measures it for your seed and includes the literal in its exported CSS:

:root {
  --forte-color-on-primary: oklch(0.995 0 0); /* measured for your seed */
}

Light and dark

Both modes are built from the same seed — dark mode is not a second theme, it is the same derivation against a dark base. By default the library follows the operating system via color-scheme. To let people choose, set data-theme on any element, typically <html>:

<html data-theme="dark">

"light" and "dark" force a mode for that subtree; removing the attribute returns to the OS preference. There is nothing color-related to write twice: a component, a page or a whole app themed by seed is automatically correct in both modes.

You do not have to drive the attribute yourself: ThemeToggle is a ready-made switch for it, useTheme is the hook underneath for building your own control, and ThemeScript replays a stored choice before first paint.

Scoped themes

Re-theming does not have to be global. Add the forte-theme class or a data-forte-theme attribute to any element and set seeds there — that subtree re-derives its entire palette while the rest of the page keeps yours:

theming/scoped.tsx

Page theme

Scoped theme

Scopes nest, and each level can override just the inputs it cares about. data-theme works on a scope too, so a permanently-dark sidebar inside a light app is one attribute.

Where to go next

The geometry switches — radius, density, motion — are attributes rather than colors, and they have their own page: Presets. For overriding individual variables beyond the seeds, start with Design tokens; for restyling one component, Styling components.