Migrating from 0.8
guidenew0.9 adds event rules with :on(), mutations on the state's own property, bind() in state(), --attr-* and reactive content. The 0.8 syntax keeps working; this page maps each form to its 0.9 equivalent and lists the differences.
On this page · 10 sections+−
0.9.0 adds a new way to write behavior. Selectors say when, declarations say what: an event rule such as .button:on(click) names the moment, and its declarations change the states it owns.
The 0.8 syntax keeps working in 0.9. on:, bind:, text:, attr: and focus: compile to the same runtimes as before, and the runtime prints nothing about them. A page that works with 0.8.1 works with 0.9.0 unchanged, with one exception: custom properties named --attr-* (see reserved attribute properties).
Migrating is not required. New code reads better in the 0.9 syntax, and it is what these docs use; move existing stylesheets over when you touch them.
Quick map
Events
.menu { --open: state(false);}.menu > button { on: click(toggle(--open));}.menu { on: key(Escape, set(--open, false)), outside(click, set(--open, false));}.menu { --open: state(false);}.menu > button:on(click) { --open: toggle();}.menu:on(keydown(Escape)) { --open: set(false);}.menu:on(outside(click)) { --open: set(false);}- The mutation is written on the state’s own property:
--open: toggle()changes the nearest owner of--openon the element or its ancestors. key(Key, …)becomeskeydown(Key).keyup(Key)is new.prevent()moves into the selector as a modifier::on(click, prevent()).stop()is new. Both are written as functions.- 0.9 adds triggers that
on:never had, among themsubmit,dblclick,pointerdown,pointermove,keyup,scrolland drag. See Events & Actions.
Forms
.search input { --q: state(""); bind: --q;}.search input { --q: state("", bind(value));}state(…, bind(value)) binds the control to its own state. A radio group, or any control that writes an ancestor’s state, keeps bind: --state; it is not deprecated. A one-way alternative copies the value at input time: .search input:on(input) { --q: set(event(value)); }, with --q on an ancestor. Details: DOM Integration.
DOM text and attributes
.toggle { --on: state(false); on: click(toggle(--on)); attr: aria-pressed var(--on);}.toggle .label { text: when(--on, "On", "Off");}.toggle { --on: state(false); --attr-aria-pressed: var(--on);}.toggle:on(click) { --on: toggle();}.toggle .label { content: when(--on, "On", "Off");}What has no 0.9 form
- Explicit owners.
set(--x, v, closest(.scope))stays inon:. A 0.9 mutation always changes the nearest owner of the state. - Two-way ancestor binding.
bind: --sizekeeps a radio group and an ancestor’s state equal in both directions. The 0.9 form is one-way:.size input:on(change) { --size: set(event(value)); }, with the initial radio markedcheckedin the HTML. Keepbind:where a change of the state must also update the controls. - Focus requests.
focus: when(--open)is already the declaration for this, in 0.8 and in 0.9. See Focus & Blur. - One-time rules.
:on()has nooncemodifier. Guard the rule with a state instead:.b:when(not --done):on(click) { --done: set(true); }.
Differences to know
One transaction per event
- In 0.8,
on:runs its actions one after another. An inner and an outerincrement(--n)for the same state gave +2. - In 0.9, every mutation reads the values from before the event, and when two rules on the event path write the same state, the outer one wins. The same two rules give +1, and
--a: set(var(--b)); --b: set(var(--a))swaps two states. - Legacy
on:rules keep the 0.8 behavior.
One mutation per state, per element
- In 0.8 the cascade picked one
on:declaration per element, so different actions in different rules replaced each other. - In 0.9 it picks one mutation per state.
.b:on(click) { --a: … }and.b.x:on(click) { --b: … }both run; two rules for--agive one winner.
content is DOM text only under conditions
- The rule must target elements, not
::beforeor::after. - The value must read state, through
var(),when(),previous()ordirection(), and contain no image orattr(). - Everything else stays native
content.text: "Saved"writes a literal string;content: "Saved"stays native and writes nothing. See DOM Integration.
Reserved attribute properties
- A custom property named
--attr-<name>now writes that attribute when<name>is on the allowlist (aria-*states,disabled,hidden,required,tabindex,data-*). Other--attr-*names give a diagnostic and stay plain custom properties. - This is the one change that can alter a page that worked with 0.8.1. Rename any existing
--attr-*custom property that is not meant as an attribute, for example--attr-hiddento--x-hidden.
Functional :focus() is reported
:focus, :focus-visible and :focus-within stay native and untouched. A functional form such as :focus(…) is not CSS: Reactive CSS reports it and leaves the rule unchanged.
Event-time values
- Inside a mutation,
mouse(),element(),viewport(),random(),pick()andattr()are read once, when the event happens. - Outside mutations they keep their 0.8 live behavior.
event(field)exists only inside mutations.
Build pipelines: keep empty rules
A rule with only modifiers, such as a.confirm:on(click, prevent()) {}, is empty on purpose: it cancels the link’s default and changes nothing else. Minifiers that remove empty rules delete it silently:
Rules with declarations survive all of them. For stylesheets with Reactive CSS, use whitespace-only minification, or keep a modifier rule out of the minified file. Nothing preserves these rules automatically, and the runtime cannot see a rule that was removed before the page loaded.
Tools
Find the 0.8 syntax. reactive-css analyze with --json lists the legacy declarations of each file, with their 0.9 form:
npx -p @reactivecss/core -p esbuild reactive-css analyze --json styles/"legacy": [ { "syntax": "attr:", "use": "--attr-<attribute>: <value>" }, { "syntax": "on:", "use": ".x:on(event) { --state: action() }" }, { "syntax": "text:", "use": "content: <value>" }]bind: and focus: are not listed: they remain the current form for what they do.
Read production diagnostics. The minified bundles report problems as stable codes with their values, for example RCSS-P001⟦:on()⟧⟦events⟧⟦events⟧. Decode a message offline:
npx -p @reactivecss/core reactive-css explain "RCSS-P001⟦:on()⟧⟦events⟧⟦events⟧":on() is not included in this bundle (feature "events"); build a bundle with it (reactive-css build --features events) or use the full bundleOr load the development bundle, reactive-css.js, which logs the full text of every message. explain needs no esbuild.
Custom bundles. Each 0.9 construct belongs to a feature. A custom bundle without the feature reports the syntax once, naming the feature to add.
reactive-css analyze detects all of them. See Custom bundles.
Legacy 0.8.x compatibility
These declarations are the 0.8 syntax. They keep working in 0.9.0 and compile to the same runtimes, but the docs and examples no longer use them. Their reference pages stay available for existing stylesheets:
Inside on:, the actions take the state as their first argument (set(--x, 5), toggle(--open)) and prevent() is an action. In :on(), the state is the property (--x: set(5)) and prevent() and stop() are modifiers. once is not part of 0.9: guard the rule with a state.