Your Theme

Tailwind CSS Fundamentals

Chapter 9 · Your Theme: @theme, Variables and Fonts

Every class you have used so far reads from a theme: p-4 from --spacing, bg-blue-500 from --color-blue-500, rounded-lg from --radius-lg. In Tailwind v3 you changed the theme in a JavaScript file, tailwind.config.js. In v4 you change it in your CSS, with a directive called @theme. This chapter shows how to add your own colours, fonts, sizes and animations, how to remove or replace the defaults, and the one option (inline) that decides whether theming by context works.

Every example here was run
Every theme file in this chapter was compiled with Tailwind CSS 4.3.3 and the output read, and the behaviour was checked in headless Chrome (computed styles, the fonts the browser loaded, animations running, and the page resized to test breakpoints). The official @theme reference was used for the concepts, and each claim below was tested rather than assumed. One font file (a Windows system font) was used only for the local test, not published.

What @theme Does

A theme variable is a CSS variable written inside @theme. It does two jobs at once: it becomes a normal CSS variable on the page, and its name (the part before the first dash after --) tells Tailwind which classes to create. That first part is the namespace:

NamespaceCreates classes such asExample variable
--color-*bg-mint-500, text-mint-500, border-mint-500, ring-, fill- …--color-mint-500: oklch(0.72 0.11 178)
--font-*font-display (font family)--font-display: "Georgia", serif
--text-*text-huge (font size), with --text-huge--line-height--text-huge: 5rem
--spacing, --spacing-*every padding, margin, gap, width and height class; p-gutter, gap-gutter, w-gutter--spacing-gutter: 1.5rem
--radius-*rounded-pill--radius-pill: 9999px
--shadow-*shadow-glow--shadow-glow: 0 0 20px rgb(34 211 238 / 0.5)
--ease-*ease-snappy--ease-snappy: cubic-bezier(0.2, 0.9, 0.3, 1)
--animate-*animate-wiggle--animate-wiggle: wiggle 1s ease-in-out infinite
--breakpoint-*screen variants xs:, 3xl: (chapter 7)--breakpoint-xs: 30rem
--container-*, --font-weight-*, --blur-*, …@md: and max-w-md; font-bold; blur-smthe other default namespaces

I put one variable in each of those namespaces into a theme file, used a class for each, and compiled it. Each class produced a rule that reads its variable (shadows are the one exception, see the note after the list):

@import "tailwindcss"; @theme { --color-mint-500: oklch(0.72 0.11 178); --font-display: "Georgia", serif; --radius-pill: 9999px; --shadow-glow: 0 0 20px rgb(34 211 238 / 0.5); --text-huge: 5rem; --text-huge--line-height: 1; --ease-snappy: cubic-bezier(0.2, 0.9, 0.3, 1); --spacing-gutter: 1.5rem; --animate-wiggle: wiggle 1s ease-in-out infinite; @keyframes wiggle { 0%, 100% { transform: rotate(-3deg); } 50% { transform: rotate(3deg); } } --breakpoint-xs: 30rem; --breakpoint-3xl: 120rem; }
bg-mint-500 background-color: var(--color-mint-500) font-display font-family: var(--font-display) rounded-pill border-radius: var(--radius-pill) text-huge font-size: var(--text-huge); line-height: var(--tw-leading, var(--text-huge--line-height)) ease-snappy transition-timing-function: var(--ease-snappy) p-gutter padding: var(--spacing-gutter) (also gap-gutter, w-gutter) animate-wiggle animation: var(--animate-wiggle)

A detail: shadow-glow did not read a variable. The generated rule has the shadow written into it (--tw-shadow: 0 0 20px var(--tw-shadow-color, rgb(34 211 238 / 0.5))), because shadows are built from layers that combine with rings (chapter 3). Use @theme rather than :root when you want classes: a plain :root { --brand: … } variable is just a variable and creates nothing.

Extending, Overriding and Removing

The three things you can do to the defaults, all measured:

  • Extend: use a new name. --color-mint-500 and the others above sit alongside the defaults; every default class still works.
  • Override: reuse an existing name with a new value. With @theme { --spacing: 0.5rem; }, p-4 measured 32px (it is 4 × 0.5rem; it was 16px) and w-64 measured 512px. The whole spacing scale doubled, because every size class multiplies that one variable.
  • Remove: set a whole namespace to initial. After --color-*: initial; --color-white: #fff; --color-midnight: #121063; I checked the compiled classes: bg-white, bg-midnight and text-midnight exist, and bg-blue-500 and text-red-500 were not generated (an element with text-red-500 kept the plain black text). This is how you replace Tailwind's palette with your brand's, so a stray bg-blue-500 stops working instead of quietly slipping through.
