Sass and @theme: Build-Time and Runtime Tokens
Sass still compiles design JSON. Tailwind v4 and custom properties own the browser.
The migration ticket said delete Sass. Three sprints later, Sass was still compiling token JSON into CSS custom properties, Tailwind v4’s @theme block was consuming those same values, and a junior developer had reintroduced @import 'colors' because Stack Overflow said so. Nothing was wrong with Sass in that pipeline. It was doing the job CSS still cannot do at runtime: ingesting design-token exports, validating structure, and emitting multiple theme bundles from one source tree. What was wrong was the absence of rules about where Sass ends and where the browser begins.
Sass was the answer when CSS could not hold variables at runtime. CSS custom properties fixed runtime theming years ago. Sass remains useful at the boundary where tokens enter the repository, not as the source of truth in the DOM, but as the compiler that shapes what the DOM receives. Confusion between the two causes dark mode bugs that only appear in production builds because components read compiled hex while the demo page read custom properties correctly in development.
This article explains the two-layer model, the module system migration from @import to @use, coexistence patterns with Tailwind v4, and where Sass still earns its keep versus where custom properties must own the cascade.
Build-time compilation versus runtime cascade
Think in two layers. Build-time Sass ingests tokens, computes ramps, generates files, and fans out white-label themes. Runtime CSS custom properties plus Tailwind @theme are what components and utilities read in the browser. Sass variables like $primary are compile-time constants. They disappear after compilation. Custom properties like --primary survive in output CSS and respond to media queries, class toggles, and user preferences.
The Sass module system replaced @import for single evaluation, namespacing, and deprecation of global pollution. New code should use @use exclusively. @import still compiles today but emits warnings and will leave the language. Namespace partials prevent the $primary collision that killed large @import codebases. Forward selectively when you need a public API surface in a tokens index file.
Design systems increasingly store tokens in JSON, often exports compliant with the Design Tokens Format Module from Figma, Style Dictionary, or Tokens Studio. Sass sits naturally at that boundary when teams have existing SCSS entry points, legacy component libraries, or white-label generators that iterate brand configs at compile time. Style Dictionary can skip Sass entirely and emit CSS directly. The decision is pipeline economics, not ideology.
@use 'sass:color';
@use 'tokens/colors' as tokens;
:root {
--primary: #{tokens.$primary};
--primary-hover: #{tokens.hover(tokens.$primary)};
--text-primary: #{tokens.$text-primary};
--surface-default: #{tokens.$surface-default};
}
Browsers read --primary. Dark mode overrides :root or [data-theme="dark"] without recompiling Sass, provided components use var(--primary), not hard-coded hex copied from a style guide PDF.
When the production build diverged from the demo
A retail platform maintained twelve white-label brands from one repository. Sass iterated $brand-configs at compile time, emitting [data-brand="…"] blocks with accent and surface values. The architecture was legitimate Sass territory. Dark mode, added a year later, was implemented as a second compiled CSS file per brand because someone on the team believed Sass could not theme at runtime. The result was twenty-four stylesheets and a deployment matrix that confused CDN caching.
The demo environment loaded only the default brand plus a manual dark override using custom properties on html. It looked correct in stakeholder reviews. Production loaded brand-acme-light.css or brand-acme-dark.css based on server-side session flags. Components authored against var(--accent) worked in the demo. Components that had been copy-pasted from an older product still referenced $primary through a shim that compiled to a single hex per build artifact. When Acme’s accent changed, light mode updated. Dark mode accent stayed stale until someone remembered to rebuild the dark bundle for each tenant.
The incident that forced remediation was a contract renewal with a tenant who toggled dark mode and saw neon cobalt focus rings that had been tuned for light surfaces three years earlier. Engineering traced the ring color to a dark bundle that had not been regenerated after the token JSON update because the CI job only rebuilt light bundles on token changes. The bug was not Sass itself. It was using compile-time fan-out for a problem runtime custom properties already solved.
Stakeholders had approved the dual-bundle approach because it felt explicit: light CSS and dark CSS as separate artifacts you could reason about. In practice, separate artifacts doubled cache keys, doubled deployment mistakes, and doubled the chance that a token update reached only one bundle. The demo environment masked the problem because it loaded a hybrid hand-tuned file a senior engineer maintained for sales calls, not the production artifacts tenants received. Sales demos and production parity diverged quietly for two release cycles until a tenant with an accessibility reviewer filed a formal non-conformance letter.
The consolidated architecture kept Sass for brand fan-out because twelve retail tenants were a compile-time problem. Each [data-brand] block received accent, surface, and on-accent text values emitted from the same JSON importers already used for light tokens. Dark mode nested inside each brand block as custom property overrides on data-theme, not as a second file. CI validated that every brand block included paired dark values for text-muted and focus-ring before merge. The accessibility packet attached the contrast matrix per brand, generated from the same JSON, so enterprise reviewers saw evidence without requesting a custom screenshot tour.
The fix consolidated to one CSS file per brand, not two per brand. Sass still emitted brand blocks at build time for the twelve tenants. Dark mode became custom property overrides inside each brand block, toggled by data-theme on the document root without a second artifact. Components were linted to ban $ color variables in output CSS. The Sass migrator ran on a branch to replace remaining @import with @use over two weeks.
White-label fan-out remained in Sass because CMS set data-brand at runtime and twelve distinct accent values were business requirements, not user preferences. Dark mode became runtime because it was a user preference that should not require recompilation. Documenting that distinction on the architecture wiki prevented a third sprint of delete Sass tickets that ignored legitimate compile-time jobs.
Patterns that coexist without rotting
Pattern A is Sass emits and CSS consumes. Sass never appears in component files. Components only see var(--*). Pattern B is Sass wraps CSS for ergonomics: a map of semantic roles generates dozens of custom property declarations in one loop. Pattern C is dual naming during migration, legacy $blue-600 beside new --accent, acceptable only with a lint rule blocking new dollar-prefixed color variables in component partials and a deletion date on the calendar.
@use 'sass:map';
$semantic: (
'text-primary': #0f172a,
'text-secondary': #64748b,
'surface': #f8fafc,
'accent': #2563eb,
);
:root {
@each $key, $val in $semantic {
--#{$key}: #{$val};
}
}
When Sass still wins at build time, the list is concrete. White-label builds generating ten theme CSS files from one source tree where $brand-primary changes per customer. Transforming token JSON into SCSS maps with validation that fails on missing required keys. Legacy codebases with hundreds of SCSS partials not ready for deletion. Computing ramps with color.mix or perceptual libraries before all target browsers supported color-mix(). When Sass should not own runtime theming: dark mode via class on the document root, per-user accent colors from CMS, component-level overrides in the cascade. Those are custom property problems.
Tailwind v4 @theme alignment means greenfield projects should not maintain parallel $blue-500 and --color-blue-500 unless automation generates one from the other. Recommended flow: token JSON as source of truth, build script emits theme.css with @theme, utilities resolve from variables, Sass optional only for non-Tailwind SCSS. When combining entry points, emit :root vars from Sass and reference them in @theme with var(). Avoid triplicate hex in JSON, Sass, and theme blocks.
Tailwind v4 moves design tokens into CSS with @theme. Colors become --color-* variables that utilities reference. Greenfield projects should not maintain parallel $blue-500 in Sass and --color-blue-500 in CSS unless a build step generates one from the other. Recommended greenfield flow: token JSON is source of truth, build script emits theme.css with @theme, Tailwind utilities resolve from those variables, Sass is optional only if you still compile non-Tailwind SCSS.
When you must combine Sass entry points with Tailwind v4, emit :root vars from Sass and reference them in @theme with var(), avoiding triplicate hex in JSON, Sass, and theme blocks. Pick one authoring surface and automate the rest.
Sass maps should use semantic keys that match CSS custom property names, not gray-500. surface-raised survives when the raised surface stops being white. OKLCH strings can live in Sass as unquoted values if your pipeline accepts them natively:
$accent: oklch(0.55 0.18 264);
:root {
--accent: #{$accent};
}
Alternatively, Sass holds hex anchors and PostCSS converts to OKLCH at the boundary. Convert once, not per component.
Anti-patterns, white-label, and where the industry is heading
Global @import 'colors' in every partial recreates 2018 problems. !default variables spread across dozens of files should centralize in a tokens index. Sass variables appearing in component CSS output mean compilation failed or someone bypassed the pipeline. Runtime theming via separate compiled CSS files per theme when custom properties would swap with one data-theme block means recompile for dark mode, which is wrong for user preference and right only for distinct brand customers.
Mixing $primary and var(--primary) in the same rule creates two sources of truth. Pick var(--primary) for anything theme-aware. Sass can fail builds early on contrast violations using approximate sRGB math, still better than shipping failing pairs silently.
@use 'contrast' as *;
$text: #64748b;
$surface: #f1f5f9;
@if contrast.ratio($text, $surface) < 4.5 {
@error "text-secondary on surface-default fails WCAG AA";
}
White-label remains legitimate Sass territory when one repository serves twelve retail brands and output is one CSS file with [data-brand] blocks, CMS sets the attribute on html, and no Tailwind rebuild runs per tenant. That is compile-time fan-out from structured data, not runtime accent from a user picker.
Greenfield Tailwind v4 projects shrink Sass surface area toward zero for colors. Token JSON to @theme CSS is the default path. Sass remains in mature codebases as a compiler, not as a philosophy. The teams that win treat custom properties as the public API to the browser and Sass as whatever machinery compiles that API, never the other way around.
Migration from @import to @use deserves its own checklist because deprecation warnings are not cosmetic. Create tokens/_colors.scss with namespaced variables. Replace @import with @use 'tokens/colors' as tokens in entry files. Update references from $primary to tokens.$primary. Run the Sass migrator on a branch and fix directory by directory. Delete shim files that only existed to preserve import order. Add Stylelint rules banning @import in new files. The mechanical migration often surfaces duplicate $primary definitions that @import order had papered over for years.
Testing and validation in Sass remains worthwhile even when browsers own runtime theme. Contrast math in Sass is approximate under sRGB assumptions, yet failing the build on a four point two to one pair is better than shipping it. Pair Sass validation with visual regression on real components because approximate math and perceptual judgment diverge at muted text sizes on tinted panels. When OKLCH becomes the canonical storage format, Sass can still validate that string values parse and that required semantic keys exist in exported JSON before CI emits CSS.
The industry direction is not anti-Sass. It is anti-confusion about what Sass compiles versus what the cascade computes. Node scripts, Style Dictionary, Terrazzo, and custom exporters can emit @theme blocks directly from DTCG JSON. Sass competes with those tools at the build boundary and often loses for greenfield color work. Sass wins when the repository already contains hundreds of SCSS partials, when white-label generation iterates brand maps, or when design-ops needs @error guards in a language designers’ export tools do not speak. Know which story your repository tells before accepting delete Sass tickets that ignore legitimate compile-time jobs.
Sass @forward and selective public APIs prevent token leakage. A tokens index can forward colors and spacing while keeping raw JSON import modules private to the build entry. Consumers import one @use 'tokens' in controlled files rather than reaching into generated partials whose names change when Figma paths change. That indirection pays off the first time a designer renames a collection and only the index path updates.
PostCSS and native CSS capabilities continue to eat Sass color functions over time. color-mix(), relative OKLCH, and wide-gamut syntax reduce the set of transforms that must happen before the browser sees CSS. Sass’s remaining value is structural: validating JSON, fanning out white-label brands, and bridging legacy SCSS components during years-long migrations. Treat each remaining Sass color function as a ticket with an expiration date tied to browser support analytics, not as permanent architecture.
Version pinning for Sass matters during migration because deprecations arrive on a schedule independent of your token epic. Track Sass release notes alongside browser OKLCH support notes in the same engineering changelog so @import removal and color-mix() adoption do not collide in one overwhelming week for the team maintaining tokens.
Delete Sass when the last @use only re-exports what a ten-line Node script could emit. Until then, use @use, emit var(--*), align names with Figma roles, and let @theme own utility colors in Tailwind projects. Two layers, one contract, zero duplicate hex.