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 configweb/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
- Edit CSS variables in
web/next/src/app/globals.cssfor global color changes - Use Tailwind utility classes that reference the CSS variables
- Update
components.jsonand runbun run shadcn:updateto 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.