Theming the Site: CSS Variables, Theme Presets, and a Settings Page

Jun 11
·

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:

  1. The dark: prefix disappears. The CSS variable already switches between light and dark values based on the .dark class on <html>. Components don't need to know which theme is active.

  2. Opacity modifiers get folded into the token. Instead of text-rose-100/90, the light-mode --content-primary is 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:

FileChanges
ui/Header.tsx8
ui/Footer.tsx5
ui/PostCard.tsx6
ui/MDXComponents.tsx12
pages/blog/[slug].tsx7
pages/index.tsx4
ui/Layout.tsx3

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.

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
└── Sunset

The 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.