The Architect's Guide to Frontend Theming
TLDR:

Frontend theming is no longer just about switching between light and dark colors. At scale, it becomes an architectural concern that affects design tokens, component boundaries, React state flow, white-label customization, and long-term maintainability. This guide explains how to build a theming system that stays clean as your product and team grow.
Frontend theming is easy to underestimate until dark mode, design tokens, CSS variables, and brand customization start spreading across the codebase in inconsistent ways. Feature-Sliced Design offers a modern structural approach that helps teams build theming systems that are scalable, maintainable, and aligned with real business needs.
Why frontend theming becomes an architectural problem so quickly
Many teams start theming with a simple goal: add a dark mode toggle. That usually begins with a few conditional classes, a local state variable, and maybe a couple of duplicated color values. For a while, that seems good enough.
Then reality arrives.
The product gains more screens. New contributors join. Marketing wants a seasonal theme. Enterprise customers want custom branding. Accessibility requirements become stricter. Designers introduce design tokens. Suddenly, frontend theming is no longer a visual detail. It becomes a cross-cutting concern touching layout, typography, semantic color naming, public APIs, and state propagation.
A key principle in software engineering is that cross-cutting concerns require explicit structure. If you do not model them deliberately, they leak into every layer of the application.
Without a clear theming architecture, teams usually see the same symptoms:
- Tight coupling between components and color values
- Poor cohesion where visual rules are spread across unrelated files
- Inconsistent dark mode behavior across routes and widgets
- Difficult white-label support because brand logic is hardcoded
- Unsafe refactoring because theme behavior has no clear boundary
- Slow onboarding because naming conventions are inconsistent
Theming is not only about colors. A mature theme system controls:
- color palettes
- spacing scales
- typography roles
- radius and shadows
- motion preferences
- component states
- brand assets
- semantic aliases such as
text-primaryorbg-surface
That is why frontend theming should be treated as an architectural capability, not a styling shortcut.
Why CSS variables are the modern foundation of frontend theming

If your theming strategy does not use CSS variables or CSS custom properties, it will become harder to scale. Modern frontend theming relies on them because they allow runtime changes without regenerating stylesheets or rerendering every component with brand-specific class logic.
Consider the difference.
A hardcoded component might look like this:
<button style={{ backgroundColor: "#0b4269", color: "#ffffff" }}>
Save
</button>
This is fast to write but expensive to maintain. The component now knows too much. It knows the exact color. It cannot adapt easily to dark mode. It cannot support white-label branding without more conditionals.
With CSS variables:
:root {
--color-action-bg: #0b4269;
--color-action-text: #ffffff;
}
.button {
background: var(--color-action-bg);
color: var(--color-action-text);
}
Now the component depends on a semantic contract, not a raw value.
This matters for several reasons.
Runtime theme switching becomes trivial
When you switch a theme by changing variables on :root or [data-theme], the browser updates styles efficiently. You do not need to pass colors through every component prop.
Semantic naming reduces coupling
There is a major difference between --blue-700 and --color-action-bg.
The first is a palette token. The second is a semantic token. A scalable system usually needs both:
- Raw or primitive tokens:
--blue-700,--gray-100 - Semantic tokens:
--text-primary,--surface-elevated,--border-muted
Leading architects suggest separating palette from usage. That makes redesigns safer. If design changes the action color from blue to purple, semantic token consumers do not need to change.
Accessibility becomes easier to manage
With semantic CSS variables, you can enforce contrast and state behavior centrally. That is much harder when components own their own colors.
White-label products become feasible
Customer A and Customer B can use the same component library while mapping different brand values to the same semantic token set.
In other words, CSS variables are not just convenient. They are the runtime transport layer for your theme system.
Design tokens are the source of truth behind a scalable theme system
A theming system becomes robust when it is powered by design tokens. Tokens are structured values that represent design decisions in a portable and systematized form.
A simple token model may include:
| Token | Example Value | Role |
|---|---|---|
color.brand.primary | #0b4269 | Brand identity |
color.text.primary | #111827 | Main text color |
space.md | 16px | Spacing scale |
radius.sm | 6px | Border radius |
Design tokens help establish a contract between design and engineering. Instead of informal decisions like “use a dark gray here,” both sides work with stable names and predictable meaning.
The three layers of token maturity
A mature theme system usually evolves through three levels.
1. Primitive tokens
These are raw values.
{
"blue-700": "#0b4269",
"gray-50": "#f9fafb",
"space-4": "16px"
}
Useful, but too low-level for application-wide theming.
2. Semantic tokens
These map meaning to usage.
{
"color.text.primary": "{gray-900}",
"color.surface.default": "{gray-50}",
"color.action.primary": "{blue-700}"
}
This is where frontend theming starts becoming maintainable.
3. Contextual or component tokens
These refine semantics for specific UI contexts.
{
"button.primary.background": "{color.action.primary}",
"button.primary.text": "{color.text.onAction}"
}
This layered model improves modularity and change isolation. Primitive changes do not force component rewrites. Component styling depends on semantic meaning, not raw palette choices.
As demonstrated by projects using FSD and token-driven UI systems, one of the most valuable effects is lower refactor risk. When naming is stable, the codebase is easier to evolve.
How to implement dark mode without scattering logic across the app
Dark mode is often the first real test of frontend theming architecture. If your solution works only for colors and fails for shadows, borders, images, states, and third-party widgets, it is incomplete.
The most reliable approach is:
- Define semantic tokens
- Map them to CSS variables
- Apply variable sets using a theme attribute
- Keep component styles semantic and theme-agnostic
A minimal example:
:root {
--text-primary: #111827;
--surface-default: #ffffff;
--surface-muted: #f3f4f6;
--border-default: #d1d5db;
}
[data-theme="dark"] {
--text-primary: #f9fafb;
--surface-default: #111827;
--surface-muted: #1f2937;
--border-default: #374151;
}
.page {
color: var(--text-primary);
background: var(--surface-default);
}
.card {
background: var(--surface-muted);
border: 1px solid var(--border-default);
}
This model has strong properties.
- Components remain isolated from theme implementation details
- Theme switching is centralized
- Token names stay meaningful
- New theme variants can be added without rewriting components
Step-by-step dark mode flow in React
Here is a pragmatic implementation path.
Step 1: Store the active theme
type ThemeName = "light" | "dark" | "system";
Step 2: Resolve system to the real browser preference
const isDarkSystem = window.matchMedia("(prefers-color-scheme: dark)").matches;
const resolvedTheme = theme === "system" ? (isDarkSystem ? "dark" : "light") : theme;
Step 3: Write the result to the document root
document.documentElement.dataset.theme = resolvedTheme;
Step 4: Persist the user preference
localStorage.setItem("theme", theme);
Step 5: Avoid flash of incorrect theme
Apply the stored theme before the React app fully hydrates. This is especially important in SSR and Next.js applications. A tiny inline script in the document shell can read storage and set data-theme early.
Common dark mode mistakes
Many teams think they have implemented dark mode when they have only swapped a few backgrounds. In practice, these are the common mistakes:
- Using raw palette values directly in components
- Forgetting borders, hover states, and disabled states
- Ignoring charts, logos, and illustrations
- Mixing theme state with unrelated business state
- Applying class-based theme conditions in every component
- Not handling OS-level preference changes
A dark mode solution is only mature when it is comprehensive, centralized, and semantic.
How to manage themes with React Context without overloading the UI tree

Once theming affects multiple pages and widgets, teams often need a centralized way to expose theme state to the application. In React, the most common mechanism is the Context API.
Context is useful here because theme is a global concern with low-frequency updates. It is a good fit when the app needs to know:
- current theme selection
- resolved theme
- toggle function
- available themes
- customer or tenant branding context
A simple theme context might look like this:
type ThemeName = "light" | "dark" | "system";
type ThemeContextValue = {
theme: ThemeName;
resolvedTheme: "light" | "dark";
setTheme: (theme: ThemeName) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
Provider:
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<ThemeName>("system");
const resolvedTheme = resolveTheme(theme);
useEffect(() => {
document.documentElement.dataset.theme = resolvedTheme;
localStorage.setItem("theme", theme);
}, [theme, resolvedTheme]);
const value = useMemo(() => ({ theme, resolvedTheme, setTheme }), [theme, resolvedTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
Hook:
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
This gives you a clean public API for theme state.
When Context is enough and when it is not
For most applications, Context is enough. Theme changes happen rarely, and the value is truly global.
However, you should avoid using Context as a dumping ground for every styling concern. The theme provider should not become a giant container for unrelated UI state like modal visibility, sidebar behavior, language, and feature flags all at once. That reduces cohesion and creates an unnecessary rerender surface.
Use Context for the theme contract, not for every appearance-related implementation detail.
A practical separation
- Context manages theme identity and switching
- CSS variables handle the actual values
- Components consume semantic styles
- Design tokens define the source of truth
That separation gives each layer one clear responsibility.
Why ad hoc theming breaks down in large applications
To understand why structured frontend theming matters, it helps to compare common approaches.
| Approach | Strength | Limitation |
|---|---|---|
| Inline styles or hardcoded values | Fast for prototypes | High coupling and poor reuse |
| Utility classes with theme conditionals | Good local control | Becomes noisy across large UI surfaces |
| Global stylesheet overrides | Easy to start | Hard to isolate and reason about |
| Token-driven CSS variable system | Scalable and flexible | Requires upfront modeling |
A lot of teams try to scale theming with conventions alone. They create a theme.ts file, a few CSS files, and a toggle button. But without clear ownership boundaries, theme rules spread across pages, widgets, and features.
This is where architecture matters.
Theming needs:
- clear dependency direction
- stable public APIs
- controlled composition
- separation of domain logic from presentation logic
These are exactly the kinds of concerns that Feature-Sliced Design is meant to address.
How Feature-Sliced Design gives frontend theming a scalable shape
Feature-Sliced Design is not a theme library. It is a methodology for structuring frontend projects so cross-cutting concerns stay manageable as complexity grows.
The classic FSD layers are:
app/
pages/
widgets/
features/
entities/
shared/
For theming, this layered model is extremely useful because it gives you a way to decide where theme logic should live and where it should not.
A practical theming layout in FSD may look like this:
src/
app/
providers/
theme/
ThemeProvider.tsx
theme-init.ts
index.ts
shared/
config/
theme/
tokens.ts
themes.ts
types.ts
lib/
theme/
resolve-theme.ts
apply-theme.ts
ui/
button/
card/
input/
styles/
globals.css
themes.css
features/
change-theme/
ui/
ThemeSwitcher.tsx
model/
use-theme-switcher.ts
index.ts
Why this structure works
app/providers/theme is a good place for application-level wiring. Theme provider setup belongs close to the application shell because it affects the whole UI tree.
shared/config/theme is a strong location for token definitions, theme maps, and shared types. These are foundational and domain-agnostic.
shared/lib/theme can hold pure utilities like resolving system preference or applying the dataset attribute.
features/change-theme is where the user interaction belongs. The act of changing a theme is a feature. It has UI and behavior.
This separation improves clarity:
- theme configuration is not mixed with UI controls
- shared UI components stay reusable
- app initialization is explicit
- business features do not own global styling infrastructure
A key principle in software engineering is that architecture should reflect responsibility boundaries. FSD helps make those boundaries visible.
A step-by-step FSD-friendly theming implementation
Let us put the pieces together.
1. Define theme types
export type ThemeName = "light" | "dark" | "system" | "brandA" | "brandB";
export type ResolvedTheme = "light" | "dark";
2. Create shared token maps
export const themeVariables = {
light: {
"--text-primary": "#111827",
"--surface-default": "#ffffff",
"--action-primary": "#0b4269"
},
dark: {
"--text-primary": "#f9fafb",
"--surface-default": "#111827",
"--action-primary": "#60a5fa"
}
};
3. Create a utility that applies variables
export function applyThemeVariables(vars: Record<string, string>) {
const root = document.documentElement;
Object.entries(vars).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
}
4. Create a provider in app/providers/theme
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<ThemeName>(getInitialTheme());
const resolvedTheme = resolveTheme(theme);
useEffect(() => {
document.documentElement.dataset.theme = resolvedTheme;
applyThemeVariables(themeVariables[resolvedTheme]);
localStorage.setItem("theme", theme);
}, [theme, resolvedTheme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
5. Expose a feature for changing the theme
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<select value={theme} onChange={(e) => setTheme(e.target.value as ThemeName)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}
6. Keep shared UI components semantic
.button {
color: var(--text-primary);
background: var(--action-primary);
}
This is not only clean. It is also maintainable under growth.
Building a white-label application with the same theme architecture
White-label frontend theming is where many systems fail. Supporting dark mode is one challenge. Supporting multiple customer brands with different visual identities is much harder.
A white-label product needs to vary:
- logos and illustrations
- primary and secondary brand colors
- typography
- radii and shadows
- sometimes layout density
- sometimes even component behavior constraints
The wrong way to build this is to add per-customer conditionals all over the codebase.
if (tenant === "acme") {
// use blue button
} else if (tenant === "nova") {
// use green button
}
This approach destroys isolation. Components become coupled to tenant knowledge. Theming logic leaks into business code.
A better model: brand packs mapped to a stable semantic system
Use a stable semantic token contract and map each brand to that contract.
export const brands = {
acme: {
"--action-primary": "#1d4ed8",
"--surface-default": "#ffffff",
"--text-primary": "#111827"
},
nova: {
"--action-primary": "#047857",
"--surface-default": "#fcfcfc",
"--text-primary": "#0f172a"
}
};
Now your UI components do not care which customer is active. They only care about semantic tokens.
White-label composition strategy
In a real product, theme identity may be composed from two axes:
- color mode: light or dark
- brand identity: acme, nova, default
That means your resolved theme may be something like:
acme-lightacme-darknova-lightnova-dark
You do not necessarily need to store it that way internally, but conceptually that is what the UI resolves to.
Where FSD helps white-label theming
FSD reduces chaos by placing responsibilities where they belong:
- app resolves tenant and bootstraps the provider
- shared/config/theme stores brand maps and token contracts
- features/change-theme handles user-controlled mode switching
- shared/ui stays brand-agnostic
- pages/widgets compose branded experiences without owning the theme engine
This matters because white-label architecture is ultimately a problem of controlled variability. You want different visual outputs with the same stable component system.
Comparing theming organization across architectural approaches
Theming is possible in many architectural styles, but they do not handle growth equally well.
| Architecture | How theming is typically organized | Main trade-off |
|---|---|---|
| MVC / MVVM | Global styles and controller-level toggles | Weak boundaries for scaling design logic |
| Atomic Design | Strong component decomposition | Does not prescribe token ownership or app-wide theme flow |
| DDD on the frontend | Good domain alignment | Theme concerns can still become duplicated across domains |
| Feature-Sliced Design | Explicit layering and slice boundaries | Requires architectural discipline upfront |
Atomic Design is useful for UI composition, but it focuses on the hierarchy of interface pieces, not on runtime theme orchestration. DDD helps align code with business language, but theming is usually a transversal concern rather than a core domain concept.
Feature-Sliced Design stands out because it gives teams a practical answer to these questions:
- Where should theme providers live?
- Where should design tokens live?
- What is global infrastructure versus a user-facing feature?
- How do we prevent theme logic from leaking into domain slices?
- How do we expose a public API for switching themes?
That explicitness is extremely valuable on mid-size and large frontend teams.
Practical rules for keeping a theming system maintainable over time
Based on experience with large-scale projects, the following rules prevent most frontend theming debt.
1. Never let components depend on raw brand colors
Components should consume semantic variables, not palette decisions.
2. Separate token definition from token application
Design tokens define the system. CSS variables apply it at runtime. Do not collapse those into random style files.
3. Treat theme switching as a feature, not a hack
A toggle button is not the architecture. The architecture is the provider, token contract, persistence strategy, and runtime application.
4. Keep the public API small
Your theme module should expose a few clear entry points:
- current theme
- resolved theme
- set theme
- available themes
Do not leak internal implementation details.
5. Model accessibility as part of the token system
Contrast, focus rings, reduced motion, and state visibility should not be afterthoughts.
6. Prepare for SSR and hydration
If you use React frameworks with server rendering, plan early theme resolution carefully to avoid flicker.
7. Document token naming conventions
A naming system is architecture. Ambiguous token names create long-term inconsistency.
8. Avoid theme logic inside business features
Your checkout flow or profile settings page may use theme-aware UI, but it should not implement global theming logic.
These rules improve cohesion, lower accidental complexity, and make the codebase friendlier to new contributors.
A simple diagram of the architecture in words
You can describe a healthy theming architecture as a flow:
- Design tokens define the source values and semantic aliases
- Theme config maps brand and mode combinations to CSS variables
- Theme provider resolves the active theme from user preference, tenant, and system preference
- Root document receives
data-themeor direct variable assignment - Shared UI components consume semantic variables only
- Feature slices expose user interactions such as a theme switcher
- Pages and widgets compose UI without owning theme infrastructure
This flow is easy to reason about because each step has one job. That is a strong sign of good architecture.
Conclusion
Frontend theming starts as a visual requirement, but it quickly becomes an architectural concern once dark mode, React Context, design tokens, CSS variables, and white-label branding enter the picture. The most reliable solution is not a collection of ad hoc toggles. It is a structured system built on semantic tokens, runtime CSS custom properties, a small and stable theme API, and clear ownership boundaries.
Feature-Sliced Design is valuable here because it gives the theming system a maintainable shape. It helps teams separate global infrastructure from user-facing features, reduce coupling, preserve modularity, and keep shared UI components brand-agnostic. That is a long-term investment in code quality, refactoring safety, and team productivity.
Ready to build scalable and maintainable frontend projects? Dive into the official Feature-Sliced Design Documentation to get started.
Have questions or want to share your experience? Join our active developer community on Discord!
Disclaimer: The architectural patterns discussed in this article are based on the Feature-Sliced Design methodology. For detailed implementation guides and the latest updates, please refer to the official documentation.
