Generate a full UI palette from one base color

Starting from a single brand color, you often need to derive many more: hover states, borders, highlights, and alternatives for light and dark themes. CSS relative color syntax plus a few other Baseline features let you generate those variants programmatically instead of hand-picking every hex value.

Define the base color in oklch(). This cylindrical form of the Oklab color space has channels for lightness (L), chroma (C), hue (H), and an optional alpha value. OkLCh provides perceptual uniformity: adjusting one channel—say, hue—keeps the other perceptual qualities stable, which helps avoid contrast surprises.

html {
  --base-color: oklch(43.7% 0.075 224);
}

Derive color variants with relative color syntax

Relative color syntax lets you start from an origin color and adjust its channels in the output color function using the from keyword. For a triadic scheme, take the base color and shift its hue by 120 degrees in opposite directions:

html {
  /* ... */
  --triadic-color-primary: oklch(from var(--base-color) l c calc(h + 120));
  --triadic-color-secondary: oklch(from var(--base-color) l c calc(h - 120));
}

Keeping lightness and chroma constant but changing the hue yields a dark pink accent and a gold highlight at the same perceived weight as the original teal.

html {
  /* ... */
  --accent-color: var(--triadic-color-primary);
  --highlight-color: var(--triadic-color-secondary);
}
  html {
    /* Input color in the rgb color space*/
    --base-color: teal;

     /* Output color in oklch. Computes to oklch(0.543123 0.0927099 314.769) */
     --triadic-color-primary: oklch(from var(--base-color) l c calc(h + 120));
  }

A complementary color is a 180-degree hue rotation away:

html {
  /* ... */
  --complement-color: oklch(from var(--base-color) l c calc(h + 180));
  --border-highlight: var(--complement-color);
}

For interactive states, manipulate the lightness or alpha channels. For example, a lighter hover color increases the lightness channel, while an active state might add transparency or lower lightness. The syntax allows referencing unchanged channels by their letter (l, c, h) or using calc() for adjustments:

html {
  /* Darken the --base-color by 15% */
  --base-color-darkened: oklch(from var(--base-color) calc(l * 0.85) c h);
  /* Assign this color a meaningful variable name */
  --action-color: var(--base-color-darkened);
  /* Lighten the --action-color by 15% */
  --action-color-light: oklch(from var(--action-color) calc(l * 1.15) c h);
  /* Darken the --action-color by 10% */
  --action-color-dark: oklch(from var(--action-color) calc(l * 0.9) c h);
}

Because these variants are defined relative to a custom property like --action-color, updating that single variable automatically updates all derived states, even if you later reroute it to a different base color.

Create support colors with color-mix()

Rather than adjusting individual channels, color-mix() interpolates two colors. A border color might blend the base color with grey in the oklab color space for perceptually uniform results:

html {
  --base-mix-grey-50: color-mix(in oklab, var(--base-color), grey);
  --border-color: var(--base-mix-grey-50);
}

By default the mix is 50/50, but you can weight either side. For focus states on form inputs, one approach is to raise the chroma of the base color relative to the original rather than introducing a new color:

html {
  --background-mix-base-80: color-mix(in oklab,
    var(--background-color) 80%,
    var(--base-color));
  --surface-light: var(--background-mix-base-80);
}
[data-input*="text"] {
  --focus-ring: transparent;
  /* ... */
  &:focus {
    --focus-ring: oklch(from var(--border-color) l calc(c + 0.1) h);
  }
}

Enable automatic light and dark rendering

Declare support with the color-scheme property

Set color-scheme: light dark on :root or the html element. This signals that the page supports both modes and makes the browser UI—scrollbars, form controls, and similar—render according to the operating system setting:

 html {
   color-scheme: light dark;
}

You can also announce this support early in the document's head with a meta tag:

<head>
  <!-- ... -->
   <meta name="color-scheme" content="light dark">
</head>

Set mode-specific colors without media queries