--*: initial removes everything, including the spacing scale
@theme { --*: initial; } clears every default variable. With --*: initial; --color-midnight: #121063; the class bg-midnight still compiled, but p-4 and w-64 produced no rules at all, because --spacing was gone too. It is meant for building a design system from nothing; if you only want your own colours, remove just --color-*.

Theme Variables Are CSS Variables, and Only Used Ones Are Written

Because they are CSS variables, you can use them anywhere: in your own CSS (color: var(--color-mint-500)), in arbitrary values (rounded-[calc(var(--radius-lg)-1px)]), in inline styles, and in JavaScript with getComputedStyle(document.documentElement).getPropertyValue('--color-mint-500'). One catch: Tailwind only writes out the theme variables that something uses. In the theme above, --color-mint-500 was in the compiled file, while --color-unused-500: #123456, which no class used, was not. Usually that is what you want (a small file), but not when JavaScript or plain CSS needs a variable that no class mentions. Mark such variables with static:

@theme static { --color-primary: var(--color-red-500); }
a page where no class uses "primary": with @theme static getPropertyValue("--color-primary") oklch(63.7% 0.237 25.331) with plain @theme getPropertyValue("--color-primary") "" (not emitted)

Two more rules, both checked. Theme variables must be at the top level. The documentation says they cannot be nested under a selector or a media query. I put @theme { --color-nested: #ff0000; } inside @media (min-width: 40rem): Tailwind raised no error, and it emitted --color-nested unconditionally on :root, so the media condition was silently ignored. If you want a value that changes with screen size, use a normal variable in a normal media query. And @theme can live in another file: a brand.css containing only the @theme block, pulled in with @import "./brand.css"; after @import "tailwindcss";, produced bg-brand-500 as expected.

@theme inline: Theming by Context

This is the option that most often goes wrong, so I measured it. Suppose you define the brand colour as an ordinary variable that changes in part of the page, and point a theme variable at it:

@import "tailwindcss"; @theme { --color-primary: var(--brand); } /* A: plain */ /* @theme inline { --color-primary: var(--brand); } B: inline */ :root { --brand: oklch(0.62 0.21 260); } /* blue */ [data-theme=green] { --brand: oklch(0.72 0.22 150); } <div class="bg-primary">…</div> <div data-theme="green"><div class="bg-primary">…</div></div>
outside the island inside data-theme="green" A: plain @theme blue blue (the theme did not apply) B: @theme inline blue green

With the plain form the theme variable --color-primary is declared once, on :root, and its value is worked out there (the blue --brand) and then inherited as a finished value, so changing --brand lower down does nothing to it. With inline the utility skips that intermediate variable. I compared the two compiled rules: the plain form generates .bg-primary { background-color: var(--color-primary); }, the inline form generates .bg-primary { background-color: var(--brand); }, and that var(--brand) is looked up on each element, so the green island gets green. The rule: use @theme inline whenever a theme value is a var() of something that changes by context: a data-theme attribute, a dark-mode override, or a font variable supplied by a framework. It is also what lets you build the multi-brand setups in Tailwind CSS Intermediate/Advanced 2.

Fonts

A font family is a theme variable like any other, in the --font-* namespace. Loading the font is separate: that is plain CSS, written outside @theme. For a font file you have:

@import "tailwindcss"; @font-face { font-family: "MyDisplay"; src: url("./fonts/mydisplay.woff2"); } @theme { --font-display: "MyDisplay", serif; }
<span class="font-sans"> width 312px <span class="font-display"> width 322px fonts the browser loaded: ["MyDisplay"] same page, but --font-display: "NoSuchFontAnywhere", serif <span class="font-display"> width 302px fonts loaded: [] (the plain serif fallback)

I used a system font file as MyDisplay to test this. With the @font-face present the browser loaded the font and the text came out at a different width than the default sans; with a name that matches nothing it silently used the generic serif from the fallback list (a third width again). So a misspelled font name does not fail, it falls back, and the quickest check is the list of loaded fonts (Array.from(document.fonts) with a status of loaded), or the "Rendered Fonts" list at the bottom of DevTools' Computed panel. (document.fonts.check() returned true in both cases, so do not rely on it.)

To change the font of the whole page, override --font-sans: Preflight (chapter 1) sets the page font to --default-font-family, which is var(--font-sans). With @theme { --font-sans: "Inter", sans-serif; } a paragraph with no classes at all had the computed font family Inter, sans-serif, while font-mono was unchanged. For a hosted font such as Google Fonts, the stylesheet import has to be the first thing in the file: @import url("https://fonts.googleapis.com/…"); followed by @import "tailwindcss"; compiled with the font import kept first (I compiled this; I did not fetch the font). When a framework's font loader gives you a CSS variable, that is the inline case above: @theme inline { --font-sans: var(--font-inter); }.

Your Own Animations

Define the animation as a variable and put its @keyframes inside the same @theme block (as in the first example). Tailwind then writes both the animate-wiggle class and the keyframes. In Chrome the element had animation-name: wiggle, one running animation (getAnimations().length was 1), and its transform was a different rotation 350ms later: matrix(0.9988, 0.0486, -0.0486, 0.9988, 0, 0) then matrix(0.9989, -0.0465, 0.0465, 0.9989, 0, 0), swinging from about +2.8° to −2.7°. Respect the visitor's reduced-motion setting (chapter 8) with motion-safe:animate-wiggle. The other examples in the first table work the same way: --ease-snappy becomes ease-snappy for transitions, --radius-pill becomes rounded-pill, and a named spacing such as --spacing-gutter is available to padding, margin, gap and size classes alike.

Your Own Breakpoints

The --breakpoint-* namespace controls the screen variants. I added --breakpoint-xs: 30rem and --breakpoint-3xl: 120rem (next to the five defaults) and resized the viewport on an element with bg-gray-300 xs:bg-red-500 3xl:bg-green-500:

Viewport widthColourWhy
479pxgraybelow xs (30rem = 480px)
480pxredxs: applies from 480
1919pxredxs: still applies; 3xl (120rem = 1920px) has not started
1920pxgreen3xl: applies and, being larger, overrides xs:

Tailwind orders the generated media queries by size, not by the order you declared the variables, so a new breakpoint slots into the right place. Changing an existing one is the same: --breakpoint-sm: 30rem moves sm: (the documentation shows this; I tested adding names, not changing sm).

Hands-On Exercises

Exercise 1

Replace Tailwind's palette with a brand palette: keep only white, black and three brand shades (50, 500 and 900), add a font-display family loaded with @font-face and a rounded-pill radius. Build a pill button using them and show from the compiled output and the browser that the brand classes work and that bg-blue-500 and text-red-500 no longer do.

📄 View solution
Exercise 2

Build a two-theme page: :root blue and [data-theme=green] green, with a bg-primary class. Do it first with a plain @theme and show that the green section stays blue, then fix it with the one-word change and show the green section turns green.

📄 View solution
Exercise 3

Add a wiggle animation, an xs breakpoint at 30rem, and a --color-primary that JavaScript must be able to read even though no class uses it. Prove the animation is running, show the breakpoint boundary at 479 and 480px, and show the difference between @theme and @theme static for the unused variable.

📄 View solution

Chapter 9 Quick Reference

  • @theme { --namespace-name: value; } makes a CSS variable and the classes for its namespace: --color-*, --font-*, --text-* (+ --text-x--line-height), --spacing/--spacing-*, --radius-*, --shadow-*, --ease-*, --animate-*, --breakpoint-*, --container-*. Use :root for variables that should NOT create classes
  • Extend with a new name; override with an existing name (--spacing: 0.5rem: p-4 = 32px); remove a namespace with --color-*: initial (bg-blue-500 stops existing); --*: initial removes everything, including spacing
  • Only used theme variables are written to the output; @theme static forces them (JavaScript can then read them); @theme must be top level (inside @media the condition is silently ignored); it can live in an imported file
  • @theme inline when a theme value is var(--something) that changes by context (data-theme, dark overrides, framework font variables); plain @theme resolves it once on :root
  • Fonts: @font-face outside @theme, then --font-display: "Name", serif; a wrong name silently falls back; override --font-sans to change the page font; @import url() before @import "tailwindcss"
  • Animations: --animate-wiggle: wiggle 1s infinite plus @keyframes wiggle {…} inside @theme; add motion-safe:
  • Breakpoints: --breakpoint-xs: 30rem gives xs: (from 480px); generated in size order
Coming next
Tailwind CSS Fundamentals 10 goes beyond the generated utilities: arbitrary values and variants, @utility and @variant, @layer, @apply and when to write ordinary CSS instead.