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
import { MeshGradient } from "@/components/mesh-gradient";
export function ThemeAwareMeshDemo() {
return (
<div className="[--hero-1:oklch(0.82_0.1_275)] [--hero-2:oklch(0.85_0.09_350)] [--hero-3:oklch(0.88_0.08_200)] [--hero-bg:oklch(0.97_0.01_275)] dark:[--hero-1:oklch(0.45_0.18_275)] dark:[--hero-2:oklch(0.42_0.16_350)] dark:[--hero-3:oklch(0.45_0.1_200)] dark:[--hero-bg:oklch(0.16_0.03_275)]">
<MeshGradient
className="flex h-[360px] items-end p-8"
colors={["var(--hero-1)", "var(--hero-2)", "var(--hero-3)"]}
background="var(--hero-bg)"
>
<h2 className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-white">
Switch the theme
</h2>
</MeshGradient>
</div>
);
}
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 oklchafter 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.jsonimport { 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@propertyas above, and switch themes by changing a class orstyleon<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.
Related
- Animated gradient background for shadcn/ui builds a hero from shadcn's theme tokens.
- Animated gradient backgrounds in Tailwind CSS covers CSS-only gradients and Tailwind's colour variables.
- Background video vs animated gradient explains why a video can't follow the theme.
- Pastel gradients in CSS has light-mode pastel palettes with hex codes, and what to swap them for in dark mode.
- Purple gradients in CSS has light and dark purple palettes with hex codes, and the text colours that stay readable on each.
- Gradient backgrounds for websites covers full-page gradients, text contrast across the stops and hero heights on phones.