Theming & dark mode
The useTheme hook, the ThemeToggle, system-aware light/dark switching, and the input text-color rule you must follow.
Valcraven ships light, dark, and system-aware theming out of the box. A user's choice is persisted, applied without a flash of the wrong theme, and available anywhere via a hook. The runtime lives in lib/theme.tsx (ThemeProvider + useTheme), the UI control is components/ui/theme-toggle.tsx (ThemeToggle), and colors are driven by a semantic token system in app/globals.css.
The ThemeProvider is already mounted in the root layout, and the header already renders a ThemeToggle — so dark mode works before you write any code. This page covers how to read/set the theme, how switching works, and the one rule you must not forget when styling inputs.
Reading and setting the theme
Use the useTheme hook from lib/theme.tsx:
import { useTheme } from "@/lib/theme";
function Example() {
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "light" | "dark" | "system" (the user's preference)
// resolvedTheme: "light" | "dark" (what's actually applied)
// setTheme: (t: "light" | "dark" | "system") => void
}themeis the stored preference, including"system".resolvedThemeis always a concrete"light"or"dark"— resolve system preference here when you need to branch on the real appearance (e.g. picking a chart palette).setThemeupdates the preference, persists it, and applies it immediately.
useTheme() throws if used outside a ThemeProvider, but since the provider wraps the whole app in the root layout, that only matters for isolated tests.
The ThemeToggle component
ThemeToggle from @/components/ui/theme-toggle renders the switcher two ways:
import { ThemeToggle } from "@/components/ui/theme-toggle";
<ThemeToggle compact /> // header: single icon button that cycles themes
<ThemeToggle /> // settings: segmented Light / Dark / System controlcompactshows just the current icon (sun / moon / monitor) and cycleslight → dark → system → lighton each click. This is the header variant.- Full mode shows all three options as a segmented control — used on the settings page.
How switching works
ThemeProvider in lib/theme.tsx:
- On mount, reads the saved preference from
localStorage(keyvalcraven-theme), defaulting to"system". - Resolves
"system"againstwindow.matchMedia("(prefers-color-scheme: dark)"). - Applies the result by toggling the
light/darkclass on the<html>element. - Writes a cookie (also
valcraven-theme) so the server can render the correct theme on first paint — this prevents a flash of the wrong theme (FOUC). - While the preference is
"system", it listens for OS-level color-scheme changes and re-applies live.
Because everything hangs off the dark class on <html>, Tailwind's dark: variants and the semantic tokens both respond automatically.
If you scaffold a project from Valcraven under a different name, the
"valcraven-theme"storage/cookie key is one of the strings renamed to your slug. The template's create skill handles this — just be aware the key isn't literallyvalcraven-themein a renamed project.
Colors: semantic tokens, not hardcoded scales
Valcraven styles components with intent-based semantic tokens (bg-primary, text-muted-foreground, border-border) rather than raw Tailwind color scales (bg-emerald-600, text-gray-500). Every token has a paired light and dark value wired in app/globals.css, so:
- You change your brand color in one place and the whole app recolors.
- Light/dark parity is automatic — you rarely need to write
dark:variants yourself. - The token names follow the shadcn/ui convention, so shadcn components drop in with zero remapping.
The lazy way to rebrand: change --primary, --accent, --accent-foreground, and --ring in :root and .dark inside globals.css. The full token reference — every surface, brand, status, border, and radius token, plus reference brand themes and the shadcn primitives — lives in the admin dev wiki at /admin/docs/theming.
The input text-color rule (important)
Tailwind's unstyled <input> inherits colors that look fine in light mode but become invisible or low-contrast in dark mode — dark text on a dark field. Whenever you add a raw <input> (or <textarea> / <select>), always set an explicit text color for both themes:
<input className="text-gray-900 dark:text-gray-100 …" />This is a required convention in Valcraven (it's in the "things to never do" list). The shipped Input component from @/components/ui already handles this — prefer it. Only raw HTML form controls need the manual classes.
Extending
- Rebrand. Edit the brand tokens (
--primary,--accent,--ring, …) inglobals.css. See the dev wiki theming reference for the full token list and the old-class → token mapping table. - A user-facing brand-theme picker. The template supports switchable
data-themebrand palettes (documented in the dev wiki) but doesn't wire a persistent picker by default — most projects pick one brand color and keep it. If you need one, the dev wiki walks through extendinglib/theme.tsxto persist abrandTheme. - Branch on appearance. When a component genuinely needs to know light vs. dark (image assets, canvas colors), read
resolvedThemefromuseTheme()rather than re-queryingmatchMedia.
Gotchas
- Don't reintroduce hardcoded colors. Writing
bg-emerald-600/bg-blue-500directly defeats the token system. If a value should theme-swap, add or use a semantic token instead. - Prefer
resolvedThemefor logic.themecan be"system"; useresolvedThemewhen you need a definitelight/dark. - The cookie is what kills FOUC. The theme cookie lets SSR set the right class on first render. If you strip it out, expect a flash of the wrong theme on load.
Search
The three search surfaces in the template — full-text item search (FTS5/LIKE, session-scoped), public docs search, and admin-only dev wiki search — all built on fumadocs and SQLite.
Building with Valcraven
Practical, copy-paste recipes for extending the template — features, API routes, admin pages, migrations, realtime channels, chat tools, and API-key auth.