Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button
Up to this point the site had a look, but not a system. Colors were scattered
across component files as raw Tailwind utilities — text-rose-100/90,
bg-white/5, border-neutral-800 — and every new component meant guessing
which shade of off-white to use. Phase 6 changes that. I'm building a proper
theme layer: CSS custom properties for every semantic color, a migration of
every component to those tokens, WCAG contrast fixes, five switchable theme
presets, and a dedicated Settings page to tie it all together.
Seven tasks, one coherent system. Let's go.
1. CSS Variables Foundation
The entire theme system rests on one idea: decouple the color role from the
color value. Instead of writing text-rose-100 in a component, I write
text-foreground and define what "foreground" means in one place.
Defining the palette in globals.css
Every color is stored as an HSL triplet in a CSS custom property. Tailwind's
hsl() wrapper composes the value at runtime:
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Surface & text */
--background: 38 33% 94%; /* #F4F1EA — warm cream */
--foreground: 20 10% 12%; /* #221F1C — warm near-black */
/* Primary accent */
--accent: 20 48% 53%; /* #C4704B — terracotta */
--accent-foreground: 0 0% 100%; /* white */
/* Surfaces */
--surface-primary: 38 33% 94%;
--surface-secondary: 38 20% 90%;
--surface-tertiary: 38 15% 85%;
/* Content hierarchy */
--content-primary: 20 10% 12%;
--content-secondary: 20 5% 40%;
--content-tertiary: 20 3% 55%;
/* Borders & muted */
--border: 38 10% 80%;
--muted: 38 10% 80%;
--muted-foreground: 20 5% 45%;
/* Ring focus */
--ring: 20 48% 53%;
}
.dark {
--background: 356 10% 10%; /* near-black with a warm tint */
--foreground: 356 0% 95%; /* off-white */
--accent: 356 89% 60%; /* rose */
--accent-foreground: 0 0% 5%;
--surface-primary: 356 10% 10%;
--surface-secondary: 356 8% 14%;
--surface-tertiary: 356 6% 18%;
--content-primary: 356 0% 95%;
--content-secondary: 356 0% 65%;
--content-tertiary: 356 0% 50%;
--border: 356 5% 20%;
--muted: 356 5% 20%;
--muted-foreground: 356 0% 50%;
--ring: 356 89% 60%;
}
}Notice the naming convention. Each token follows the pattern
--{category}-{role}. Categories group related values (surface, content,
border); roles describe intent (primary, secondary, tertiary). This is
deliberately close to the shadcn/ui convention so it feels familiar to anyone
who has used that system.
Wiring tokens into Tailwind
Tailwind doesn't know about my CSS variables by default. I map them in
tailwind.config.js using the hsl(var(--x)) pattern that shadcn/ui
popularized:
// tailwind.config.js (excerpt)
module.exports = {
darkMode: "class",
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
surface: {
primary: "hsl(var(--surface-primary))",
secondary: "hsl(var(--surface-secondary))",
tertiary: "hsl(var(--surface-tertiary))",
},
content: {
primary: "hsl(var(--content-primary))",
secondary: "hsl(var(--content-secondary))",
tertiary: "hsl(var(--content-tertiary))",
},
border: "hsl(var(--border))",
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
ring: "hsl(var(--ring))",
},
},
},
}Now bg-surface-secondary compiles to background-color: hsl(var(--surface-secondary)),
which resolves to the right value in either theme. One variable change, entire
site updates.
2. Migrate Components to Semantic Tokens
This is the tedious-but-essential part. Every hardcoded color utility in every component needs to become a semantic token.
Before and after
// BEFORE — raw Tailwind colors, duplicated logic
<h2 className="text-rose-100/90 dark:text-rose-100/90">Title</h2>
<p className="text-neutral-400 dark:text-neutral-400">Subtitle</p>
<div className="bg-white/5 dark:bg-white/5">Card</div>
// AFTER — semantic tokens, theme-aware by default
<h2 className="text-content-primary">Title</h2>
<p className="text-content-tertiary">Subtitle</p>
<div className="bg-surface-secondary">Card</div>Two things to notice:
-
The
dark:prefix disappears. The CSS variable already switches between light and dark values based on the.darkclass on<html>. Components don't need to know which theme is active. -
Opacity modifiers get folded into the token. Instead of
text-rose-100/90, the light-mode--content-primaryis defined with the exact luminance I want. No per-component opacity hacks.
Migration scope
I swept through every component file. Here's the rough breakdown:
| File | Changes |
|---|---|
ui/Header.tsx | 8 |
ui/Footer.tsx | 5 |
ui/PostCard.tsx | 6 |
ui/MDXComponents.tsx | 12 |
pages/blog/[slug].tsx | 7 |
pages/index.tsx | 4 |
ui/Layout.tsx | 3 |
Total: ~45 color utility replacements across the codebase. Each one is a
find-and-replace with semantic meaning, not a blind text swap — I had to decide
whether a given text-neutral-400 was "secondary content" or "tertiary content"
based on its visual weight in context.
3. Verify the Original Theme
After migrating all components to tokens, I needed to verify the original light/dark theme still looked right. Semantic tokens are only useful if the underlying values are correct.
I loaded every page side-by-side with screenshots from before the migration. Three issues surfaced:
Fix 1: Primary text opacity
The blog post body text was rendering at full opacity (--content-primary =
12% lightness) when it should have been slightly softer for long-form reading.
I adjusted the surface relationship:
/* Before */
--content-primary: 20 10% 12%;
/* After — slightly reduced saturation for body text comfort */
--content-primary: 20 8% 14%;Subtle, but it matters when you're reading 2000 words.
Fix 2: Muted text legibility
--content-tertiary was too close to the background in light mode. Labels like
"5 min read" and date stamps were vanishing. Bumped the lightness:
/* Before */
--content-tertiary: 20 3% 55%;
/* After */
--content-tertiary: 20 4% 48%;Fix 3: Border visibility
Borders in light mode (--border: 38 10% 80%) were invisible against
--surface-secondary: 38 20% 90%. The lightness gap was only 10 points.
Dropped the border lightness:
/* Before */
--border: 38 10% 80%;
/* After */
--border: 38 10% 75%;These three fixes took 10 minutes. Without the visual regression check they would have shipped as subtle bugs that users notice but can't articulate.
4. Fix Contrast Issues
WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. I ran every text/background pair through a contrast checker. Two failures:
Muted foreground on background
--muted-foreground at /30 opacity (the common Tailwind pattern) against
--background was coming in at 2.8:1 — failing AA for normal text.
/* Before — too subtle */
--muted-foreground: 20 5% 45%;
/* Used with opacity: text-muted-foreground/30 */
/* After — baked into the token at proper contrast */
--muted-foreground: 20 5% 50%;By setting the base value higher and removing the opacity modifier, I hit 4.6:1 without losing the "muted" feel.
ContentLink metadata
The ContentLink.Meta component (showing category and date on post cards) was
using text-content-tertiary/50 — a token at half opacity. Contrast: 3.1:1.
For small metadata text, that's a fail.
// Before
<span className="text-content-tertiary/50">{meta}</span>
// After — full token, no opacity modifier
<span className="text-content-tertiary">{meta}</span>The token's base value (48% lightness in dark mode) already provides the right
visual weight. The extra /50 was a leftover from the pre-token days.
Decorative vs. readable
Not everything needs 4.5:1. Decorative elements — the · separator between
metadata items, background pattern dots, the series progress indicator — are
exempt under WCAG because they don't convey information. I left those at lower
contrast intentionally. The rule: if you need to read it, it passes AA; if
it's decorative, it can be subtle.
5. Theme Presets and Picker
With the semantic token layer in place, adding new themes is almost trivial. Each preset just redefines the CSS variable values.
Five presets
I created five theme presets, each with a distinct personality:
/* Rose Dark — the default dark theme */
[data-theme="rose-dark"] {
--background: 356 10% 10%;
--foreground: 356 0% 95%;
--accent: 356 89% 60%;
/* ... */
}
/* Midnight — deep blue-black */
[data-theme="midnight"] {
--background: 222 30% 8%;
--foreground: 220 15% 93%;
--accent: 217 91% 60%;
/* ... */
}
/* Ocean — teal accent on cool gray */
[data-theme="ocean"] {
--background: 200 15% 10%;
--foreground: 200 10% 93%;
--accent: 174 70% 45%;
/* ... */
}
/* Forest — earthy greens */
[data-theme="forest"] {
--background: 140 10% 10%;
--foreground: 140 5% 93%;
--accent: 142 55% 45%;
/* ... */
}
/* Sunset — warm amber on charcoal */
[data-theme="sunset"] {
--background: 30 10% 10%;
--foreground: 30 5% 93%;
--accent: 35 90% 55%;
/* ... */
}Each preset overrides every semantic token. No component code changes needed.
next-themes integration
I'm using next-themes to manage theme switching. It handles the class
attribute on <html>, persists the choice to localStorage, and respects
prefers-color-scheme on first visit.
// _app.tsx
import { ThemeProvider } from "next-themes"
function MyApp({ Component, pageProps }: AppProps) {
return (
<ThemeProvider
attribute="data-theme"
defaultTheme="rose-dark"
themes={["light", "dark", "rose-dark", "midnight", "ocean", "forest", "sunset"]}
>
<Layout>
<Component {...pageProps} />
</Layout>
</ThemeProvider>
)
}The attribute="data-theme" setting means next-themes writes
data-theme="midnight" to <html> instead of a class. My CSS selectors match
[data-theme="midnight"] { ... }. This avoids conflicts with the existing
dark class used for light/dark mode.
ThemePicker component
The picker renders colored swatches — one per preset. Clicking a swatch calls
setTheme():
// ui/ThemePicker.tsx
import { useTheme } from "next-themes"
const THEMES = [
{ name: "light", label: "Light", color: "#F4F1EA" },
{ name: "rose-dark", label: "Rose Dark", color: "#E8466A" },
{ name: "midnight", label: "Midnight", color: "#3B82F6" },
{ name: "ocean", label: "Ocean", color: "#2DD4BF" },
{ name: "forest", label: "Forest", color: "#34D399" },
{ name: "sunset", label: "Sunset", color: "#F59E0B" },
]
export function ThemePicker() {
const { theme, setTheme } = useTheme()
return (
<div className="flex gap-3">
{THEMES.map((t) => (
<button
key={t.name}
onClick={() => setTheme(t.name)}
aria-label={`Switch to ${t.label} theme`}
className={`
h-8 w-8 rounded-full border-2 transition-transform
hover:scale-110
${theme === t.name ? "border-accent scale-110" : "border-border"}
`}
style={{ backgroundColor: t.color }}
/>
))}
</div>
)
}Each swatch is a 32px circle with the theme's accent color as its background.
The active theme gets a ring (via border-accent) and a slight scale-up.
Hovering any swatch scales it to 110% — a micro-interaction that makes the
picker feel alive.
6. Theme Documentation
With five themes and a token system, I needed documentation for my future self
(and anyone else who might touch the codebase). I created
docs/theme-system.md:
docs/theme-system.md
├── Architecture Overview
│ ├── CSS Variables → Tailwind Mapping
│ └── How next-themes manages switching
├── Token Reference
│ ├── Surface tokens (primary, secondary, tertiary)
│ ├── Content tokens (primary, secondary, tertiary)
│ ├── Accent tokens
│ ├── Border & muted tokens
│ └── Ring token
├── Adding a New Theme
│ ├── 1. Define CSS variables in globals.css
│ ├── 2. Add theme name to next-themes config
│ ├── 3. Add swatch entry to ThemePicker
│ └── 4. Run contrast checks
├── WCAG Compliance Notes
│ ├── Which tokens must pass AA
│ └── Decorative exemptions
└── Preset Gallery
├── Light
├── Rose Dark
├── Midnight
├── Ocean
├── Forest
└── SunsetThe key section is "Adding a New Theme" — a four-step checklist. If I want a seventh theme three months from now, I don't have to reverse-engineer the system. The steps are explicit and in order.
I also added inline JSDoc comments to the CSS variables themselves:
/**
* Surface tokens — backgrounds and card fills.
* - primary: page background
* - secondary: elevated cards, code blocks
* - tertiary: deeply nested surfaces (rare)
*/
--surface-primary: 38 33% 94%;
--surface-secondary: 38 20% 90%;
--surface-tertiary: 38 15% 85%;CSS doesn't have a formal documentation system, but comments at the point of definition work well enough.
7. Settings Page and Nav Icon
The theme picker needs a home. I built a dedicated /settings page and added a
gear icon to the navigation.
Settings page
// pages/settings.tsx
import { ThemePicker } from "../ui/ThemePicker"
import { useTheme } from "next-themes"
export default function SettingsPage() {
const { theme } = useTheme()
return (
<main className="mx-auto max-w-2xl px-6 py-16">
<h1 className="text-content-primary mb-2 text-3xl font-bold">Settings</h1>
<p className="text-content-secondary mb-12">Customize the look and feel of the site.</p>
<section className="space-y-4">
<h2 className="text-content-primary text-lg font-semibold">Theme</h2>
<p className="text-content-tertiary text-sm">
Current: <span className="text-accent">{theme}</span>
</p>
<ThemePicker />
</section>
{/* Future sections: font size, reading preferences, etc. */}
</main>
)
}The page is intentionally minimal — a heading, a description, and the theme
picker. Future settings (font size, reading width, motion preferences) can slot
into new <section> blocks without restructuring.
CogIcon in navigation
I added a CogIcon to the header navigation, separated from the main links by
a divider:
// ui/Header.tsx (excerpt)
import { CogIcon } from "lucide-react"
import Link from "next/link"
export function Header() {
return (
<header className="border-border border-b">
<nav className="mx-auto flex h-16 max-w-4xl items-center justify-between px-6">
{/* Left: site title / logo */}
<Link href="/" className="text-content-primary text-lg font-bold">
shanejix
</Link>
{/* Right: nav links + settings */}
<div className="flex items-center gap-6">
<Link
href="/blog"
className="text-content-secondary hover:text-content-primary transition-colors"
>
Blog
</Link>
<Link
href="/projects"
className="text-content-secondary hover:text-content-primary transition-colors"
>
Projects
</Link>
{/* Divider */}
<span className="bg-border h-5 w-px" />
{/* Settings gear */}
<Link
href="/settings"
className="text-content-tertiary hover:text-accent transition-colors"
aria-label="Settings"
>
<CogIcon className="h-5 w-5" />
</Link>
</div>
</nav>
</header>
)
}The divider (w-px h-5 bg-border) is a thin vertical line that visually
separates the settings icon from the content links. It's decorative — no
accessible name needed.
Gear rotation micro-interaction
A small CSS animation makes the gear rotate on hover:
/* globals.css */
@keyframes gear-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(90deg);
}
}
a[aria-label="Settings"]:hover svg {
animation: gear-spin 0.4s ease-in-out;
}The gear rotates 90 degrees over 0.4 seconds when you hover the settings link.
It's a one-shot animation — it doesn't loop. The ease-in-out curve gives it a
mechanical feel, like turning a real dial. Subtle enough to miss if you're not
paying attention, obvious enough to feel intentional when you notice it.
I chose 90deg instead of a full 360deg rotation because a quarter-turn
reads as "this is interactive" without being distracting. A full spin would feel
like a loading indicator.
What I Learned
Semantic tokens are an investment that pays compound interest. The initial
migration was tedious — 45 find-and-replace operations with semantic judgment
calls. But every subsequent change is now trivial. When I added the five theme
presets, I didn't touch a single component file. When I fixed the contrast
issues, I changed three lines in globals.css instead of hunting through
twelve files.
CSS custom properties are the right abstraction for theming. They cascade
naturally, they work with Tailwind's hsl() pattern, and they don't require
JavaScript at runtime. The next-themes library handles the switching logic;
the CSS variables handle the rendering. Clean separation.
Visual regression checking is not optional. The three color fidelity fixes in Task 3 were invisible to automated tests but obvious to human eyes. After any migration that touches colors, load every page and look at it. Actually look at it.
WCAG contrast is a floor, not a ceiling. The two contrast failures were both cases where I had prioritized aesthetics over readability. The fixes didn't make the design worse — they made it more intentional. Contrast checking isn't a chore; it's a design tool.
Phase 7 adds the final piece — multilingual support. The site will serve content in English and Chinese, with locale-aware routing and a language switcher.
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button