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.

01Primitives
--color-neutral-900--color-red-600--color-blue-500

Primitives are Tailwind's base palette, named for how the color looks. They say nothing about where it's used.

02Semantic tokens
--primary--muted-foreground--destructive

Semantic tokens are named for their purpose and point at a primitive, sometimes at reduced opacity. --muted-foreground means "lower-emphasis text", not "neutral 500".

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>
Class
bg-primary
Semantic token
--primary
Primitive
Light--color-neutral-900
Dark--color-neutral-200

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.

One token system, two themes

Add .dark to the root element. The same semantic tokens point at dark primitives, so every component and block changes with it.

  1. 01Map each theme

    Keep light values in :root and dark values in .dark, both pointing at primitives.

  2. 02Use semantic colors

    Use tokens like bg-primary, not primitives like bg-neutral-900.

  3. 03Keep it centralized

    Avoid dark: overrides in components, so theme changes stay in one file.

Every token lives in one file you copy into your project.

Get the theme CSS ↓

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.

SwatchTokenLightDark
--backgroundwhiteneutral-950
--foregroundneutral-950neutral-50
--cardwhiteneutral-900
--card-foregroundneutral-950neutral-50
--popoverwhiteneutral-900
--popover-foregroundneutral-950neutral-50

Action

Filled actions and their interaction states. primary for the main action, secondary for lower-emphasis ones, accent for hovered, focused and selected items.

SwatchTokenLightDark
--primaryneutral-900neutral-200
--primary-foregroundneutral-50neutral-900
--secondaryneutral-100neutral-800
--secondary-foregroundneutral-900neutral-50
--accentneutral-100neutral-800
--accent-foregroundneutral-900neutral-50

Muted

Subtle fills and lower-emphasis text: descriptions, placeholders, helper text and empty states.

SwatchTokenLightDark
--mutedneutral-100neutral-800
--muted-foregroundneutral-500neutral-400

State

Errors and destructive actions. destructive has no foreground pair; use it for the text, border or icon that carries the warning.

SwatchTokenLightDark
--destructivered-600red-400

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.

SwatchTokenLightDark
--borderneutral-200white/10
--inputneutral-200white/15
--ringneutral-400neutral-500

Chart

The default data palette, chart-1 to chart-5. Values match in both themes, so a series keeps its color.

SwatchTokenLightDark
--chart-1orange-600orange-600
--chart-2teal-600teal-600
--chart-3cyan-900cyan-900
--chart-4amber-400amber-400
--chart-5amber-500amber-500

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.

SwatchTokenLightDark
--sidebarneutral-50neutral-900
--sidebar-foregroundneutral-950neutral-50
--sidebar-primaryneutral-900blue-700
--sidebar-primary-foregroundneutral-50neutral-50
--sidebar-accentneutral-100neutral-800
--sidebar-accent-foregroundneutral-900neutral-50
--sidebar-borderneutral-200white/10
--sidebar-ringneutral-400neutral-500

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);
}
PreviewTokenTailwind classValueResolved
--radius-smrounded-smcalc(var(--radius) * 0.6)6px
--radius-mdrounded-mdcalc(var(--radius) * 0.8)8px
--radius-lgrounded-lgvar(--radius)10px
--radius-xlrounded-xlcalc(var(--radius) * 1.4)14px
--radius-2xlrounded-2xlcalc(var(--radius) * 1.8)18px
--radius-3xlrounded-3xlcalc(var(--radius) * 2.2)22px
--radius-4xlrounded-4xlcalc(var(--radius) * 2.6)26px

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 .dark class on any ancestor, so toggle it on <html>.
  • Borders and outlines default to border and ring.
  • body uses background and foreground.
  • 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;
  }
}