Trovella Wiki

Theming

Dark mode implementation, Tailwind v4 CSS-first configuration, font loading, and the theme provider architecture.

Theming in Trovella connects three systems: the design token CSS variables (light/dark values), Tailwind v4's CSS-first theme mapping, and the next-themes library for user-controlled theme switching.

How the Theme Pipeline Works

tokens.css                     -- :root (light) and .dark { } token definitions
  |
  v
globals.css                    -- @import tokens, @theme inline { } maps tokens to Tailwind
  |
  v
layout.tsx                     -- loads Google Fonts, sets CSS variables, wraps in ThemeProvider
  |
  v
ThemeProvider (next-themes)    -- manages .dark class on <html>, persists user preference
  |
  v
Components                     -- use Tailwind utilities (bg-primary, text-foreground, etc.)

Tailwind v4 CSS-First Configuration

There is no tailwind.config.ts file. Tailwind v4 reads its configuration directly from CSS. The entire theme mapping lives in globals.css:

@import "tailwindcss";
@import "@repo/design-tokens/tokens.css";

@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-primary: hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  /* ... all semantic color mappings ... */

  --font-sans: var(--font-body), "DM Sans", system-ui, -apple-system, sans-serif;
  --font-serif: var(--font-display), "DM Serif Display", Georgia, "Times New Roman", serif;
  --font-mono: var(--font-mono), "JetBrains Mono", ui-monospace, "Cascadia Code", monospace;
}

Key points:

  • @import "tailwindcss" replaces the old @tailwind base/components/utilities directives
  • @custom-variant dark defines the dark mode selector as &:is(.dark *), matching elements inside a .dark ancestor
  • @theme inline maps CSS custom properties to Tailwind's namespace (e.g., --color-primary becomes the bg-primary utility)
  • Font stacks include both the Next.js font variable (var(--font-body)) and static fallbacks for SSR/flash protection

Color Mapping Pattern

Design tokens store bare HSL triplets. The @theme block wraps them with hsl() for Tailwind:

/* In tokens.css */
--primary: 188 32% 33%;

/* In globals.css @theme */
--color-primary: hsl(var(--primary));

This indirection allows Tailwind's opacity modifier syntax to work:

<!-- bg-primary/20 produces hsl(188 32% 33% / 0.2) -->
<div class="bg-primary/20">...</div>

The sidebar has its own color namespace (--color-sidebar, --color-sidebar-foreground, etc.) mapped in the @theme block. This keeps the sidebar's dark teal appearance independent of the page theme.

Base Layer Styles

The @layer base block in globals.css sets three global defaults:

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background font-sans text-foreground antialiased selection:bg-primary/20;
  }
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    @apply font-serif font-bold tracking-tight;
  }
}
  • All elements get border-border so borders use the semantic token by default
  • Body sets the page background, text color, selection highlight, and base font
  • All headings use the display font (DM Serif Display) with tight tracking

Font Loading

Fonts are loaded in layout.tsx using Next.js's next/font/google optimization:

const dmSans = DM_Sans({
  subsets: ["latin"],
  variable: "--font-body",
  display: "swap",
  weight: ["400", "500", "600", "700"],
});

const dmSerifDisplay = DM_Serif_Display({
  subsets: ["latin"],
  variable: "--font-display",
  display: "swap",
  weight: "400", // Only weight available
});

const jetbrainsMono = JetBrains_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
  display: "swap",
});

The CSS variables (--font-body, --font-display, --font-mono) are set on the <html> element via className:

<html className={`${dmSans.variable} ${dmSerifDisplay.variable} ${jetbrainsMono.variable}`}>

These variables are then consumed by the @theme font stacks in globals.css. The static font names in the fallback chain ("DM Sans", "DM Serif Display", "JetBrains Mono") provide SSR protection -- if the CSS variable hasn't resolved yet (during initial paint), the browser falls back to the named font which Next.js has already preloaded.

Dark Mode

Class Strategy

Dark mode uses the .dark class on the <html> element, not @media (prefers-color-scheme). This allows three theme modes:

ModeBehavior
Light.dark class absent -- :root tokens active
Dark.dark class present -- .dark { } token overrides active
SystemFollows OS preference via next-themes enableSystem

Theme Provider

The ThemeProvider component wraps the entire app in layout.tsx:

<ThemeProvider nonce={nonce}>
  <TooltipProvider>
    {children}
    <Toaster />
  </TooltipProvider>
</ThemeProvider>

Configuration:

  • attribute="class" -- toggles the .dark class (not a data-theme attribute)
  • defaultTheme="system" -- respects OS preference on first visit
  • enableSystem -- enables the system option in the theme toggle
  • disableTransitionOnChange -- prevents a flash of transitioning colors during theme switch
  • nonce -- CSP nonce passed from middleware for inline script injection

Theme Toggle

The ThemeToggle component (src/components/theme/theme-toggle.tsx) renders a segmented control with three options (Sun / Moon / Monitor icons). It lives in the user account dropdown menu in the dashboard top bar.

The active theme button uses bg-primary text-primary-foreground, and inactive buttons use text-muted-foreground hover:text-foreground.

Provider Tree

The root layout establishes the global provider hierarchy:

<html> (font CSS variables)
  <body> (base styles)
    <ThemeProvider> (next-themes, .dark class management)
      <TooltipProvider> (Radix tooltip positioning context)
        {children}
        <Toaster /> (sonner toast notifications)
      </TooltipProvider>
    </ThemeProvider>
  </body>
</html>

Dashboard pages add another layer inside {children}:

<Providers> (tRPC + TanStack Query)
  <SidebarProvider> (sidebar state + cookie persistence)
    <AppSidebar />
    <SidebarInset>
      <DashboardTopBar />
      <main>{content}</main>
    </SidebarInset>
  </SidebarProvider>
</Providers>

See Routing & Pages -- Component Boundaries for the full server/client split.

Adding a New Theme Token

To add a new semantic token (e.g., a new state color):

  1. Add the light mode value in :root { } in packages/design-tokens/tokens.css
  2. Add the dark mode value in .dark { } in the same file
  3. Map it in globals.css @theme inline { } block: --color-new-token: hsl(var(--new-token));
  4. Use it in components via Tailwind: bg-new-token, text-new-token, etc.

The token is immediately available in both light and dark modes without any build configuration changes.

On this page