ShipVeryFastShipVeryFast
Documentation

Theming & dark mode

ShipVeryFast ships a runtime theme system: ten color schemes, ten font families and a light/dark toggle, all driven from a single source of truth in libs/themes.ts and applied to the document via the ThemeProvider. This page covers the tokens, the provider API, the repo's Tailwind conventions, and how to add your own scheme or font.

The theme system runs entirely on the client. The provider writes CSS variables onto document.documentElement at runtime and persists the choice to localStorage, there is no server round-trip for switching themes.

Theme tokens in libs/themes.ts

libs/themes.ts exports two types and two arrays. A ThemeOption is a named color scheme with separate light and dark token maps; a FontOption is a named font family with its CSS variable.

// libs/themes.ts
export type ThemeOption = {
  name: string;
  id: string;
  colors: {
    light: Record<string, string>;
    dark: Record<string, string>;
  };
};

export type FontOption = {
  name: string;
  id: string;
  fontFamily: string;
  variable: string;
  weights?: number[];
};

The color values are written in OKLCH, a perceptually-uniform color space, so lightness and chroma stay consistent as you shift the hue. Each scheme sets a small set of CSS variables, the ones it overrides on top of the base tokens in app/globals.css:

VariableRole
--primaryThe scheme's main brand color (buttons, links, accents).
--primary-foregroundText/icon color that reads on top of --primary.
--secondaryMuted surface color for chips, hovers and subtle fills.
--accentSecondary highlight; paired with --primary for gradients.

Here is the full Default scheme, note the same keys appear under both light and dark:

// A ThemeOption with light/dark tokens (libs/themes.ts)
{
  name: "Default",
  id: "default",
  colors: {
    light: {
      "--primary": "oklch(0.2 0.05 240)",
      "--primary-foreground": "oklch(0.98 0 0)",
      "--secondary": "oklch(0.9 0.03 240)",
      "--secondary-foreground": "oklch(0.2 0.05 240)",
      "--accent": "oklch(0.85 0.05 240)",
      "--accent-foreground": "oklch(0.2 0.05 240)",
    },
    dark: {
      "--primary": "oklch(0.85 0.07 240)",
      "--primary-foreground": "oklch(0.15 0.02 240)",
      "--secondary": "oklch(0.3 0.05 240)",
      "--secondary-foreground": "oklch(0.95 0.02 240)",
      "--accent": "oklch(0.35 0.07 240)",
      "--accent-foreground": "oklch(0.95 0.02 240)",
    },
  },
}

Built-in schemes and light/dark variants

Ten color schemes ship in the themes array, each with its own hand-tuned light and dark token sets. Switching dark mode swaps which of the two maps the provider applies; switching scheme swaps which entry in the array it reads from.

SchemeidHue family
DefaultdefaultCool blue-slate
OceanoceanBlue / cyan
ForestforestGreen
SunsetsunsetOrange / red
LavenderlavenderPurple
MonochromemonochromeNeutral grayscale
NeonneonHigh-chroma green / blue / magenta
PastelpastelSoft low-chroma
AutumnautumnAmber / brown
WinterwinterCool blue

The font list (fonts) holds ten options: Geist Sans (geist-sans), Inter (inter), Roboto (roboto), Poppins (poppins), Montserrat (montserrat), Open Sans (open-sans), Lato (lato), Raleway (raleway), Playfair Display (playfair) and Source Code Pro (source-code-pro). Each carries a fontFamily stack and the CSS variable it maps to.

The theme provider and useTheme()

components/theme/theme-provider.tsx defines the ThemeProvider (a client component) and the useTheme() hook. The provider is mounted once in app/providers.tsx, which wraps the whole app from the root layout, so the hook is available anywhere in the tree.

On mount the provider reads system preference via window.matchMedia('(prefers-color-scheme: dark)'), applies the saved scheme and font, then keeps children hidden until mounted to avoid a theme flash on first paint. Applying a scheme writes each token onto document.documentElement with style.setProperty and toggles the dark class on <html>.

useTheme() returns the current selection plus setters. It throws if called outside a ThemeProvider:

// useTheme() usage
"use client";
import { useTheme } from "@/components/theme/theme-provider";

