referencing Tailwind CSS tokens in custom CSS
As soon as custom CSS is written outside of utility classes, the same question keeps coming up: how do you reach the values from @theme without maintaining them twice? The theme() function in Tailwind CSS v4 answers exactly that, and it works differently than one might expect from CSS custom properties.
Table of Contents
- 1. What the theme() function actually solves
- 2. Syntax and basic rules of the theme() function
- 3. theme() versus var(): the key difference
- 4. theme() in media queries and container queries
- 5. theme() in calc() and arithmetic expressions
- 6. theme() in custom plugins and @utility definitions
- 7. Fallback values and error handling
- 8. Migrating from the JavaScript theme() function in v3
- 9. theme() and var() compared directly
- 10. Summary
- 11. FAQ
1. What the theme() function actually solves
Tailwind utilities cover the vast majority of styling, but every real project eventually needs handwritten CSS: a complex grid definition, a media query block, a plugin that generates its own utilities. This is exactly where the question arises how to reach the design tokens defined in @theme, without hard coding colors, spacing or breakpoints a second time. The theme() function is the answer Tailwind CSS v4 provides for exactly this problem.
The core of the theme() function is refreshingly simple: you pass the name of a token, and the function resolves it to the matching value. theme(--color-brand-500) returns the same color value that also sits behind the utility class bg-brand-500. This keeps a single source of truth for design values intact, even when a team maintains utility classes and handwritten CSS side by side. Without the theme() function, every developer would copy color values out of the token file, which leads to inconsistencies over time as soon as a token changes and the copied values are not kept in sync.
2. Syntax and basic rules of the theme() function
The syntax of the theme() function in Tailwind CSS v4 changed fundamentally compared to v3. In v3, theme('colors.blue.500') was used with dot notation and quotes, because the function was resolved at build time against the JavaScript configuration object. In v4, the theme() function uses the same CSS custom property name as defined in @theme itself: theme(--color-blue-500), without quotes, with dash notation instead of dot notation. This reflects that Tailwind v4 relies fully on CSS custom properties internally, instead of maintaining a separate JavaScript configuration.
An important detail: the theme() function can be used anywhere valid CSS is expected, so inside declarations, but also inside selector conditions such as media query expressions, which does not work with plain var(). This ability to function in contexts that do not accept runtime values fundamentally distinguishes the theme() function from a pure CSS custom property reference and makes it the right tool for certain, very specific use cases.
/* main.css — @theme defines the tokens, theme() references them elsewhere */
@import "tailwindcss";
@theme {
--color-brand-500: #0ea5e9;
--spacing-gutter: 1.5rem;
--breakpoint-panel: 64rem;
--radius-card: 0.75rem;
}
/* Handwritten CSS referencing the same tokens via theme() */
.custom-hero-shape {
clip-path: polygon(0 0, 100% 0, 100% calc(100% - theme(--spacing-gutter)), 0 100%);
border-radius: theme(--radius-card);
}
/* Utility classes generated from the same tokens, no duplication */
.bg-brand-500 { background-color: var(--color-brand-500); }
3. theme() versus var(): the key difference
Probably the most important conceptual point when using the theme() function: it is statically resolved to a concrete value by Tailwind's CSS compiler at build time. var(--color-brand-500), on the other hand, remains a runtime reference that the browser re-evaluates on every repaint and that reacts to cascading overrides. Whoever uses theme() gets a fixed value baked in at build time that no longer changes, even if someone later overrides the underlying custom property at runtime.
This property of the theme() function is sometimes an advantage, sometimes a disadvantage, depending on the use case. In contexts that should react at runtime, such as dark mode switching or multi brand theming through data-brand attributes, var() is the right choice because the value is meant to change dynamically. In contexts that need pure CSS arithmetic at build time, such as media query conditions or complex clip-path calculations, the theme() function is the right choice, because no runtime switching happens there anyway and static resolution is even more performant.
4. theme() in media queries and container queries
The practically most important use case of the theme() function is its use inside media query and container query conditions. @media (min-width: var(--breakpoint-panel)) does not work reliably in many browsers, because custom properties in media query conditions have historically not been resolvable at runtime in that position. The theme() function elegantly sidesteps this problem, because it already inserts a concrete pixel or rem value at build time, so the browser sees a completely normal, static media query condition.
The same principle applies to container queries, which are increasingly used in modern Tailwind v4 projects instead of classic media queries. A custom breakpoint token, defined in @theme as --breakpoint-panel, can be inserted into an @container condition via theme(--breakpoint-panel), without maintaining the number twice. When the panel breakpoint is adjusted later, a single change in @theme is enough, and every theme() function reference in the project follows automatically on the next build.
/* main.css — theme() inside media queries and container queries */
@import "tailwindcss";
@theme {
--breakpoint-panel: 64rem;
--breakpoint-sidebar: 20rem;
}
/* Media query condition resolved at build time, not a runtime var() lookup */
@media (min-width: theme(--breakpoint-panel)) {
.dashboard-grid {
grid-template-columns: theme(--breakpoint-sidebar) 1fr;
}
}
/* Same pattern for container queries in a component-driven layout */
.data-table-wrapper {
container-type: inline-size;
}
@container (min-width: theme(--breakpoint-sidebar)) {
.data-table-wrapper table {
font-size: 0.9375rem;
}
}
5. theme() in calc() and arithmetic expressions
Another strong use case for the theme() function is combining it with calc() for values that derive from a token but do not exactly match an existing one. Instead of defining a whole new token like --spacing-gutter-half just to get half of an existing spacing value, the value is computed directly in CSS: calc(theme(--spacing-gutter) / 2). This keeps the token list lean while derived values stay consistent with their original source.
This pattern is especially useful for sticky headers, offset calculations and scroll margin values derived from the height of another element. A sticky header with the height of the token --spacing-header needs a scroll-margin-top for anchor links that is slightly larger than the header height itself, so the target section does not disappear directly beneath the header. With the theme() function, the formula is scroll-margin-top: calc(theme(--spacing-header) + 1rem), which automatically scales when the header height is adjusted later, without manually chasing the offset value.
6. theme() in custom plugins and @utility definitions
When writing custom utilities with the @utility directive in Tailwind CSS v4, the theme() function is the tool of choice for pulling consistent values from the existing token system, instead of introducing new, hard coded values in the utility definition. A custom utility for a brand specific shading, for example, reaches for theme(--color-brand-900), the same token that regular utility classes like bg-brand-900 also use, instead of repeating the hex value a second time in plugin code.
This consistency pays off especially in design systems with many custom utilities, as commonly emerge in larger Hyvä theme projects. A @utility that produces a special shadow with a brand specific tint for product cards should never introduce its own color values, but should always reach for the central token source through the theme() function. When the brand color is changed later, every utility built on top of it follows automatically, without plugin code needing to be searched and adjusted.
/* main.css — custom @utility referencing tokens through theme() */
@import "tailwindcss";
@theme {
--color-brand-900: #0c4a6e;
--shadow-brand-card: 0 10px 25px -5px rgba(12, 74, 110, 0.35);
}
@utility shadow-brand-card {
box-shadow: theme(--shadow-brand-card);
border: 1px solid color-mix(in srgb, theme(--color-brand-900) 20%, transparent);
}
@utility text-brand-gradient {
background: linear-gradient(135deg, theme(--color-brand-900), theme(--color-brand-500));
background-clip: text;
color: transparent;
}
7. Fallback values and error handling
A token referenced via the theme() function that does not exist in the current theme causes a hard build error, not a silently ignored value. This clearly distinguishes the theme() function from var(--foo, blue), where a fallback value after the comma applies if the custom property is not set at runtime. This strictness is intentional: a typo in a token name should be caught immediately at build time, not discovered later as a visual bug in production.
Anyone who still needs a fallback mechanism, for example for optional theme extensions, combines the theme() function with a sensible default value defined in the token itself, rather than hoping for a fallback syntax on the function. The token --color-accent-optional is therefore always defined with a sensible default in @theme, even if it is overridden in most projects. That way the theme() function reference in the CSS always stays valid, regardless of whether a particular theme extension is active in the current project.
8. Migrating from the JavaScript theme() function in v3
Projects migrating from Tailwind CSS v3 to v4 inevitably run into JavaScript code that used the old theme() function from tailwind.config.js, for example in plugin definitions or in resolveConfig() calls. This JavaScript variant of the function no longer exists in the same form in v4, because there simply is no central JavaScript configuration left to pull values from. Migration means replacing every JavaScript theme() call with an equivalent CSS theme() function or with direct access to the generated CSS custom properties.
The biggest stumbling block in this migration is code that wanted to access theme values at runtime in the browser, for example a chart library that needed Tailwind colors inside canvas drawings. Since the new theme() function is resolved exclusively at CSS build time and does not exist in JavaScript, this use case must instead access the generated CSS custom properties in the browser through getComputedStyle(). That is not a downside, but a decoupling that cleanly separates build time values from runtime values, something a single theme() function serving both purposes in v3 repeatedly caused confusion about.
9. theme() and var() compared directly
The decision between the theme() function and a direct var() reference depends on the specific use case. The following table summarizes when which tool is the right choice.
| Context | theme() function | var() |
|---|---|---|
| Resolution time | Build time, static value | Runtime, cascading |
| Media query conditions | Works reliably | Not reliably supported |
| Dynamic theme switching | Not suitable, value is fixed | Ideal, reacts to overrides |
| Typo in token name | Hard build error | Silent fallback or empty value |
| Access from JavaScript | Not possible | Possible via getComputedStyle() |
In practice, most Tailwind v4 projects use both tools side by side, depending on whether a given context needs a value fixed at build time or a value that changes at runtime. The theme() function is not a replacement for var(), but a complement for exactly the cases where CSS expects a static condition, while regular custom property references keep running wherever dynamics are needed.