Skip to content

feat(theme): emit theme tokens as custom properties AND have utilities reference them (Tailwind v4 @theme parity) #25

Description

@glennmichael123

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:521cssVariables: 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

  1. 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.)
  2. 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.
  3. 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.
  4. Keep var()-valued theme colours working as they do today — they are the only workaround available right now.

Related: #16

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions