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/utilitiesdirectives@custom-variant darkdefines the dark mode selector as&:is(.dark *), matching elements inside a.darkancestor@theme inlinemaps CSS custom properties to Tailwind's namespace (e.g.,--color-primarybecomes thebg-primaryutility)- 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>
Sidebar Colors
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-borderso 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:
| Mode | Behavior |
|---|---|
| Light | .dark class absent -- :root tokens active |
| Dark | .dark class present -- .dark { } token overrides active |
| System | Follows 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.darkclass (not adata-themeattribute)defaultTheme="system"-- respects OS preference on first visitenableSystem-- enables the system option in the theme toggledisableTransitionOnChange-- prevents a flash of transitioning colors during theme switchnonce-- 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):
- Add the light mode value in
:root { }inpackages/design-tokens/tokens.css - Add the dark mode value in
.dark { }in the same file - Map it in
globals.css@theme inline { }block:--color-new-token: hsl(var(--new-token)); - 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.