Theming
How Gaudi UI uses CSS variables for color, radius and font, and how to change them to theme your app.
Gaudi UI is themed with CSS variables. Instead of a palette color like bg-neutral-900, a
component uses bg-primary, which sets its background to the --primary CSS variable. All of
these variables are defined in one stylesheet, so you change the look of your app there instead of
rewriting classes.
How the system works
Every color is a CSS variable, and the variables come in two layers.
The theme file defines each semantic token twice, once per theme, and then hands it to Tailwind:
styles/theme.css
:root {
--primary: var(--color-neutral-900); /* the semantic token points at a primitive */
}
.dark {
--primary: var(--color-neutral-200); /* dark mode points it at another one */
}
@theme inline {
--color-primary: var(--primary); /* enables every *-primary color utility */
}<button className="bg-primary">Save changes</button>bg-primary resolves to --primary, which resolves to --color-neutral-900 in light mode and
--color-neutral-200 in dark.
Components and blocks only use semantic tokens. That one step of indirection is what keeps the
system easy to change: point a token at a different primitive and everything that uses it updates,
with no component files edited. It's also how dark mode works. The .dark block points the same
semantic tokens at other primitives, so nothing needs a dark: prefix.
Token convention
Semantic tokens come in background and foreground pairs. The base token sets the surface color, and
the -foreground token sets the color of the text and icons that sit on that surface.
The background suffix is left off the surface token. For example, primary pairs with
primary-foreground.
Given the following CSS variables:
--primary: var(--color-neutral-900);
--primary-foreground: var(--color-neutral-50);The background color of the following button will be var(--primary) and its text color will be
var(--primary-foreground).
<button className="bg-primary text-primary-foreground">Save changes</button>Use the two halves of a pair together, and the text stays readable on its surface in both themes.
background and foreground are the one pair named differently: they are the default page color
and text.
Semantic tokens
Every token, grouped by purpose, with the primitive it points at in each theme.
Surface
Page and container fills. background is the page, card a raised panel, popover a floating
layer such as a menu or tooltip.
Action
Filled actions and their interaction states. primary for the main action, secondary for
lower-emphasis ones, accent for hovered, focused and selected items.
Muted
Subtle fills and lower-emphasis text: descriptions, placeholders, helper text and empty states.
State
Errors and destructive actions. destructive has no foreground pair; use it for the text,
border or icon that carries the warning.
Border and focus
Hairlines and focus rings. border for dividers and separators, input for form controls,
ring for focus outlines. In dark mode border and input are translucent white, so they read on
any surface; white/10 means white at 10% opacity.
Chart
The default data palette, chart-1 to chart-5. Values match in both themes, so a series keeps
its color.
Sidebar
Its own set, so navigation can be tinted apart from the page. Each token mirrors its page
counterpart; in dark mode sidebar-primary turns blue to mark the active item.
Radius
--radius sets the base corner radius in :root. @theme inline builds the rest of the scale
from it:
styles/theme.css
:root {
--radius: 0.625rem;
}
@theme inline {
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); /* base */
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}Font
--font-sans sets the font for the page and the font-sans utility. The theme uses
Inter, the typeface the type scale is
set in.
styles/theme.css
@theme inline {
--font-sans: 'Inter Variable', Inter, ui-sans-serif, system-ui, sans-serif;
}gaudi-ui init installs the font; Installation
shows the import.
Adding new tokens
To add a new token, point it at a primitive under :root and .dark, then expose it to Tailwind
with @theme inline.
styles/theme.css
:root {
--success: var(--color-green-600);
--success-foreground: var(--color-white);
}
.dark {
--success: var(--color-green-500);
--success-foreground: var(--color-green-950);
}
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}You can now use bg-success and text-success-foreground in your components.
<div className="bg-success text-success-foreground">Saved</div>Theme CSS
The complete file. gaudi-ui init writes it to styles/theme.css, and
Installation shows how to import it.
- Dark mode follows a
.darkclass on any ancestor, so toggle it on<html>. - Borders and outlines default to
borderandring. bodyusesbackgroundandforeground.- Buttons get a pointer cursor. Tailwind v4 leaves them on the default cursor.
styles/theme.css
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: 'Inter Variable', Inter, ui-sans-serif, system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: var(--color-white);
--foreground: var(--color-neutral-950);
--card: var(--color-white);
--card-foreground: var(--color-neutral-950);
--popover: var(--color-white);
--popover-foreground: var(--color-neutral-950);
--primary: var(--color-neutral-900);
--primary-foreground: var(--color-neutral-50);
--secondary: var(--color-neutral-100);
--secondary-foreground: var(--color-neutral-900);
--muted: var(--color-neutral-100);
--muted-foreground: var(--color-neutral-500);
--accent: var(--color-neutral-100);
--accent-foreground: var(--color-neutral-900);
--destructive: var(--color-red-600);
--border: var(--color-neutral-200);
--input: var(--color-neutral-200);
--ring: var(--color-neutral-400);
--chart-1: var(--color-orange-600);
--chart-2: var(--color-teal-600);
--chart-3: var(--color-cyan-900);
--chart-4: var(--color-amber-400);
--chart-5: var(--color-amber-500);
--radius: 0.625rem;
--sidebar: var(--color-neutral-50);
--sidebar-foreground: var(--color-neutral-950);
--sidebar-primary: var(--color-neutral-900);
--sidebar-primary-foreground: var(--color-neutral-50);
--sidebar-accent: var(--color-neutral-100);
--sidebar-accent-foreground: var(--color-neutral-900);
--sidebar-border: var(--color-neutral-200);
--sidebar-ring: var(--color-neutral-400);
}
.dark {
--background: var(--color-neutral-950);
--foreground: var(--color-neutral-50);
--card: var(--color-neutral-900);
--card-foreground: var(--color-neutral-50);
--popover: var(--color-neutral-900);
--popover-foreground: var(--color-neutral-50);
--primary: var(--color-neutral-200);
--primary-foreground: var(--color-neutral-900);
--secondary: var(--color-neutral-800);
--secondary-foreground: var(--color-neutral-50);
--muted: var(--color-neutral-800);
--muted-foreground: var(--color-neutral-400);
--accent: var(--color-neutral-800);
--accent-foreground: var(--color-neutral-50);
--destructive: var(--color-red-400);
--border: color-mix(in oklab, var(--color-white) 10%, transparent);
--input: color-mix(in oklab, var(--color-white) 15%, transparent);
--ring: var(--color-neutral-500);
--chart-1: var(--color-orange-600);
--chart-2: var(--color-teal-600);
--chart-3: var(--color-cyan-900);
--chart-4: var(--color-amber-400);
--chart-5: var(--color-amber-500);
--sidebar: var(--color-neutral-900);
--sidebar-foreground: var(--color-neutral-50);
--sidebar-primary: var(--color-blue-700);
--sidebar-primary-foreground: var(--color-neutral-50);
--sidebar-accent: var(--color-neutral-800);
--sidebar-accent-foreground: var(--color-neutral-50);
--sidebar-border: color-mix(in oklab, var(--color-white) 10%, transparent);
--sidebar-ring: var(--color-neutral-500);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
}
}