Light, dark & density
Two orthogonal switches every Kaotypr product gets for free. They compose: a dashboard in dark + compact looks intentional, not stitched together.
Light & dark
Every color token resolves through CSS variables that have a light and a dark value. Switching is a class toggle — .dark on <html> — and next-themesdrops it before paint, so there's no flash.
How tokens are structured
In app/globals.css, light values live under :root and dark values under .dark. Both blocks reference the same token names — never invent a --color-card-dark; it's the same --card re-bound under .dark.
:root {
--background: oklch(0.99 0.005 80);
--foreground: oklch(0.18 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.18 0 0);
--primary: oklch(0.205 0.04 290);
--primary-foreground: oklch(0.98 0 0);
}
.dark {
--background: oklch(0.13 0.005 290);
--foreground: oklch(0.96 0 0);
--card: oklch(0.18 0.008 290);
--card-foreground: oklch(0.96 0 0);
--primary: oklch(0.92 0 0);
--primary-foreground: oklch(0.18 0.04 290);
}Theme-agnostic tokens
Some tokens have a single value across both modes — the system shapes them once and relies on the underlying surface to provide the correct contrast. Examples: --ring-width, --radius-*, --shadow-*, --duration-*, --ease-*, --z-index-*, --size-*. Don't put these inside .dark.
Wiring the toggle
import { ThemeProvider } from "next-themes";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class" /* toggles .dark on <html> */
defaultTheme="system" /* respect OS */
enableSystem
disableTransitionOnChange /* avoid flicker on switch */
>
{children}
</ThemeProvider>
</body>
</html>
);
}Testing dark mode
- Use
resolvedTheme(nottheme) when you need to know which mode is actually rendering.themecan be"system". - Status colors (destructive / success / warning / info) keep their hue across themes — only lightness/chroma flip. Don't override
.dark--destructiveto a different hue. - Charts:
--color-chart-*have dark variants. The brand anchor stays at series 1 in both modes. color-mix(in oklch, …)in light/dark is fine — the system uses it for status backgrounds and state layers.
Density
Density is the second axis. Toggling data-density="compact" on <html> (or any ancestor) tightens controls and gaps without changing typography or surface roles. It composes with light/dark — a compact dark dashboard reads as one design, not two.
What responds to density
--density-control-h— control heights (buttons, inputs, selects)--density-card-padding— card padding (compact cards take 75% of the comfortable padding)--density-field-gap— vertical gap between form fields
Components consuming these tokens (h-(--density-control-h), p-(--density-card-padding), gap-(--density-field-gap)) get density-aware behavior for free. Hardcoded h-10 / p-4 / gap-4 does not.
Scoping
<html lang="en" data-density="compact">
…everything inside is compact
</html>When to reach for compact
- Data-heavy product UIs (tables, dashboards, admin panels)
- Expert tools where information density matters more than reading comfort
- Side-by-side comparisons (diff views, schema editors, inspector panels)
When NOT to use compact
- Marketing pages, hero sections, top-of-funnel content
- Mobile-first surfaces (touch targets stay ≥
--size-touch-targetregardless of density) - Onboarding flows or anything where comprehension matters more than scanning
Adding density-awareness to a custom component
If you build a component that takes a height or padding, consume the density token instead of hardcoding. The component then participates in compact mode without any additional work:
function FilterChip({ children, ...props }) {
return (
<button
{...props}
className="
h-(--density-control-h) px-3
rounded-chip border-border
bg-muted text-foreground-subtle
hover:bg-state-hover
"
>
{children}
</button>
);
}