React Component Theming: How Plane Implements Dark/Light Modes with CSS Variables and Tailwind
Plane uses a CSS-variable architecture combined with a custom Tailwind variant to enable automatic dark/light mode switching without duplicating component styles.
The open-source project management platform Plane (makeplane/plane) implements a robust theming system that allows React components to seamlessly adapt between dark and light modes. By combining CSS custom properties with Tailwind's variant system, the codebase eliminates hard-coded color values and enables theme switching through data attributes. This article explains how Plane's React component theming with CSS variables and Tailwind creates a maintainable, token-based design system.
The Foundation: CSS Variables and Custom Tailwind Variants
Plane's theming architecture centers on a single configuration file that bridges CSS variables and Tailwind's utility system. In packages/tailwind-config/variables.css, the team defines not only the color tokens but also the mechanism for triggering dark mode styles.
Defining the Dark Variant
The file introduces a custom Tailwind variant that instructs the compiler when to emit dark-mode styles:
@custom-variant dark (&:where([data-theme*="dark"], [data-theme*="dark"] *));
This directive tells Tailwind that any class prefixed with dark: should only apply when an element—or its ancestor—carries a data-theme="dark" attribute. This approach provides more explicit control than Tailwind's default dark mode strategies, allowing Plane to support multiple theme variants including high-contrast modes through the same mechanism.
Token-Based Color System
All visual properties in Plane are expressed as semantic CSS variables rather than static color values. The variables.css file defines tokens such as --background-color-canvas, --border-color-subtle, and --text-color-primary within separate @variant dark blocks. When the data-theme attribute changes, these variables update automatically, cascading new color values to every component reference without requiring JavaScript re-renders or class swaps.
Theme Management with next-themes
Plane leverages the next-themes library to handle theme state persistence and application, creating a seamless bridge between user preferences and the CSS variable system.
The ThemeSwitch Component
The UI for theme selection lives in apps/web/core/components/core/theme/theme-switch.tsx. This component renders a dropdown that allows users to select between light, dark, and system preferences:
export function ThemeSwitch(props: Props) {
const { value, onChange } = props;
const { t } = useTranslation();
return (
<CustomSelect
value={value}
label={value ? <.../> : t("select_your_theme")}
onChange={onChange}
buttonClassName="border border-subtle-1"
placement="bottom-end"
input
>
{THEME_OPTIONS.map(themeOption => (
<CustomSelect.Option key={themeOption.value} value={themeOption}>
<div className="flex items-center gap-2">…{t(themeOption.key)}</div>
</CustomSelect.Option>
))}
</CustomSelect>
);
}
Runtime Theme Application
When a user selects a theme, next-themes updates the <html> element's data-theme attribute to either "light" or "dark". This single DOM change triggers the CSS custom variant system, causing Tailwind to activate the appropriate @variant dark styles throughout the entire application. The theme preference is automatically persisted to localStorage and hydrated on subsequent page loads.
Component Implementation Patterns
React components in Plane never reference raw color values. Instead, they use semantic Tailwind utilities that map directly to the CSS variables defined in the configuration.
Using Semantic Utility Classes
For example, in packages/propel/src/toast/toast.tsx, components apply theme-aware classes that resolve to CSS variables:
<div data-theme={theme} className="inline-block">
{/* Toast content using bg-canvas, text-primary, etc. */}
</div>
The data-theme attribute on this container ensures the correct variable values are applied, while classes like bg-canvas resolve to background-color: var(--background-color-canvas);. This abstraction means components remain agnostic about which specific theme is active.
Practical Implementation Examples
Creating a Theme-Aware Button
Components use semantic utility classes that automatically adapt to the active theme:
export function MyButton() {
return (
<button className="bg-canvas text-primary border-subtle-1 hover:bg-layer-2">
Click me
</button>
);
}
bg-canvasmaps tovar(--background-color-canvas)text-primarymaps tovar(--text-color-primary)border-subtle-1maps tovar(--border-color-subtle-1)
When data-theme="dark" is present on the HTML element, these variables reference the dark-mode values defined in the @variant dark block of variables.css.
Building a Theme Toggle
Implementing a toggle button requires only the next-themes hook:
import { useTheme } from "next-themes";
export function ThemeToggle() {
const { theme, setTheme } = useTheme(); // theme = "light" | "dark" | "system"
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Toggle to {theme === "dark" ? "light" : "dark"}
</button>
);
}
Creating Reusable Layout Components
Higher-level components leverage the same token system for consistent styling:
export function Card({ children }: { children: ReactNode }) {
return (
<div className="rounded-md bg-surface-1 border border-subtle-1 p-4 shadow-raised-100">
{children}
</div>
);
}
This card component uses bg-surface-1 and border-subtle-1, which automatically adjust their actual color values based on the current theme without additional logic.
Summary
- CSS variables defined in
packages/tailwind-config/variables.cssprovide the foundational color tokens for both light and dark modes. - Custom Tailwind variant
@custom-variant darkenables thedark:prefix to respond specifically to[data-theme*="dark"]attributes. - next-themes manages theme state, persists user preferences to localStorage, and applies the
data-themeattribute to the HTML element. - Semantic utility classes like
bg-canvasandtext-primaryallow components to remain theme-agnostic while automatically adapting to color changes. - Zero component duplication is required for theming—adding new themes only requires adding new variable definitions in the CSS configuration.
Frequently Asked Questions
How does Plane detect dark mode without using Tailwind's default darkMode: 'class' strategy?
Plane defines a custom variant in packages/tailwind-config/variables.css using @custom-variant dark that specifically targets [data-theme*="dark"] attributes. This provides more explicit control than Tailwind's default class-based approach and allows the system to support multiple theme variants (including high-contrast) through the same mechanism.
Can I add a custom theme (like high-contrast) without modifying React components?
Yes. Because components reference semantic tokens (such as bg-canvas and text-primary) rather than specific color values, you can add new themes by only modifying packages/tailwind-config/variables.css. Adding a new @variant block with different CSS variable values immediately applies across the entire UI without touching any React code.
Why does Plane use CSS variables instead of Tailwind's default color palette?
CSS variables enable runtime theme switching without recompiling Tailwind classes or shipping multiple CSS bundles. By changing a single data-theme attribute on the HTML element, the entire application updates instantly. This approach also supports dynamictheme adjustments and system-level preferences that static Tailwind classes cannot provide.
How is the theme preference persisted across page reloads?
The ThemeSwitch component integrates with next-themes, which automatically stores the selected theme in localStorage and hydrates the data-theme attribute during the initial render. This ensures the user's preference remains active across sessions without requiring additional state management code in individual components.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →