When Every Page Clashed — Building a Theme System
Colors that refused to stay in sync across header buttons, nav menu, and modals. Fixing it with CSS variable overrides and a central theme mapping — a casual write-up of how the palette system actually came together.
Starting Point — Something Felt Off
A while after deploying the blog I opened the Starship Mission page. The top buttons were cyan-themed, but the moment I opened the hamburger menu, it was flooded with purple again. Something felt subtly off.
At first I thought "okay, just match the nav menu color too". But as I dug in, the story turned out to be much deeper.
Understanding the Setup
The SystemButtons component already had a simple theme switch. Pass theme: 'starship' and it shifted to cyan. The catch: that switch only lived inside SystemButtons.
- Top icon buttons → cyan ✓
- NavMenu (hamburger) → still purple
- InfoModal (info popup) → still purple
- SearchPalette (⌘K) → still purple
- Page body → still purple
From the "I want a single page to have a unified color" viewpoint, nothing past the entry point was actually changing.
Why Was That?
Two reasons.
1. Direct references to global tokens
The project-wide CSS looked like this:
.button {
border: 1px solid rgba(var(--js2-primary), 0.4);
}
--js2-primary sits in globals.css as 139, 92, 246 (purple). Every element referenced this value directly, which made changing just one spot practically impossible. To change it, you'd overwrite the global value — and that would change every page, including the home.
2. Portal isolation
NavMenu, InfoModal, and SearchPalette all rendered via createPortal(..., document.body). In short, they escape the tree and attach directly under body. Z-index stacking becomes clean, but the price is a broken CSS cascade.
No matter what class I put on the PageLayout root, anything that teleported into a portal was out of reach.
The Way Out — Local CSS Variable Overrides
CSS variables shine at cascade. Redefine a value on a specific element and every descendant recomputes with the new value. Original stays untouched.
.themeAurora {
--js2-primary: 110, 220, 180;
--js2-primary-mid: 140, 230, 195;
}
Put that class on the page root and every rgba(var(--js2-primary), ...) inside that page computes in mint. Globals untouched. Clean.
But portals are the wall. Portal nodes aren't under the page root, so the override doesn't reach them. The fix was simple — attach the same class directly to the portal's internal element.
Picking a Palette
Started with six colors — technically default plus five.
| ID | Tone | Vibe |
|---|---|---|
default | Purple | Base / meta pages |
starship | Sky | Spaceships / missions |
inferno | Amber | Arcade / combat |
aurora | Mint | Healing / creative |
void | Magenta | Abyss / chaos |
solar | Gold | Astronomy / observation |
Each theme redefines four variables.
.themeAurora {
--js2-primary: 110, 220, 180;
--js2-primary-light: 180, 240, 215;
--js2-primary-mid: 140, 230, 195;
--js2-primary-pale: 210, 250, 230;
}
Four was enough. Existing CSS hardly referenced anything outside the --js2-primary-* family.
Where It Lives — Four Spots with the Same Block
PageLayout root → page body + SystemButtons inherit naturally
NavMenu panel → portal-local override
InfoModal panel → portal-local override
SearchPalette panel → portal-local override (rendered at layout.tsx level)
All four carry the same .themeXxx blocks with identical RGB values. That's duplication, yes — but each CSS Module scope is separate and portals are isolated, so this one couldn't be avoided. Elsewhere, anything that could be deduplicated was ruthlessly deduplicated.
Auto-mapping — What About Future Pages?
When I showed the initial 5-theme design to the user (me), this came up:
"We might add more pages later. Sometimes I'll want a specific page on a totally different theme too..."
So I introduced a central mapping table. A single pageTheme.ts file holding "which URL uses which theme". Split by category for readability.
const HEALING_THEME = {
'/cosmos': 'default',
'/breathing': 'aurora',
// ...
};
const BATTLE_THEME = {
'/galaga': 'inferno',
'/cosmicBarrage': 'starship',
// ...
};
export const PAGE_THEME = {
...HEALING_THEME,
...BATTLE_THEME,
// ...
};
PageLayout reads the current path with usePathname() and looks it up. If a page container explicitly passes theme, that wins; otherwise it's the table's mapping. Absent from the table → default purple.
Next time a new area (say "Dev Trends") appears, adding it is one line in pageTheme.ts.
A Funny Moment Mid-work
Right after finishing, I checked the page body and the buttons were still purple. I'd already added the theme class to root — why wasn't it working? Turned out the body's CSS had rgba(139, 92, 246, 0.15) hardcoded in a few places. Blamed my past self.
A full-project grep found 7 files with 68 occurrences. A quick sed replaced all of them with rgba(var(--js2-primary), ...). Only then did the colors really start flowing through.
Lesson: once you commit to design tokens, don't leave exceptions. A single hardcoded value punctures the whole system.
Optimization — Removing Duplication
Initially I'd added .themeXxx blocks inside SystemButtons too, but later realized it was unnecessary. When the PageLayout root gets a theme class, --js2-primary is already redefined, and SystemButtons inherits it through cascade — which then flows into its own --sys-btn-* variables automatically.
I deleted those five blocks and left this one-line comment:
/* Theme overrides cascade: when PageLayout root's .themeXxx redefines
--js2-primary, .root's --sys-btn-* automatically inherits the new value. */
50 lines of code turned into a single comment. This is the kind of moment that makes refactoring feel good.
Retrospective
1. Local CSS variable overrides are powerful
"Override a specific region without touching the global token" is broader than it looks. Beyond themes, it works for component variants, dark mode, section accents, and more.
2. Portals are convenient — but remember they're isolated
The moment you reach for a portal to keep z-index stacking clean, you've lost CSS cascade. Anything that should influence the entire descendant tree — themes, dark mode — needs dedicated handling inside each portal component.
3. Build extensibility in from day one
Hardcoding "we only need 5 themes right now" means you'll touch many places the moment a sixth theme shows up. Starting with a "edit one constant table and done" shape is faster in the long run.
With each page subtly tinted, the whole blog feels more dimensional. Small differences, but these are the things that accumulate into a site's overall character.
Guestbook
Leave a short note about this post
Loading...