Skip to Content
UI ComponentsTheming — Dark & Light Mode

Theming — Dark & Light Mode

All three apps support light mode, dark mode, and system-preference mode. The user can switch modes at any time via the theme toggle in the sidebar footer.


Implementation Status

AppPathTheming status
Dashboardapps/dashboard[Live] — implemented April 2026
DM Portalapps/dm[Live] — implemented and verified April 2026
Manageapps/manage[Live] — implemented April 2026

Technology

ConcernSolution
Theme toggle logicnext-themes — wraps root layout, reads/writes localStorage
Dark mode CSS strategyTailwind CSS v4 class strategy — .dark class on <html>
Color tokensCSS custom properties (oklch) via Tailwind v4 @theme inline
Component stylesshadcn/ui — all components use CSS variable tokens, adapt automatically

Color Palette

All colors are defined as CSS custom properties on :root (light) and .dark (dark). Components reference these tokens, never raw Tailwind color values directly.

Semantic tokens

TokenPurposeLightDark
--backgroundPage backgroundzinc-50zinc-950
--foregroundDefault textzinc-950zinc-50
--cardCard / panel surfacewhitezinc-900
--card-foregroundText on cardszinc-950zinc-50
--popoverDropdown / tooltip backgroundwhitezinc-900
--popover-foregroundText in popoverszinc-950zinc-50
--primaryPrimary action (buttons, links, focus rings)indigo-600indigo-500
--primary-foregroundText on primary elementswhitewhite
--secondarySecondary action / surfacezinc-100zinc-800
--secondary-foregroundText on secondaryzinc-900zinc-100
--mutedMuted background (empty states, subtle bg)zinc-100zinc-800
--muted-foregroundMuted / placeholder textzinc-500zinc-400
--accentHover highlightzinc-100zinc-800
--accent-foregroundText on accentzinc-900zinc-50
--destructiveDestructive actions (delete, reject)red-600red-500
--borderBorders and dividerszinc-200zinc-800
--inputForm field borderszinc-200zinc-700
--ringFocus ringindigo-600indigo-500

The sidebar has its own token set so it can have a distinct surface color from the page background.

TokenLightDark
--sidebarwhitezinc-900
--sidebar-foregroundzinc-900zinc-100
--sidebar-primaryindigo-600indigo-500
--sidebar-primary-foregroundwhitewhite
--sidebar-accentzinc-100zinc-800
--sidebar-accent-foregroundzinc-900zinc-50
--sidebar-borderzinc-200zinc-800

Project-specific semantic colors

These are project-level tokens used for agent status, risk levels, and content type badges. They sit on top of shadcn/ui’s base palette.

TokenValueUsage
--agent-accentviolet-600 / violet-400Anything AI/agent-related (agent icon bg, agent badges, live pulse)
--successemerald-600 / emerald-400Completed status, approval badges, connected channel
--warningamber-500 / amber-400Retrying, budget warnings, expiring tokens
--infoblue-500 / blue-400Running status, informational banners

CSS Variable Definition

All variables are defined in packages/ui/src/globals.css, which every app imports.

@import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-agent-accent: var(--agent-accent); --color-success: var(--success); --color-warning: var(--warning); --color-info: var(--info); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } :root { --background: oklch(0.980 0.002 247); /* zinc-50 */ --foreground: oklch(0.141 0.005 285); /* zinc-950 */ --card: oklch(1 0 0); /* white */ --card-foreground: oklch(0.141 0.005 285); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285); --primary: oklch(0.511 0.262 276); /* indigo-600 */ --primary-foreground: oklch(1 0 0); --secondary: oklch(0.946 0.003 264); /* zinc-100 */ --secondary-foreground: oklch(0.210 0.006 285); --muted: oklch(0.946 0.003 264); --muted-foreground: oklch(0.553 0.013 285); /* zinc-500 */ --accent: oklch(0.946 0.003 264); --accent-foreground: oklch(0.210 0.006 285); --destructive: oklch(0.577 0.245 27); /* red-600 */ --border: oklch(0.898 0.005 247); /* zinc-200 */ --input: oklch(0.898 0.005 247); --ring: oklch(0.511 0.262 276); /* indigo-600 */ --radius: 0.5rem; --sidebar: oklch(1 0 0); /* white */ --sidebar-foreground: oklch(0.210 0.006 285); --sidebar-primary: oklch(0.511 0.262 276); --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.946 0.003 264); --sidebar-accent-foreground: oklch(0.210 0.006 285); --sidebar-border: oklch(0.898 0.005 247); --agent-accent: oklch(0.558 0.288 293); /* violet-600 */ --success: oklch(0.596 0.170 151); /* emerald-600 */ --warning: oklch(0.769 0.188 70); /* amber-500 */ --info: oklch(0.623 0.214 256); /* blue-500 */ } .dark { --background: oklch(0.100 0.004 285); /* zinc-950 */ --foreground: oklch(0.985 0.001 247); /* zinc-50 */ --card: oklch(0.179 0.006 285); /* zinc-900 */ --card-foreground: oklch(0.985 0.001 247); --popover: oklch(0.179 0.006 285); --popover-foreground: oklch(0.985 0.001 247); --primary: oklch(0.585 0.233 277); /* indigo-500 */ --primary-foreground: oklch(1 0 0); --secondary: oklch(0.272 0.006 286); /* zinc-800 */ --secondary-foreground: oklch(0.915 0.003 264); --muted: oklch(0.272 0.006 286); --muted-foreground: oklch(0.710 0.009 285); /* zinc-400 */ --accent: oklch(0.272 0.006 286); --accent-foreground: oklch(0.985 0.001 247); --destructive: oklch(0.637 0.237 25); /* red-500 */ --border: oklch(0.272 0.006 286); /* zinc-800 */ --input: oklch(0.329 0.007 286); /* zinc-700 */ --ring: oklch(0.585 0.233 277); /* indigo-500 */ --sidebar: oklch(0.179 0.006 285); /* zinc-900 */ --sidebar-foreground: oklch(0.915 0.003 264); --sidebar-primary: oklch(0.585 0.233 277); --sidebar-primary-foreground: oklch(1 0 0); --sidebar-accent: oklch(0.272 0.006 286); --sidebar-accent-foreground: oklch(0.985 0.001 247); --sidebar-border: oklch(0.272 0.006 286); --agent-accent: oklch(0.702 0.202 293); /* violet-400 */ --success: oklch(0.697 0.142 161); /* emerald-400 */ --warning: oklch(0.836 0.157 78); /* amber-400 */ --info: oklch(0.707 0.165 257); /* blue-400 */ }

Theme Provider Setup

Each app’s root layout wraps the entire tree with ThemeProvider from next-themes.

// app/layout.tsx import { ThemeProvider } from 'next-themes'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <body> <ThemeProvider attribute="class" // adds/removes .dark on <html> defaultTheme="system" // respects OS preference by default enableSystem disableTransitionOnChange // prevents flash when switching > {children} </ThemeProvider> </body> </html> ); }

suppressHydrationWarning is required on <html> because next-themes adds the class attribute server-side from a cookie, which may differ from the client’s initial render.


Theme Toggle Component

Placed in the topbar (right-hand action cluster, before the notification bell). Uses a three-way cycle: Light → Dark → System.

[Light ☀] → [Dark 🌙] → [System 💻] → [Light ☀] ...

Actual implementation (src/components/theme-toggle.tsx)

"use client"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Sun, Moon, Monitor } from "lucide-react"; const THEMES = ["light", "dark", "system"] as const; type Theme = (typeof THEMES)[number]; const ICONS: Record<Theme, React.ReactNode> = { light: <Sun className="w-3.5 h-3.5" />, dark: <Moon className="w-3.5 h-3.5" />, system: <Monitor className="w-3.5 h-3.5" />, }; const LABELS: Record<Theme, string> = { light: "Light", dark: "Dark", system: "System", }; export function ThemeToggle() { const { theme, setTheme } = useTheme(); const [mounted, setMounted] = useState(false); // Avoid hydration mismatch — only render after mount useEffect(() => setMounted(true), []); const current = (mounted ? theme : "system") as Theme; const cycle = () => { const idx = THEMES.indexOf(current); setTheme(THEMES[(idx + 1) % THEMES.length]); }; return ( <button onClick={cycle} aria-label={`Theme: ${LABELS[current]}. Click to switch.`} title={`Theme: ${LABELS[current]}`} className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" > {mounted ? ICONS[current] : <Monitor className="w-3.5 h-3.5" />} <span>{mounted ? LABELS[current] : "System"}</span> </button> ); }

Behaviour

StateIconLabelnext-themes value
LightSunLight"light"
DarkMoonDark"dark"
SystemMonitorSystem"system"

On first load (SSR), the button renders a neutral Monitor icon to avoid hydration mismatch — mounted is false until useEffect runs on the client.


Dark Mode for Specific Elements

Agent status pulses

Running agents have an animated pulse ring. The ring colour adapts:

light: ring-blue-500 / bg-blue-100 dark: ring-blue-400 / bg-blue-900/30

Terminal / live output pane

The streaming output pane (activity detail, agent run detail) always uses a dark surface regardless of theme, because it resembles a terminal:

bg-zinc-950 text-emerald-400 font-mono

This is achieved by using hard-coded classes (not CSS variables) on this specific component.

Charts (Recharts)

Recharts components receive colours via props, not CSS. A useTheme() hook reads the current theme and passes the appropriate colour set:

const { resolvedTheme } = useTheme(); const chartColors = resolvedTheme === 'dark' ? darkChartColors : lightChartColors;

Light chart colors: indigo-500, violet-500, emerald-500, amber-500, blue-500 Dark chart colors: indigo-400, violet-400, emerald-400, amber-400, blue-400

Markdown-rendered content

Deliverable content (blog posts, approval review) uses react-markdown with a custom renderer. Prose styles come from @tailwindcss/typography. Both light and dark prose classes must be applied:

className="prose prose-zinc dark:prose-invert max-w-none"

No-Flash Strategy

To avoid a flash of unstyled content (FOUC) when the page loads in dark mode:

  1. next-themes injects a small inline script in <head> that reads localStorage and sets the class on <html> before the page renders.
  2. suppressHydrationWarning on <html> suppresses the React mismatch warning that this causes.
  3. disableTransitionOnChange on ThemeProvider prevents a jarring CSS transition when the class flips.
  4. CSS transitions for color changes use transition-colors duration-200 only on interactive elements (buttons, links), not on background/text — this keeps switching snappy.

Accessibility

  • Color contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large text) in both modes.
  • The theme toggle is keyboard-accessible and announces its state via aria-label.
  • Users who prefer prefers-reduced-motion still see the theme toggle work, but animated pulses and transitions are disabled via @media (prefers-reduced-motion: reduce).
  • Never rely solely on color to convey meaning — status badges always include text, not just a colored dot.

© 2026 Leadmetrics — Internal use only