How to make a gradient follow light and dark mode

Theme-aware gradients with CSS variables and light-dark(), and the catch for canvas and WebGL.

Updated

To make a gradient follow your site's theme, don't write its colours into the gradient. Put them in CSS variables, give the variables a second set of values for dark mode, and build the gradient from var(). The gradient itself never changes; the browser swaps the colours when the theme does:

:root {
  --hero-from: oklch(0.88 0.06 275);
  --hero-to: oklch(0.9 0.06 350);
}

.dark {
  --hero-from: oklch(0.32 0.14 275);
  --hero-to: oklch(0.3 0.12 350);
}

.hero {
  background: linear-gradient(
    135deg in oklch,
    var(--hero-from),
    var(--hero-to)
  );
}

The gradient below is a WebGL mesh that reads its colours from variables the same way. Switch the site between Light and Dark at the bottom of the page and it repaints with the other set.

Switch the theme

Follow the system setting

The .dark class above suits a site with a theme switcher: next-themes, shadcn/ui and most React setups put dark on <html>. To follow the visitor's operating system instead, change the variables in a media query:

@media (prefers-color-scheme: dark) {
  :root {
    --hero-from: oklch(0.32 0.14 275);
    --hero-to: oklch(0.3 0.12 350);
  }
}

Or write both values in one place with light-dark(). It returns its first colour in a light colour scheme and its second in a dark one, so it needs color-scheme set on the page:

:root {
  color-scheme: light dark;
}

.hero {
  background: linear-gradient(
    135deg in oklch,
    light-dark(oklch(0.88 0.06 275), oklch(0.32 0.14 275)),
    light-dark(oklch(0.9 0.06 350), oklch(0.3 0.12 350))
  );
}

color-scheme: light dark follows the system. A switcher can pin it by setting color-scheme: light or color-scheme: dark on <html>, and every light-dark() on the page follows. It works in Chrome and Edge 123, Firefox 120 and Safari 17.5 and later.

Pick dark colours on purpose

Inverting a light gradient rarely gives a good dark one. A few rules that hold up:

  • Write colours in oklch(). Its first number is lightness. Keep the hue and drop the lightness for dark mode, and the gradient stays recognisably the same.
  • Lower the lightness, not just the background. Pastel stops that look soft on white glow on black. Dark mode stops around 0.3 to 0.45 lightness sit back the way the light ones do.
  • Raise the chroma a little. Dark colours look greyer than light ones at the same chroma. The example goes from 0.06 in light mode to 0.12 and 0.14 in dark.
  • Interpolate in oklch. in oklch after the angle blends through the hue wheel instead of through grey, so a blue-to-pink gradient doesn't go muddy in the middle.
  • Check text in both modes. Headline contrast against the lightest and darkest part of the gradient, in each theme.

Animate the switch

A gradient can't transition between two backgrounds, so a theme switch snaps. Register the variables as colours with @property and the browser can animate them instead:

@property --hero-from {
  syntax: "<color>";
  inherits: true;
  initial-value: transparent;
}

@property --hero-to {
  syntax: "<color>";
  inherits: true;
  initial-value: transparent;
}

.hero {
  transition:
    --hero-from 400ms ease,
    --hero-to 400ms ease;
}

Now when .dark changes the variables, each one fades to its new value over 400ms and the gradient redraws on every frame. next-themes has a disableTransitionOnChange option that turns this off during a switch, so leave it unset if you want the fade.

Canvas and WebGL gradients

A shader can't read CSS. A canvas or WebGL gradient has to read the colours with JavaScript, turn them into numbers, and read them again whenever the theme changes. Two things catch people out.

First, getComputedStyle(el).getPropertyValue("--hero-from") returns whatever text you wrote in the variable. If that's a hex code or oklch(), a canvas can parse it. If it's light-dark(...), it can't: in our test in Chrome, a 2D canvas ignored the value and the gradients.sh engine fell back to grey. Register the variable with @property and syntax: "<color>" and the same call returns the resolved rgb() for the current scheme.

Second, nothing tells a canvas that the theme changed. Watch for it:

function readColors(el) {
  const style = getComputedStyle(el);
  return ["--hero-from", "--hero-to"].map((name) =>
    style.getPropertyValue(name).trim()
  );
}

const redraw = () => draw(readColors(canvas));

// a theme switcher changing a class or attribute on <html>
new MutationObserver(redraw).observe(document.documentElement, {
  attributes: true,
  attributeFilter: ["class", "style", "data-theme"],
});

// the operating system switching between light and dark
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", redraw);

Read the variables from the canvas element itself, not from <html>, so values set on a wrapper apply.

Theme-aware gradients in React

The free gradients.sh components do this for you. Every colour prop takes any CSS colour, including var(--token). Install one with the shadcn CLI:

pnpm dlx shadcn@latest add https://gradients.sh/r/mesh-gradient.json
import { MeshGradient } from "@/components/mesh-gradient";

<MeshGradient
  className="h-[480px]"
  colors={["var(--primary)", "var(--chart-2)", "var(--chart-4)"]}
  background="var(--background)"
>
  {children}
</MeshGradient>;

With shadcn/ui's theme tokens, the gradient follows the theme and any rebrand with no extra code. The components read the variables from their own canvas and draw again when the class or style attribute on <html> changes, so next-themes with attribute="class" works, including its system mode, which sets the class for you. Two cases need a small change:

  • A switcher that only sets data-theme. Have it toggle a class too.
  • Variables that use light-dark(). Register them with @property as above, and switch themes by changing a class or style on <html>.

Visitors who prefer reduced motion see one still frame, in the current theme's colours. Theming in the docs covers the props, and the Mesh Gradient docs list the rest. To find colours first, tune a gradient in the editor and copy them into your variables.