Skip to content

feat(css): ordered CSS value fallbacks - #3745

Open
segunadebayo wants to merge 1 commit into
v2from
feat/css-value-fallbacks
Open

feat(css): ordered CSS value fallbacks#3745
segunadebayo wants to merge 1 commit into
v2from
feat/css-value-fallbacks

Conversation

@segunadebayo

Copy link
Copy Markdown
Member

CSS has always let you write a property twice so an older browser keeps the value it understands. Panda could not express it, because the array slot that would have carried it already means responsive values.

This adds fallback(a, b).

css({ color: css.fallback('oklch(55% 0.18 250)', '#0057b8') })
.c_fallback\(oklch\(55\%_0\.18_250\)\,_\#0057b8\) {
  color: #0057b8;
  color: oklch(55% 0.18 250);
}

You write the value you want first, the same shape as var(--brand, red). Panda emits the members in reverse, because CSS keeps the last declaration it understands.

The value is a string, and that is the whole design

An earlier draft of the design note proposed a marker object, { __panda: 'fallback', values: [...] }, carried by new Fallback variants on StyleTree, Literal, and AtomValue, plus a FallbackScalar type, a build-info wire change, and a SCHEMA_VERSION bump from 5 to 6.

That version was written and compiling before it got thrown away. None of it was needed. A fallback run is one value for one property, so writing it as one string says exactly that, and every stage Panda already has treats a string correctly without being taught anything:

Stage What it needed
Extraction nothing, it is a string literal
StyleTree / Literal nothing
Encoder nothing, one atom with one value
Build info nothing, no schema bump
Class naming nothing, the existing arbitrary-value escaping
Stylesheet expansion into a declaration run

Three problems the marker design had went away with it. Token references keep working, because collect_token_refs already scans raw value strings for {colors.brand}. There is no runtime parity contract to maintain, because class names come from the value text through the same escaping every arbitrary value uses. And design systems need no compatibility gate, because a published library's build info carries a string that every consumer version already understands.

Class names are escaped, not hashed

.c_fallback\(red\,_blue\)
.c_color-mix\(in_oklch\,_red\,_blue\)   /* already how Panda names this */

Hashing was considered and rejected. It would make fallback() the only value form with a bespoke naming rule, it would create a runtime parity contract where none is needed, and it would produce class names that say nothing when readable class names are the entire point of the default. hashClassNames: true already hashes everything uniformly for anyone who wants short names.

Members are typed by the property they sit in

interface CssFallbackFunction {
  <T>(first: T, second: T, ...rest: T[]): T
  <A extends CssFallbackMember, B extends CssFallbackMember, R extends CssFallbackMember[]>(
    first: A, second: B, ...rest: R
  ): A | B | R[number]
}

The first overload is what makes the editor useful. T has no argument to infer from before you type one, so it comes from the contextual return type, and every parameter is typed as that property's value union. Inside css.fallback( you get the same 33 color tokens you get on color: itself, measured through the TypeScript language service against the real generated styled-system.

The second overload catches members that do not share a type, where the first fails.

Two earlier attempts each lost something measurable. A phantom-branded CssFallbackValue<T> autocompleted but forced every member to one type, so css.fallback(4, '1rem') failed. StyleX's firstThatWorks signature alone allowed mixed types but offered zero completions, because parameters inferred from arguments have no contextual type to suggest from. The overload pair keeps both and needs no brand, so WithEscapeHatch is untouched by this feature.

Config recipes

A config file loads before styled-system/css exists, so it writes the value form directly, or uses the same helper from @pandacss/dev:

import { cssFallback, defineRecipe } from '@pandacss/dev'

defineRecipe({
  className: 'card',
  base: { color: cssFallback('oklch(45% 0.16 250)', '{colors.blue.700}') },
})

The helper buys arity as a compile error, a function name that cannot be silently mistyped, and keyword autocomplete inside the call: 13 completions for position, versus 0 for a non-generic signature. It does not buy value validation. @pandacss/types has no token unions and csstype admits string & {} for every property, so any string is a legal config value with or without it.

Importance belongs to the run

!important applies to a whole run or to none of it. Marking every member individually means the same thing and is accepted. Marking only some is rejected, because an important declaration beats the others whatever the order, so the rest could never apply.

  • fallback(a !important, b) leaves b unprotected once a turns out unsupported.
  • fallback(a, b !important) is worse: the fallback always wins, so the preferred value never applies at all.

This needed its own handling. split_important takes the first ! anywhere in a value, which for a run hoists one member's marker onto every declaration. split_run_important strips only a marker after the closing paren.

Malformed runs emit nothing

fallback(...) is not real CSS, so passing a malformed one through guarantees a broken declaration. Every drop is reported instead, across seven diagnostic codes at two layers: the extractor reports misuse of the API with a call span, the stylesheet reports malformed values, which is the only layer that sees a hand-written string. They do not double-report, because a refused css.fallback() never folds to a value.