function AppearanceControls() {
  const {
    theme,            // current scheme id, e.g. "default"
    setTheme,         // (id: string) => void
    font,             // current font id, e.g. "inter"
    setFont,          // (id: string) => void
    isDarkMode,       // boolean
    toggleDarkMode,   // () => void
    availableThemes,  // ThemeOption[]
    availableFonts,   // FontOption[]
  } = useTheme();

  return (
    <button onClick={() => setTheme("ocean")}>
      Use Ocean {theme === "ocean" ? "(active)" : ""}
    </button>
  );
}

Two ready-made UI pieces consume this hook and are re-exported from components/theme: ThemeSelector (a dark-mode toggle plus scheme and font dropdowns) and ThemeToggle (a compact palette button that opens the selector in a popover). Drop either into a header to get instant theming controls.

Tailwind conventions in this repo

tailwind.config.ts sets darkMode: "class", so dark styling is driven by the dark class the provider toggles on <html> , that is what makes every dark: variant resolve.

Semantic color utilities

The Tailwind colors map binds utilities to the CSS variables from globals.css, so classes like bg-primary, text-primary-foreground, bg-secondary, bg-card, text-muted-foreground and border-border automatically track the active scheme and dark mode. The base tokens are defined as HSL channels under :root and .dark in app/globals.css; the active scheme then overrides --primary, --secondary and --accent at runtime.

Font utilities and headings

The config registers a font-display utility mapped to --font-display, which the root layout wires to Bricolage Grotesque (loaded with next/font/google). Use font-display on headings for the premium display look, while font-sans (Inter, via --font-inter) is the body default. Each selectable font also has its own utility, font-inter, font-poppins, font-montserrat and so on.

Card & surface patterns

The repo's components lean on a consistent surface recipe: rounded, bordered cards on a card background, for example rounded-lg border border-border bg-card, with hover:bg-secondary for interactive rows. Because these all reference theme tokens rather than fixed palette values, a card looks correct in every scheme and in both light and dark mode without extra work.

Adding a new color scheme or font option

Both are pure data edits in libs/themes.ts. To add a scheme, append a new ThemeOption to the themes array with a unique id and both light and dark token maps. It immediately shows up in the selector and the appearance page, because those iterate over availableThemes.

// Registering a new theme, add to the `themes` array in libs/themes.ts
{
  name: "Rose",
  id: "rose",
  colors: {
    light: {
      "--primary": "oklch(0.6 0.18 10)",
      "--primary-foreground": "oklch(0.985 0 0)",
      "--secondary": "oklch(0.85 0.1 350)",
      "--accent": "oklch(0.75 0.14 20)",
    },
    dark: {
      "--primary": "oklch(0.65 0.2 10)",
      "--primary-foreground": "oklch(0.1 0 0)",
      "--secondary": "oklch(0.45 0.14 350)",
      "--accent": "oklch(0.55 0.17 20)",
    },
  },
},

Keep the same variable keys the other schemes use so the provider has something to apply for both modes. To add a font, append a FontOption to the fonts array with a fontFamily stack and its variable. If the font needs loading, register it in app/layout.tsx (via next/font/google) and expose its CSS variable, then add a matching font-* utility under fontFamily in tailwind.config.ts, that is how the existing options such as --font-inter are wired.

Appearance settings page and persisting choice

The full settings screen lives at app/settings/appearance/ (route /settings/appearance). The server page.tsx renders the client component in client-page.tsx, which uses useTheme() to render three sections, Color Mode (Light / Dark), Color Theme (a grid over availableThemes) and Font Family (a grid over availableFonts), each marking the active choice with a check.

Persistence is handled by libs/userPreferences.ts. The provider doesn't store theme state itself: setTheme, setFont and toggleDarkMode all call updatePreferences from the useUserPreferences hook, which writes the whole preferences object to localStorage under the key userPreferences and notifies subscribers. On the next load, preferences are read back fromlocalStorage (merged over DEFAULT_PREFERENCES) and re-applied.

Heads up on defaults.The theme system's default scheme is default and default font is geist-sans (see DEFAULT_THEME_CONFIG in components/theme/index.ts), but DEFAULT_PREFERENCES in libs/userPreferences.ts start at theme: "blue" and font: "inter". Since blueisn't one of the ten scheme ids, the provider falls back to the first scheme (themes[0], i.e. Default) until the user picks one.

Next steps