Valcraven Docs
Features

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
}
  • theme is the stored preference, including "system".
  • resolvedTheme is 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).
  • setTheme updates 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 control
  • compact shows just the current icon (sun / moon / monitor) and cycles light → dark → system → light on 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:

  1. On mount, reads the saved preference from localStorage (key valcraven-theme), defaulting to "system".
  2. Resolves "system" against window.matchMedia("(prefers-color-scheme: dark)").
  3. Applies the result by toggling the light / dark class on the <html> element.
  4. 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).
  5. 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 literally valcraven-theme in 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, …) in globals.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-theme brand 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 extending lib/theme.tsx to persist a brandTheme.
  • Branch on appearance. When a component genuinely needs to know light vs. dark (image assets, canvas colors), read resolvedTheme from useTheme() rather than re-querying matchMedia.

Gotchas

  • Don't reintroduce hardcoded colors. Writing bg-emerald-600 / bg-blue-500 directly defeats the token system. If a value should theme-swap, add or use a semantic token instead.
  • Prefer resolvedTheme for logic. theme can be "system"; use resolvedTheme when you need a definite light/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.

On this page