Summary
There is no way to define a colour token once in crosswind.config and get both (a) the :root custom property and (b) utilities that reference that property. cssVariables: true gets you (a) but inlines the literal value into the utility, so overriding the variable at runtime — the standard light/dark mechanism — changes nothing. Pointing a theme colour at var(--x) gets you (b) but emits no definition, so the variables must be hand-written in CSS somewhere else. Either way the token block ends up duplicated by hand.
Separately, there is no CSS entry point: @apply is not implemented and build() takes no input stylesheet, so authored component CSS cannot go through the same pipeline as utilities.
Repro
// cssVariables: true, theme.extend.colors = { bg: '#0a0c0e', panel: '#141719' }
:root {
--bg: #0a0c0e;
--panel: #141719;
}
.bg-bg { background-color: #0a0c0e; } /* ← the literal, not var(--bg) */
So @media (prefers-color-scheme: dark) { :root { --bg: #080b12 } } re-points the custom property and no utility notices.
// cssVariables: false, theme.extend.colors = { bg: 'var(--bg)' }
.bg-bg { background-color: var(--bg); } /* ← themeable, but --bg is defined nowhere */
Impact (real app)
analyticshq took the second branch. config/crosswind.ts:23-39 registers eight tokens as var() references:
colors: {
'bg': 'var(--bg)', 'panel': 'var(--panel)', 'border': 'var(--border)',
'text': 'var(--text)', 'text-2': 'var(--text-2)', 'text-3': 'var(--text-3)',
'accent': 'var(--accent)', 'bar': 'var(--bar)',
}
The definitions are then hand-written three times, each with its own @media (prefers-color-scheme: dark) override — resources/layouts/auth.stx:24-33, resources/views/dashboard.stx:1071-1076 and resources/views/account.stx:88-93 (the latter two near-identical, differing only in --bar and the mono stack). A fourth, independent token set lives in public/marketing.css — 905 lines / 34,503 bytes of hand-authored CSS committed straight to public/ and served unhashed at /marketing.css (resources/layouts/marketing.stx:22): no minification, no hashing, no cache busting, and no crosswind pass over it.
The layout file records how that started (resources/layouts/auth.stx:14-17):
The <style> block below was copy-pasted, byte for byte, into login.stx and register.stx, and into sites/new.stx minus the five .oauth-btn/.divider rules it does not use.
Evidence in crosswind
packages/crosswind/src/generator.ts
:2401 if (this.config.cssVariables) { … } — the only consumer of the flag
:2514-2537 generateCSSVariables() — emits :root { --<name>: <value> } from extendColors
- The colour rules resolve
theme.colors[name] to its raw value; nothing rewrites it to var(--name), which is why the utility carries the literal.
packages/crosswind/src/config.ts:521 — cssVariables: false by default.
No @apply: grep -rn '@apply' packages/crosswind/src/*.ts → no matches. packages/crosswind/src/build.ts exposes build(config) (:17) and buildAndWrite(config) (:91) — both generate utilities from scanned content; neither accepts an authored CSS entry to transform.
Prior art
- Tailwind v4
@theme — one declaration emits the :root custom property and every generated utility references it (background-color: var(--color-bg)), so overriding the property in a media query or on [data-theme] re-themes the whole sheet. This is exactly the shape needed here.
- Tailwind v3 —
@apply plus a processed entry CSS, so authored component rules go through the same pipeline as utilities.
- Vue/Svelte scoped
<style> — component CSS is owned by the component and still processed.
Ask
- Add a theme mode where a token declared as a literal emits both
:root { --<name>: <literal> } and utilities that reference var(--<name>), so a runtime override re-themes every utility. (cssVariables: 'reference' alongside today's true, or a @theme-equivalent config key.)
- Support per-scheme token values in config —
{ bg: { light: '#fbfbfc', dark: '#080b12' } } — emitting the :root block plus the prefers-color-scheme / [data-theme] override, so the dark block does not have to be hand-written per page.
- Accept an input CSS entry point in
build() / the plugin, and implement @apply, so authored component CSS is minified, hashed and shipped by the same pipeline as the utilities.
- Keep
var()-valued theme colours working as they do today — they are the only workaround available right now.
Related: #16
Summary
There is no way to define a colour token once in
crosswind.configand get both (a) the:rootcustom property and (b) utilities that reference that property.cssVariables: truegets you (a) but inlines the literal value into the utility, so overriding the variable at runtime — the standard light/dark mechanism — changes nothing. Pointing a theme colour atvar(--x)gets you (b) but emits no definition, so the variables must be hand-written in CSS somewhere else. Either way the token block ends up duplicated by hand.Separately, there is no CSS entry point:
@applyis not implemented andbuild()takes no input stylesheet, so authored component CSS cannot go through the same pipeline as utilities.Repro
// cssVariables: true, theme.extend.colors = { bg: '#0a0c0e', panel: '#141719' }So
@media (prefers-color-scheme: dark) { :root { --bg: #080b12 } }re-points the custom property and no utility notices.// cssVariables: false, theme.extend.colors = { bg: 'var(--bg)' }Impact (real app)
analyticshq took the second branch.
config/crosswind.ts:23-39registers eight tokens asvar()references:The definitions are then hand-written three times, each with its own
@media (prefers-color-scheme: dark)override —resources/layouts/auth.stx:24-33,resources/views/dashboard.stx:1071-1076andresources/views/account.stx:88-93(the latter two near-identical, differing only in--barand the mono stack). A fourth, independent token set lives inpublic/marketing.css— 905 lines / 34,503 bytes of hand-authored CSS committed straight topublic/and served unhashed at/marketing.css(resources/layouts/marketing.stx:22): no minification, no hashing, no cache busting, and no crosswind pass over it.The layout file records how that started (
resources/layouts/auth.stx:14-17):Evidence in crosswind
packages/crosswind/src/generator.ts:2401if (this.config.cssVariables) { … }— the only consumer of the flag:2514-2537generateCSSVariables()— emits:root { --<name>: <value> }fromextendColorstheme.colors[name]to its raw value; nothing rewrites it tovar(--name), which is why the utility carries the literal.packages/crosswind/src/config.ts:521—cssVariables: falseby default.No
@apply:grep -rn '@apply' packages/crosswind/src/*.ts→ no matches.packages/crosswind/src/build.tsexposesbuild(config)(:17) andbuildAndWrite(config)(:91) — both generate utilities from scanned content; neither accepts an authored CSS entry to transform.Prior art
@theme— one declaration emits the:rootcustom property and every generated utility references it (background-color: var(--color-bg)), so overriding the property in a media query or on[data-theme]re-themes the whole sheet. This is exactly the shape needed here.@applyplus a processed entry CSS, so authored component rules go through the same pipeline as utilities.<style>— component CSS is owned by the component and still processed.Ask
:root { --<name>: <literal> }and utilities that referencevar(--<name>), so a runtime override re-themes every utility. (cssVariables: 'reference'alongside today'strue, or a@theme-equivalent config key.){ bg: { light: '#fbfbfc', dark: '#080b12' } }— emitting the:rootblock plus theprefers-color-scheme/[data-theme]override, so the dark block does not have to be hand-written per page.build()/ the plugin, and implement@apply, so authored component CSS is minified, hashed and shipped by the same pipeline as the utilities.var()-valued theme colours working as they do today — they are the only workaround available right now.Related: #16