Runtime Theming in Carbon v11: Working With CSS Custom Properties
Carbon v11 themes are CSS custom properties under the hood. Use the supported Theme component for light/dark switching, override --cds-* tokens sparingly for branding, and verify in DevTools.
02 Feb 2026, 17:26 UTC

The problem: theme changes without a rebuild
You want users to flip between light and dark mode — or apply a brand palette — without shipping multiple compiled stylesheets. Since Carbon v11, the design system exposes its tokens as CSS custom properties such as --cds-background, --cds-text-primary, and --cds-interactive. Because components reference these variables, overriding them at runtime restyles everything that inherits from that point in the DOM. That is the mechanism this post builds on.
One caveat up front: Carbon's exact token names and theming APIs have changed between major versions. Verify the variable names against your installed version (search node_modules/@carbon/styles or inspect computed styles in DevTools) before relying on any name used here.
Two legitimate ways to theme
1. Carbon's built-in themes. Carbon ships four themes — white, g10, g90, and g100 — roughly light-to-dark. In React you scope one with the Theme component:
import { Theme, Button } from '@carbon/react';
function App() {
const [dark, setDark] = useState(false);
return (
<Theme theme={dark ? 'g100' : 'white'}>
<Button onClick={() => setDark(!dark)}>Toggle</Button>
</Theme>
);
}
Theme renders a wrapper element carrying the theme, and Carbon's styles set the custom properties on that scope. Everything nested inside inherits the values. This is the supported path and should be your default.
2. Overriding variables yourself. When the four built-in themes aren't enough — say, a brand accent color — you can override individual custom properties in your own CSS, scoped to a class or inline style:
.brand-scope {
--cds-interactive: #7c3aed;
--cds-link-primary: #7c3aed;
}
Apply brand-scope to a container (or combine it with a Theme wrapper) and Carbon components inside pick up the override. Keep overrides minimal: you are stepping outside the tested theme palettes, so contrast and accessibility become your responsibility.
Worked example: a persisted light/dark toggle
A common requirement is remembering the user's choice. Combine Theme with localStorage:
import { useEffect, useState } from 'react';
import { Theme, Button, TextInput } from '@carbon/react';
const KEY = 'app-theme';
export default function App() {
const [theme, setTheme] = useState(
() => localStorage.getItem(KEY) || 'white'
);
useEffect(() => { localStorage.setItem(KEY, theme); }, [theme]);
return (
<Theme theme={theme}>
<Button onClick={() =>
setTheme(t => (t === 'white' ? 'g100' : 'white'))}>
Switch to {theme === 'white' ? 'dark' : 'light'}
</Button>
<TextInput id="demo" labelText="Demo input" />
</Theme>
);
}
Run this in a standard React app with @carbon/react installed and its styles imported (import '@carbon/styles/css/styles.css'; or the Sass entry point, depending on your setup). Clicking the button should restyle the button, input, and background immediately.
How to verify it actually worked
- Open DevTools, select the element rendered by
Theme, and check the computed value of--cds-background— it should change between the light and dark values when you toggle. - Confirm no hardcoded hex colors in your own CSS are fighting the theme; components that stay put usually mean a local style overrides the variable.
- Check color contrast in both modes with DevTools' accessibility panel, especially if you overrode any tokens.
Trade-offs and limitations
- Legacy browsers. CSS custom properties are unsupported in IE11. Carbon v11 itself targets modern browsers, so if you must serve legacy clients, this theming approach — and much of v11 — won't degrade gracefully. A ponyfill such as
css-vars-ponyfillcan help in some setups, but test it; don't assume full coverage. - Scope leakage and isolation. Variables inherit down the DOM. Nested
Themewrappers work, but content rendered in portals may escape your themed subtree — verify where portal containers mount. - Custom overrides are untested territory. Carbon validates its four themes for contrast; your overrides are not validated. Audit them.
- Frequent changes cost rendering work. Toggling a theme restyles the subtree. That's fine for a user action; don't animate or rapidly mutate many variables.
Actionable closing
Start with the built-in Theme component and the four stock themes — that covers most light/dark requirements with zero custom CSS. Only override individual --cds-* properties when you have a genuine brand need, and scope those overrides narrowly. Then verify in DevTools that the computed variables change as expected, and run a contrast check in both modes before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.