A prefers-color-scheme media query updates author-controlled styles, but still leaves browser UI colors unless the color-scheme property is also set. It also duplicates color variables for each mode.

@media (prefers-color-scheme: light) {
  html {
    --background-color: oklch(95.5% 0 162);
    --text-color: black;
  }
}

@media (prefers-color-scheme: dark) {
  html {
    --background-color: oklch(22.635% 0.01351 291.83);
    --text-color: white;
  }
}

With color-scheme in place, light-dark() accepts exactly two colors—the light value and the dark value—and picks the right one based on the active scheme, all in a single declaration:

html {
  color-scheme: light dark;
  /* Color custom property values for both light and dark modes */
  --base-color: light-dark(oklch(43.7% 0.075 224), oklch(89.2% 0.069 224));
  --background-color: light-dark(oklch(95.5% 0 162), oklch(22.635% 0.01351 291.83));
  --accent-color: oklch(from var(--base-color) l c calc(h + 120));
  --active-color: light-dark(var(--action-color-light), var(--action-color-dark));
  /* ... */
}

You can place these definitions in custom properties at a global or per-component level and then use them anywhere:

/* custom property usage */
body {
  background-color: var(--background-color);
  /* ... */
}

:any-link {
  /* ... */
  text-decoration-color: var(--accent-color);
}

Let users pick a theme manually

Automatic mode detection is convenient, but many sites also offer an in-page switcher. A toggle that updates a data-scheme attribute on the html element lets you drive the color-scheme property directly from CSS:

html {
  color-scheme: light dark;

  &[data-scheme="light"] {
    color-scheme: light;
  }

  &[data-scheme="dark"] {
    color-scheme: dark;
  }

  &[data-scheme="green"] {
      --base-color-light: oklch(48.052% 0.11875 151.945);
      --base-color-dark: oklch(92.124% 0.13356 151.558);
      color-scheme: light dark;
   }
}

Notice that the data-scheme="green" value not only forces light or dark but can also swap --base-color to another hue. Because the whole palette derives from that custom property, switching the base color gives you an entirely new theme without touching any other variable.

Register custom properties with @property

Standard custom properties have no type information. Registering a property with the @property rule enables type checking and provides a fallback. For a value as central as --base-color, that safety is useful:

@property --base-color-light {
  syntax: '<color>';
  inherits: false;
  initial-value: oklch(43.7% 0.075 224);
}

@property --base-color-dark {
  syntax: '<color>';
  inherits: false;
  initial-value: oklch(89.2% 0.069 224);
}

html {
  --base-color: light-dark(var(--base-color-light), var(--base-color-dark));
}

If --base-color is ever assigned something that is not a color, it reverts to the declared initial-value. Registration also enables smooth transitions for properties that need interpolation. Consider a heading with a gradient background that shows through transparent text using background-clip:

.main-heading {
  background: linear-gradient(in oklch 90deg, var(--text-color) 50%, oklch(from var(--base-color) l c var(--header-hue)));
  background-clip: text;
  color: transparent;
  animation: header-hue-switch 5s ease-in-out infinite alternate;
}

Part of that gradient relies on a hue that animates across a numeric range:

@keyframes header-hue-switch {
  from {
    --header-hue: 26.67;
  }

  to {
    --header-hue: 277;
  }
}

Registering --header-hue as a number means the browser can interpolate it gradually. Without a registered type, the transition between two values of that custom property would be a discrete jump between states.

@property --header-hue {
  syntax: '<number>';
  inherits: false;
  initial-value: 100;
}

Working with the --base-color in practice

This approach makes a theme flexible and maintainable. If branding adjusts, update the single --base-color custom property and all derived colors—triadic, complementary, states, borders, and mixes—follow automatically. The same mechanism allows for future features like changing the base palette dynamically to match the dominant color of a currently playing song or a user-selected theme.

The technique of building a palette relative to a few tokens does not eliminate design effort, but it handles the plumbing. The theme switcher logic in the demo was adapted from Adam Argyle's Theme switch component.