ZeroStarter

Theming

Dark mode, CSS variables, and styling with Tailwind CSS v4.

Overview

ZeroStarter uses Tailwind CSS v4 with CSS custom properties for theming, next-themes for dark/light mode switching, and the Fumadocs UI theme for documentation pages.

Dark Mode

Dark mode is handled by next-themes with system preference detection:

// web/next/src/app/providers.tsx
import { ThemeProvider as NextThemesProvider } from "next-themes"

export function InnerProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      {children}
      <Toaster richColors />
    </NextThemesProvider>
  )
}

The ModeToggle component at web/next/src/components/mode-toggle.tsx is a smart toggle: from system it switches to the opposite of the OS preference, it flips between light and dark while they diverge from the OS preference, and it returns to system once the explicit theme matches the OS preference again.

CSS Variables

Theme colors are defined in web/next/src/app/globals.css using OKLch color space:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --muted: oklch(0.97 0 0);
  --border: oklch(0.922 0 0);
  /* ... */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  /* ... */
}

These variables are consumed by Tailwind utility classes like bg-background, text-foreground, border-border, etc.

Tailwind Configuration

Tailwind v4 is configured via PostCSS in web/next/postcss.config.mjs:

const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
}

export default config

web/next/src/app/globals.css imports Tailwind, animations, and the Fumadocs UI and shadcn presets:

@import "tailwindcss";
@import "tw-animate-css";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "shadcn/tailwind.css";

A @custom-variant dark (&:is(.dark *)) declaration teaches Tailwind to treat the .dark class (toggled by next-themes) as the trigger for dark: utilities. It then wires the design tokens to Tailwind v4 via an @theme inline block that maps --color-* utilities to the --* custom properties (background, foreground, primary, card, popover, chart, sidebar, radius, fonts, and so on).

Fonts

Fonts are loaded with next/font/local from .woff2 files in web/next/src/fonts/, defined in web/next/src/lib/fonts.ts (not via @fontsource). Four variable fonts are included:

  • DM Sans: Primary UI font (body text, headings)
  • JetBrains Mono: Monospace font (code blocks)
  • Caveat: Handwriting accent font
  • Newsreader: Serif accent font

Each exposes a CSS variable (e.g. --font-dm-sans). Only DM Sans and JetBrains Mono are wired into Tailwind's font utilities by the @theme inline block, which maps --font-sans: var(--font-dm-sans), --font-mono: var(--font-jetbrains-mono), and --font-heading: var(--font-sans) (so headings track the sans family unless you repoint it). Caveat and Newsreader are exposed as CSS variables (--font-caveat, --font-newsreader) only, so use them via those variables directly.

Component Styling

Components use the shadcn/ui base-nova preset with Base UI primitives. The configuration is in web/next/components.json:

{
  "style": "base-nova",
  "iconLibrary": "remixicon",
  "tailwind": {
    "baseColor": "neutral",
    "cssVariables": true
  }
}

Utility Functions

import { cn } from "@/lib/utils"

// Merge Tailwind classes with conflict resolution
<div className={cn("text-sm", isActive && "font-bold")} />

Customizing the Theme

  1. Edit CSS variables in web/next/src/app/globals.css for global color changes
  2. Use Tailwind utility classes that reference the CSS variables
  3. Update components.json and run bun run shadcn:update to regenerate components with a different preset

Customizations and the shadcn sync

bun run shadcn:update wipes web/next/src/components/ui/ and silently loses any hand-edits there. Put overrides in shadcn-customize.ts instead.

The re-scaffold also drops registry-controlled lines in globals.css, not just files in ui/. Every override is instead re-applied afterward by .github/scripts/shadcn-customize.ts, which the sync runs at the end: it restores files the project owns outright, patches the registry components it extends (button, spinner, sidebar) by shape rather than text, and re-applies the --font-sans: var(--font-dm-sans), sans-serif line in globals.css via its patchGlobals step.

Each patch is idempotent and throws if its target is missing, so a registry shape change fails the sync loudly rather than silently dropping an override.