A dynamic member is deliberately not one of them. It is an ordinary runtime bailout and already reports panda_call_unextractable.

What is out of scope

Custom-property declarations are rejected with a warning. --accent accepts an arbitrary token stream, so an older browser keeps the second declaration and only discovers the unsupported value when var(--accent) is substituted, too late to recover the first. The warning points at var(--accent, ...), which is the construct that actually works.

That is also why this does not collapse variable members into nested var() the way StyleX does. Panda emits :root { --colors-brand: ... } for every token it uses, so the failure barely arises on the token path, and for genuinely external variables native var() fallback syntax already works and already passes through Panda untouched.

Verification

Beyond the test suite, the emitted CSS was checked in Chrome 151 through getComputedStyle, against a stylesheet produced by the real CLI in sandbox/vite-ts:

written computed shows
fallback(oklch(55% .18 250), #0057b8) oklch(0.55 0.18 250) preferred wins
fallback(not-a-real-color(1), #0057b8) rgb(0, 87, 184) the browser recovers the fallback
fallback(bogus-fn(9), color(display-p3 ...), #0057b8) color(display-p3 0 0.6 0.2) first dropped, second wins over third
_hover: { fallback(nope-fn(2), magenta) } rgb(255, 0, 255) recovery works nested inside a condition
_dark > md > _hover run yellow at 929px, black at 500px the media wrapper gates it

The second row is the feature working. Nothing in a unit test can demonstrate that a browser discards an unparseable declaration and falls back.

Not covered: Firefox, Safari, and @media print.

Tests

130 new tests:

  • 16 on the parser (pandacss_shared)
  • 22 on folding css.fallback(), including binding-aware rejection and source spans
  • 61 on emission and diagnostics
  • 5 on run-append semantics
  • 3 on the transform, 2 on JSX props
  • 21 across the sandbox: the generated runtime, strictTokens typing, and cssFallback()

cargo nextest run --workspace is at 2481 passing, pnpm test sandbox/codegen at 259.

Three bugs this found in existing code

Two of them were only reachable through a fallback, but both were latent in code that predates it.

append_declarations appended one declaration at a time, and a single declaration replaces an existing one for the same property. Any rule with two runs silently lost all but the last member of the second one. It now groups consecutive same-property declarations and moves them as a unit.

collect_atom_usage handed the whole fallback(...) string to the utility transform, so a token used only inside a run never resolved and got pruned while the CSS still referenced it.

The third is worth knowing but was left alone: important_marker takes the first ! anywhere in a value, so content: "'!'" reads as important too. Fixing that shared helper is a separate change with its own parity tests.


design-notes/css-value-fallbacks.md has the full design, including both rejected type signatures so they do not get retried.

Add `fallback(a, b)`, a value form that emits one declaration per member for
one property, so a modern value can pair with a supported one:

    css({ color: css.fallback('oklch(55% 0.18 250)', '#0057b8') })

    .c_fallback\(oklch\(55\%_0\.18_250\)\,_\#0057b8\) {
      color: #0057b8;
      color: oklch(55% 0.18 250);
    }

Members are written most-preferred first, matching `var(--brand, red)`, and
emitted in reverse because CSS keeps the last declaration it understands.

The value is a plain string, so extraction, encoding, and build info carry it
as one atom and one class with no new IR and no schema change. Only the
stylesheet expands it, through one path shared by atoms and recipe entries.

`css.fallback()` and `cssFallback()` from @pandacss/dev build the same string.
Both use two overloads: a uniform one whose type parameter comes from the
property, so its values autocomplete, and a second that infers each position
separately for members of differing types.

Malformed runs emit nothing rather than leaking `fallback(...)` into the sheet,
and report one of seven diagnostics.
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

馃 Changeset detected

Latest commit: f0f299b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 19 packages
Name Type
@pandacss/compiler Minor
@pandacss/compiler-wasm Minor
@pandacss/dev Minor
@pandacss/types Minor
@pandacss/cli Minor
@pandacss/eslint-plugin Minor
@pandacss/language-server Minor
@pandacss/mcp Minor
@pandacss/postcss Minor
@pandacss/rollup Minor
@pandacss/transformer Minor
@pandacss/typescript-plugin Minor
@pandacss/vite Minor
@pandacss/webpack Minor
@pandacss/compiler-shared Minor
@pandacss/config Minor
@pandacss/preset-base Minor
@pandacss/preset-panda Minor
@pandacss/preset-typography Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
panda-docs Ready Ready Preview Aug 20, 2026 1:15pm
panda-playground Ready Ready Preview Aug 20, 2026 1:15pm
panda-studio Error Error Aug 20, 2026 1:15pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant