:root { --color-bg: #fff; --color-text: #111; }
[data-theme="dark"] { --color-bg: #111; --color-text: #eee; }
[data-brand="acme"] { --color-accent: #e4572e; }
.card { background: var(--color-bg); color: var(--color-text); }
Make components use semantic CSS variables, and make each theme a different set of values for those variables. CSS custom properties (CSS variables) fit this well.
Components reference variables such as --color-bg and --color-text, never raw colours. A theme sets those values under an attribute on html or on a subtree. To switch theme, you change one attribute. The browser recomputes the styles without React re-rendering anything.
CSS variables fit because:
- They cascade, so one section can use a different theme by scoping the attribute.
- They work at runtime.
- They need no JavaScript to read.
- They work with server rendering.
CSS-in-JS theme providers that pass values through context are worse here. They re-render consumers, and they are harder to use with streaming SSR.
Avoid a flash of the wrong theme on load. There are two ways:
- Read the saved preference or
prefers-color-schemein a tiny inline script, before the first paint. - Or set the attribute on the server, from a cookie.
Dark themes need their own tuned semantic values, not inverted colours. Each theme must pass contrast checks.
- Components use semantic variables only, never raw colours.
- A theme is a set of variable values under an attribute.
- Switching theme needs no re-render, and a subtree can have its own theme.
- Avoid a flash of the wrong theme with an inline script or a server cookie.
- Check the contrast of each theme.