Changelog
projectAll notable changes, from the library's own CHANGELOG.md.
On this page · 15 sections+−
Declarative events and state
A new authoring model: :on() event rules mutate a state on its own property, with event() data, bind() and persist() in state(), --attr-*, DOM text through content, focus requests, :blur and drag.
- :on() event rules with the prevent() and stop() modifiers, plus keydown(Key), keyup(Key) and outside()
- Mutations on the state's own property: set(), toggle(), increment(), decrement(), cycle()
- event() with 32 fields, and event-time calc(), clamp(), mouse(), element() and random()
- bind(value) and bind(checked) in state(); persist() for localStorage or sessionStorage (experimental)
- --attr-* attributes and DOM text through content (experimental)
- focus: when() focus requests (experimental) and the :blur selector
- Drag gestures: :on(drag) and drag(start | move | end | cancel) (experimental)
- Native-first random(), production diagnostic codes, and new custom-bundle features: gestures, persist, blur
- The 0.8 syntax keeps working
Breaking: Custom properties named --attr-<attribute> now write that attribute when the attribute is allowlisted (aria-* states, data-*, disabled, hidden, required, tabindex). Rename any --attr-* property used for another purpose. The rest of the 0.8 syntax keeps working; see the migration guide.
Known limitations: Minifiers that remove empty rules (esbuild minify, csso, Lightning CSS) delete modifier-only :on() rules such as a:on(click, prevent()) {}; minify Reactive stylesheets for whitespace only. Firefox, real Safari, touch and pen hardware and screen readers are not verified. persist(), content, focus: when(), drag and the --attr-* namespace are experimental. Bundle sizes are measured from the build on the Installation and Custom Bundles pages.
Changelog
All notable changes. 0.9.0 is the first version published to npm, as @reactivecss/core; earlier versions were local builds.
0.9.0 — The 0.9 language
A new way to write behavior, built on the 0.8 runtimes. The 0.8 syntax keeps working.
- Design:
docs/rfc-v0.9.0-language.md. - Migration:
docs/migration-0.9.md. - Engineering report:
docs/release-0.9.0-preparation.md. - Measurements:
benchmarks/v090/results.md.
Added
- Event rules:
:on()..b:on(click) { --count: increment(1); }.- Mutations go on the state’s own custom property:
set(expr),toggle(),increment(n?),decrement(n?),cycle(a, b, …). - Triggers:
- 32 DOM events, among them pointer, mouse, form,
submit, keyboard, focus, animation, transition,scrollandscrollend; - key filters with
keydown(Key)andkeyup(Key); outside(click | pointerdown | focusin).
- 32 DOM events, among them pointer, mouse, form,
- Modifiers (functions, each optional):
prevent()callspreventDefault()synchronously, for cancelable events only;stop()callsstopPropagation()(notstopImmediatePropagation()) when the event reaches the element, so ancestors’ rules and listeners do not see it.
- Cascade. The cascade picks one winning mutation per state, trigger and element, using origin,
!important, layers, specificity, order, media and:when(). - Transactions. Each event is one transaction: all values are read before any write.
- Mutations go on the state’s own custom property:
- Event-time expressions in mutations:
- arithmetic: typed
calc(),min(),max(),clamp(),round(),abs(),var(); - event data:
event(field), a closed catalog checked against the trigger; - snapshots read once:
mouse(),element(),viewport(),random(),pick(),attr(),sibling-index(),sibling-count().
- arithmetic: typed
- Drag gestures (feature
gestures)::on(drag)and:on(drag(start | move | end | cancel)).event(x)andevent(y)give the mutated state at drag start plus the pointer distance, so positions continue across drags.event(delta-x),event(movement-x)andevent(start-x)give the raw distances (and the same fory).- Pointer capture, a 3 px threshold and
pointercancelhandling. - At idle there is one
pointerdownlistener and nothing else.
- Persistence (feature
persist):state(v, persist("key" [, session])).- Initial value: storage, then the DOM source, then the declared value.
- Stored values are validated by type.
- Writes are coalesced (at most 4 per second per key).
- Keys are shared between widgets and across tabs.
- When storage is unavailable, the page gets one note and keeps working.
state(v, bind(value | checked)): two-way binding of a control with its own state.--attr-<attribute>: the reserved custom-property namespace for attributes, with theattr:semantics.- The allowlist adds
aria-hidden,aria-busy,aria-checked,aria-invalid,aria-label,aria-valuenowandaria-valuetext.
- The allowlist adds
content: var(--x)as DOM text on elements: the native declaration is kept, and only state-reading values count.- Focus requests use
focus: when(condition), the 0.6 declaration. It is now also armed by:on(pointerup),:on(submit)and drag end. Plain:focusstays native. :blur(featureblur): matches from a real focus loss until the next focus.attr(name, typed fallback)instate(): reads the attribute once, as that type.- Analyzer:
files[].legacylists 0.8 declarations with their 0.9 form;- every new construct maps to its feature;
- exact usage keys now win over wildcards.
- Catalog: the features
gestures,persistandblur, and the profileinteractive. describe(): addsactionExpressionsandonEvents. Registry entries of kindrulecover:on()anddrag.
API cleanup (before publication)
Reports: docs/release-0.9.0-preparation.md and docs/release-0.9.0-hardening.md (section J).
These changes only affect syntax that existed in unpublished 0.9 candidates; 0.8 syntax is unchanged.
focus:is the one focus API.:focus(when(...))was removed, together with its duplicate runtime. A functional:focus(...)now gets one diagnostic that says to writefocus: when(...).:on()modifiers are functions:prevent()andstop(). The barepreventandstopforms are compile errors that say what to write.oncewas removed from:on(), with its per-element registration records.onceandonce()are compile errors that suggest a:when()guard. Legacyon:is unchanged.
Hardening (before publication)
Report: docs/release-0.9.0-hardening.md.
- Production diagnostic codes. Production bundles replace message text with stable codes,
RCSS-<area><nnn>⟦values⟧; development bundles keep the text.- Registry:
src/core/diagnostics.json, append-only. - Lookup:
dist/diagnostics.jsonandreactive-css explain, which works offline and without esbuild.
- Registry:
- Native
random(), aligned with the 2026 draft that Chromium and WebKit implement:- native-only keys (
--name,element-scoped,property-scoped,fixed) are left to the browser; - supported calls stay native whatever their units or step;
- the plain third-argument step is accepted, natively or polyfilled;
by <step>andelement-sharedstay; - valid native CSS no longer gets a warning.
- native-only keys (
- Drag: a press in an input, textarea, select or editable element inside a draggable never starts a drag.
:blurwithout a compound (.form:has(:blur)) works, as the RFC described.- Fixed: elements moved in the DOM kept rules they no longer matched (0.8 as well). A subtree moved within the document is now re-matched both ways.
- Fixed: two places read or sliced diagnostic text: a custom element’s
disabledwait and the native-function note. Found by the codes. - Types: the
.d.tsfiles get explicit.jsextensions (tools/fix-dts.mjs) and now resolve undermoduleResolutionnode16andnodenexttoo. This was a 0.8.1 blocker. - Smaller code: one parser path for
focus:,text:,attr:and--attr-*. - Fixed: Firefox exposed
autocomplete="one-time-code"fields tobind:,attr()andpersist()(0.8 as well). Firefox reports tokens it does not know as an emptyautocompleteproperty, so the attribute is now read instead. - Fixed: a drag selected page text, and on WebKit for Linux the next drag froze. The press was not kept from starting a text selection, so a drag left nearby text selected; the next press on that selection started a native drag, which on WebKit for Linux takes all further pointer input, and the drag stopped after its first move.
selectstartanddragstartare now cancelled while a drag gesture is armed (this also keeps images and links inside a draggable element from starting a native drag). - Production console hint says
npx -p @reactivecss/core reactive-css explain: the unscoped npm packagereactive-cssis a different project.
Changed
--attr-*custom properties with an allowlisted suffix now write that attribute (the one possibly breaking change: rename such properties if they were plain CSS).state()takes up to two modifiers (bind(),persist()) in any order. The message for a third argument that is neither is new.attr()fallbacks: instate()andset()the fallback may be any value (attr(data-n, 0)); in 0.8 it had to be a quoted string.prevent()and:on(…, prevent)keep form-control keys forkeyuptoo.- Focus requests are also armed by
pointerupandsubmit, which only 0.9 triggers produce. - Sizes:
reactive-css.min.jsgrew from 117,830 B to 151,472 B after hardening (gzip from 39,715 to 49,936, brotli from 34,983 to 43,811); the pre-hardening candidate was 162,675 B. Theeventsfeature grew from +11.3 KB to +32.5 KB. The new features add +2.2 KB (gestures), +3.8 KB (persist) and +0.9 KB (blur). Measured; seedocs/release-0.9.0-preparation.md, section 4. With the API cleanup and the release fixes, the final 0.9.0 build is 149,177 B (gzip level 9: 49,298 B), and the compact bundle 128,633 B (42,558 B).
Fixed
- Nothing user-visible: 0.8.1 behavior is unchanged. The 0.8.1 browser suite passes on 0.9.0. Assertions were updated only for the additive changes: the feature count, the
rulekind, thedescribe()keys, the two message changes above, andaria-labelandaria-hiddenbecoming writable.
0.8.1 — Pre-beta stabilization (local, unpublished)
A patch release: fixes found by the internal experiments and the visual lab. No new public API. Report: docs/prebeta-runtime-stabilization.md. Release preparation: docs/release-0.8.1-preparation.md.
Fixed
- Analyzer: root-relative stylesheets.
href="/x.css"was resolved against the filesystem root, a guessed location. It now resolves only against an explicit site root: CLI--root <dir>, APIanalyze(inputs, { root }). Paths that leave the root are refused. Without a root, aroot-relative-linknote explains why the analysis is incomplete and the full runtime is recommended. - Analyzer: query strings.
style.css?v=2(and#hash, percent-encoding) now findsstyle.css. - Keyboard focus. An Enter or printable-key action that moved focus with
focus:also activated the newly focused button, kept Enter from submitting its form, and typed a shortcut letter into a newly focused field. Focus after those keys now moves in the next frame. Arrow keys, Home, End, Escape, Tab and prevented keys keep the same-task timing (roving tabindex is unchanged). :changed()held by unrelated animations. Only animations and transitions that:changed()rules can start now keep the marker, as documented. When those rules set custom properties or usevar(), every animation in the subtree still counts (conservative fallback). Several:changed()rules on one state still clear together.bind:lost a fast second choice on pages with anon: clickrule. Two radio choices in one task (what arrow keys do), two clicks on a checkbox, or two arrow-key moves in one frame left the control and the state on the first choice. Native updates are no longer overwritten by queued writes.input/changeread the new value, and a cancelled click leaves both the control and the state unchanged.
Changed
analyze --buildwith an incomplete analysis now says that the build is the full runtime and why.- Focus requests after Enter and printable keys move one frame later on pages with
attr:bindings (as on pages without them). bind:adds one captureclicklistener onwindowwhile any bound control exists. It is removed with the last one.- Sizes:
reactive-css.min.jsgrew from 116,319 B to 117,830 B (measured;docs/release-0.8.1-preparation.md).
Documentation
-
live()(and other sources) keep running while a:when()merely selects a different value. To stop a source, remove its element or gate it with a native attribute such asdetails[open]. -
element()has no value for one frame after the first measurement, so thevar()fallback applies first. -
WebKit engine limitations, reproduced without Reactive CSS:
- a
text:inside a section hidden by CSS can show a stale value when the section is shown by something other than a state change; :has(:invalid)does not update after a required, empty<select>is enabled.
Both have documented state-driven workarounds. They are not library fixes.
- a
-
Version: one source in the runtime (
src/core/version.ts), checked againstpackage.json. The lockfile metadata (stale since 0.1.0) was resynchronized.
0.8.0 — Modular runtime and custom bundle builder (local, unpublished)
Report: docs/release-0.8.0.md. Decision record: docs/rfc-modular-runtime-0.8.md. Tooling: docs/bundle-builder-0.8.md.
Added
- Custom bundles. The runtime is split into a core and 11 features:
events,conditions,nth-var,history,forms,text,attributes,focus,values,dynamic,geometry.- One catalog (
src/features/catalog.ts, alsodist/catalog.json) lists their syntax, requirements and status. - Dependencies come from the code, and a test builds each feature alone to check them.
- One catalog (
- Missing-feature diagnostics. A custom bundle still recognizes all syntax. A declaration, function or selector whose feature is not included is reported once with the feature ID (“text: is not included in this bundle (feature “text”); …”), and left to the browser as before.
random()andelement(), which native CSS also defines, get a note. describe().bundle: the included feature IDs (nullwhen imported from source).reactive-cssCLI andreactive-css/bundleAPI:features,resolve,buildandanalyze;- profiles (
basic,forms,stateful-ui,dynamic,full); - requirements added automatically and explained;
- error codes with suggestions;
- a manifest with minified, gzip and brotli sizes.
- Analyzer: uses the real compiler on CSS, HTML and directories. When the analysis is incomplete (other scripts, remote sheets,
@import, unreadable files), it recommends the full runtime. - Benchmarks:
benchmarks/bundle-sizes.mjs(sizes, per-feature overhead, startup).benchmarks/audit/memory.mjs --bundle=custom/<file>.jsruns on custom bundles.
Fixed
- A
<link>whosehrefchanged could keep its old sheet unprocessed.link.sheetcan briefly still be the previous sheet. Found by running the examples on custom bundles in WebKit; regression test intests/browser/v071.spec.ts.
Changed
reactive-css.min.jsis 1.9 KB larger (116,319 B): it now carries the missing-feature diagnostics and runtime registration. Behavior is unchanged: all earlier tests pass.package.json(local):- a
binentry, plusexportsfor./bundleand./catalog.json; filesnow includesrcandtools;- esbuild is an optional peer dependency, needed only by the builder.
- a
0.7.1 — Cross-browser validation and advanced examples (local, unpublished)
Report: docs/release-0.7.1.md. Manual test guide: docs/browser-validation-0.7.1.md. No new public API.
Fixed (found by the new examples, each with a regression test in tests/browser/v071.spec.ts)
- A disabled and re-enabled
<link>stylesheet lost its Reactive CSS. The browser drops the sheet when the link is disabled and loads a fresh, unprocessed copy when it is re-enabled, but onlyhrefchanges were observed. Thedisabledandmediaattributes of stylesheet links are now observed too: disabling releases the sheet’s rules at once, re-enabling processes the fresh sheet, and amediachange re-reads. text:,attr:andfocus:reading a state declared in a later-loading stylesheet showed nothing, and reported a false “needs one value” warning. They now read again after every stylesheet is compiled or dropped. A value that still contains uncompiled Reactive CSS syntax (another sheet not yet processed) is skipped silently instead of reported.focus:could move focus out of an open popover in WebKit. The rule only checked where focus was, and a click inside the popover does not focus a button in WebKit. The target of the activating event is now checked too (rfc-focus-declaration-0.6.md, rule 8).
Examples
- Eight validation examples: focus transitions, keyboard navigation, attribute playground, dynamic components, nested state and cascade, form interactions, history and motion, stylesheet lifecycle lab.
- The two lifecycle labs use a clearly marked JavaScript test harness.
- Every README has a manual checklist and browser notes.
- Demo lab: the eight examples, a
harness.jssource tab, and a cross-browser checklist page (examples/lab/checklist.html) built from the READMEs. Every cell starts as “not tested”; results copy as Markdown.
Unchanged
- Every 0.7.0 API and behavior. All earlier tests pass unchanged.
0.7.0 — Native event control (local, unpublished)
Report: docs/release-0.7.0.md. Decision record: docs/rfc-prevent-action-0.7.md.
Added (experimental)
prevent(): an action that callsevent.preventDefault()synchronously during dispatch, inclick(…)andkey(…)only.- It cancels only events that happened inside the element with the declaration.
- It never cancels keys in a form control or editable element that does not carry the rule.
- Non-cancelable events are reported, and the result is checked.
- It never stops propagation, and runs only when its (possibly conditional) declaration is the one that applies.
- The document listener for an event type is non-passive only while a rule uses
prevent()for it; otherwise it stays passive.
Examples
- Tabs: arrow keys, Home and End no longer scroll the page.
- Keyboard playground: the pad’s arrow keys no longer scroll.
Not added
stop(): misleading with a delegated listener;handled()is described as future research.- A
submittrigger: assessed and deferred. prevent()inreset,outside()and non-cancelable events: compile errors.
Unchanged
- Every action and trigger without
prevent()behaves as in 0.6.0 (all earlier tests pass). ActionDef.run(el, data)gains an optional thirdeventargument; the internal extension point stays compatible.
0.6.0 — Accessible reactive interfaces (local, unpublished)
Report: docs/release-0.6.0.md. Decision record: docs/rfc-focus-declaration-0.6.md.
Added (experimental)
focus: <value>: moves focus to the matched element when its value goes false → true because of a trusted userclick,keydown,inputorchange.- Never on load, insertion, scripts, timers, form reset or focus events.
- Never over focus the user moved, focus inside the target, or an open dialog or popover.
- Resolved after the state’s CSS and
attr:/text:writes. The first eligible target in document order wins. - Targets that cannot take focus are reported;
tabindexis never added. - Trusted stylesheets only.
aria-disabledin theattr:/bind()allowlist ("true"/"false", also from1/0). It does not block events: make actions inactive with conditionalon:rules.tabindexin the allowlist, restricted to0and-1(rovingtabindex).
Changed
- Performance (from the post-0.5.3 experiment,
docs/attr-performance-optimization.md). Same-taskattr:passes no longer re-read the bindings of earlier events in the same frame (21 → 2 computed-style reads per click in a 20-event burst).
Examples
- Wizard: focus moves to the new step’s heading.
- Quiz: answering moves focus to Next; Next and Try again move it to the card heading.
- Tabs: ArrowLeft/Right, Home and End, a roving
tabindex, and focus that follows the selection (ARIA Tabs, automatic activation; keyboard behavior tested, screen readers not). - Configurator:
aria-disabledwith clamp rules, so focus stays on a button at its bound.
Unchanged
state(),on:,bind:,text:,attr:andstate(…, bind())behave as in 0.5.3 (all earlier tests pass).
0.5.3 — Derived HTML attributes and demo validation (local, unpublished)
Reports: docs/release-0.5.3.md, docs/demo-validation-0.5.3.md.
Added (experimental)
attr: <attribute> <value>: one allowlisted HTML attribute of the matched element, derived from a reactive value (usually state owned by an ancestor).-
Allowlist and value semantics as
state(…, bind()). Boolean states (1/0in CSS) are accepted for the true/false attributes. -
Each attribute compiles to its own registered, non-inherited property (
--rcss-attr-<name>). Declarations for different attributes combine; the cascade picks the winner for the same attribute. The original attribute is restored when none applies, unless something else changed it (the binding then yields). -
Written in the same task as the action or bound control that changed the state, so a derived
disabledstops the next click. -
HTML applicability, checked on write:
disabledonly on native controls, fieldsets and form-associated custom elements;requiredonly on applicable controls;hiddenonly on HTML elements.
Otherwise the attribute is skipped with one warning per element type. Obvious ARIA misuse (
aria-pressedoutside a button,aria-selectedwithout a supporting role) gets a warning. -
A
state(…, bind())of the same attribute on the same element wins, with a diagnostic. Trusted stylesheets only.
-
when(condition)without branches givestrue/false.
Changed
- After event-driven work (actions, bound controls) that affects a derived attribute, the late phase (
attr:andtext:reads) runs in the same task instead of the next frame. It runs once more if it wrote anything, and again in the frame.
Examples
-
Updated:
- configurator: derived
disabledat the bounds; - wizard:
aria-current="step", disabled Back/Next/Finish; - quiz: answered options
disabled, the chosen onearia-pressed; - form preview: derived
data-*on the card; - score history: Reset disabled at 0,
data-direction.
The 0.5.1 clamp rules are gone.
- configurator: derived
-
New: settings panel, accessible tabs, shopping cart, notification center, form validation, keyboard playground.
-
New demo lab
examples/lab/: all examples with their source, README and optional diagnostics (lab tooling only). Every example README has a manual test checklist.
Found and fixed during validation
- WebKit stale computed values. WebKit can return a stale computed value, in the same task, for an element under
display: none(quiz options in hidden cards; a field inside a just-unhidden wrapper). Fixed by a second same-task read after writes, plus a frame re-read. - The wizard’s Finish guard. Removing the 0.5.1 clamp rules would have let a script click the hidden Finish button outside step 4. Finish is now derived-
disabledoutside step 4.
Unchanged
state(…, bind()),bind:,text:,attr()and every action behave as in 0.5.2 (all earlier tests pass).
0.5.2 — Reactive HTML attribute binding (local, unpublished)
Report: docs/release-0.5.2.md.
Added (experimental)
state(initial, bind(attribute)): a state mirrored to one allowlisted HTML attribute of its owner:aria-expanded,aria-pressed,aria-selected,aria-current,disabled,hidden,required,data-*.- The attribute initializes the state once (invalid values are reported and ignored); every state change then writes it synchronously, with HTML/ARIA semantics.
- The cascade winner chooses the attribute. The original value is restored on cleanup unless something else changed it.
- Trusted stylesheets only. Bound attributes never re-match reactive rules.
- Example:
examples/accessible-toggle(aria-pressed,aria-expanded). The live form preview mirrors its accent todata-accentand styles it with attribute selectors.
Fixed
- A
@layer a, b;statement after the first rule of a reactive stylesheet was dropped when the sheet was rewritten, so the layer order fell back to first appearance and the wrong layer could win. Only the leading statements are skipped now.
Unchanged
state()withoutbind(),state(attr(…))(an initializer only),bind:on form controls,text:and all actions behave as in 0.5.1 (tested).
0.5.1 — Correctness, DOM text binding (local, unpublished)
Report: docs/release-0.5.1.md. RFCs (not implemented): docs/rfc-attribute-binding.md, docs/rfc-focus-management.md, docs/rfc-event-control.md.
Fixed
- Conditional actions saw last frame’s conditions.
:when()and:nth-var()are applied as marker attributes in the frame flush, and the cascade that picks anon:declaration reads them. A second event before the next frame therefore used stale conditions: bounds could be exceeded (12 clicks → 13), a quiz answer scored twice, real rapid clicks were affected too. Now, before each event is dispatched, the logical consequences of earlier actions in the same frame (state values,:when()and:nth-var()markers) are applied (scheduler.sync()). It costs nothing when nothing is pending; visual updates still happen in the frame.
Added (experimental)
text:: writes one scalar value (var(--x),previous(),direction(),when(), a literal) into the element’s own DOM text, through theTextnode API. Only for elements that are empty or hold one text node. The cascade picks the winning declaration; the original text is restored when none applies; the binding yields if something else changes the text. Trusted stylesheets only.- Triggers:
focusin,focusout,reset(bubbling), andanimationend,animationcancel,transitionend,transitioncancel(the element’s own animations and transitions only). dist/reactive-css.compact.min.js: optional bundle with a reduced registry (80,092 B vs 92,037 B minified; 27,463 vs 31,423 B gzip). The default bundle and itsdescribe()are unchanged.
Verified, unchanged
- State actions are sequential within a task: each reads the value the previous wrote (50 synchronous increments give 50).
Examples
- The five examples show real DOM text instead of generated content where possible, and gained bound-safe rules (wizard: Next, Back and Finish cannot leave steps 1–4 under rapid clicks). The configurator’s total moved out of
<output>, whose form reset (in WebKit) replaces its children.
Compatibility
stats()has a newsyncscounter.- Each
text:rule compiles to a registered, non-inherited--rcss-textproperty.
0.5.0 — API finalization, beta candidate (local, unpublished)
Reports: docs/beta-prep-0.5.md, docs/beta-hardening-0.5.md.
Beta hardening
- Fixed: the cascade winner initializes a state. With several
state()declarations for one name on one owner, the initializer of the declaration that wins the cascade (specificity, stylesheet order, layers, media,!important) now sets the initial value, includingattr()andpick(). Before, an evaluated initializer came from whichever declaration attached first. Each declaration emits a small unregistered marker property in its rule; the browser’s cascade picks the winner. No new registrations. - Fixed:
:nth-var()(and other custom pseudo-classes) directly inside a functional selector such as:has(.opt:nth-var(--a))was reported as an unsupported selector. dist/features.json: thedescribe()metadata as a file, for tools that do not run the library.- Examples:
examples/product-configurator,multi-step-wizard,interactive-quiz,live-form-preview,score-history, with READMEs and browser tests. - Audits:
docs/event-support-0.5.md(event matrix,prevent()/stop()proposal),docs/feature-registry-review.md,docs/example-comparison.md,docs/example-capability-gaps.md.
Added (experimental)
- Expressions in
state()andset():attr(name [, "fallback"]),pick(a, b, …)andpick(split(text, "delimiter")). Instate()they are evaluated once per owner, when the state is created; inset()on every event, on the element with theon:declaration.attr()always gives a string and reads only that element (no ancestor lookup).split()is only allowed as the single argument ofpick(), so state stays one scalar value. Outside these two positionsattr()keeps its native meaning (content: attr(data-label)is untouched). describe()lists them as kindexpression(andexpressionsin its result).
Removed
wave()(experimental since 0.4). It was a convenience over native@property+@keyframes, at a measured main-thread style cost of 171–181 ms/s per 1,000 animated elements, where a composited@keyframes translatecosts nothing.wave()declarations are now left to the browser (which drops them as invalid). Migration:@property --y { syntax: "<length>"; inherits: false; initial-value: 0px } @keyframes y { from { --y: -8px } to { --y: 8px } } .floating { translate: 0 var(--y); animation: y 1.5s ease-in-out infinite alternate }, or animatetranslatedirectly. The registry keeps it with statusremoved.
Changed
state()registers one generated property per state name for the page, instead of one per declaration per compile. Recompiling or editing a stylesheet no longer registers new properties for names already seen. Removing one of two sheets that declare the same state on one element keeps the value for the other declaration.- String values are serialized with every control character escaped (not only newlines), so any text is one CSS string.
- Feature registry: status
removedand fieldremovedIn; kindexpression;typed()documents its differences from@property. - Version metadata: 0.5.0.
Fixed
- A
<style>or<link>element that was moved (removed and re-inserted in one task) lost its reactive rules: the browser builds a new stylesheet from the original source, and it was not processed again. - Cascade-chosen initial values of boolean states (
.a { --on: state(false) } .a.preset { --on: state(true) }) were ignored: the computed1was read as a number. npm testandnpm run test:distrun only the library’s own test directories (a nestedsite/project’s tests were picked up by the path filter).
Compatibility
- Pages using
wave()lose that motion (the declaration becomes invalid and is dropped): see the migration above. set(--x, …)now acceptsattr()andpick(); other functions are still rejected with the same message, extended.
0.4.0 — CSS extensions & extensible architecture (local, unpublished)
Added (experimental)
element(width | height): the element’s own border-box size in px, through one sharedResizeObserver(no layout reads). It is context-aware: the standardelement(#id)image function is left to the browser. Self-feedback loops are detected causally and frozen with a warning.wave(min, max, duration): sine-shaped periodic motion for numbers, lengths, percentages, angles and times. It is a native Web Animations animation of a registered per-call-site property, with one shared keyframe list and no JavaScript per frame. The author’sanimation/transition/transformare left untouched, andprefers-reduced-motiongives the midpoint.
Architecture
- Extension contract:
FunctionDef.matches()for context-aware claiming. The contracts are documented indocs/extensions-0.4.md. - Feature registry:
description,parameters(with defaults),evaluation,support,cleanup, andreasonfor planned and rejected entries. Every 0.4 proposal is recorded with its decision.
Decided against (see docs/api-review-0.4.md)
map()→ nativeprogress();distance()→hypot(); valuecycle()→@keyframes+steps();smooth()→transition.- Deferred: cross-element
element()(→anchor-size(), container units),spring(),throttle(),debounce(),history().
Fixed (runtime audit, see benchmarks/AUDIT-0.4.md)
:nth-var()subscription leak. When a state owner was removed and reinserted (a list re-sorted, a component moved through a fragment), a stale:nth-var()group got a new state subscription that was never released: one per reinsertion, plus redundant work on every state change, for as long as the owner lived.- Listener leak on
<link>elements. Everyhrefchange while the link was disabled added two event listeners and kept the fetched stylesheet text alive; after enabling, theerrorlisteners stayed. Now at most one pending wait per link. element()and:just-checkedper-element tables are cleared on removal. They relied on garbage collection, and the V8 tables kept their peak size (+384 KB after 500 mount/unmount cycles of 1,000 components).- Identical
wave()calls are shared. Rules with the samewave()arguments share one registered property, one keyframe list and one animation per element. Two such rules on 1,000 elements: style time 177 → 135 ms/s, start 20 → 12 ms. Recompiling a stylesheet no longer registers new properties for them.
Tooling (development only)
stats().internalin the development builds: queue lengths and the sizes of internal element-keyed collections, for leak tests. Removed from the production bundle.- Benchmarks measure the production bundle by default, with a seeded
Math.random, a warm-up, repeated runs and medians with ranges. benchmarks/audit/: leak detector (heap snapshots, retainer paths), long-running test, startup/idle/latency/scheduler measurements.
Unchanged
- No stable syntax changed; nothing is deprecated. All 0.3.1 behavior and tests pass, including CSP and cascade tests.
Playground
- Demos S (element geometry: state-driven width, readout, keyboard buttons) and T (wave motion: reduced motion respected).
0.3.1 — Stability & Compatibility (unreleased, local release candidate)
A maintenance release: browser compatibility, secure stylesheet handling, cascade correctness, :changed() reliability, and production verification. No new public API features. No public syntax was changed.
Fixed
- Strict Content-Security-Policy no longer breaks pages (high severity). 0.3.0 inserted a generated inline
<style>and disabled the original stylesheet. Understyle-src 'self'the copy was blocked, and the page silently lost every rule of that stylesheet. Stylesheets are now rewritten in place through the CSSOM, which CSP does not govern. No<style>element is ever injected, and no nonce or'unsafe-inline'is needed. - The in-place rewrite also fixes several cascade cases:
- A processed sheet keeps its
media, itsdisabledstate (including toggling at runtime), its alternate-settitle, its URL base and its single entry indocument.styleSheets. - A
<link disabled>enabled later is processed when the browser loads it. - Alternate stylesheets are processed instead of skipped.
- A processed sheet keeps its
bind:refuses password, one-time-code and payment fields. Otherwise, injected CSS could read their live values throughbind:and conditional URLs.- The native
random()probe no longer usesstyle=""attributes, so it causes no CSP violation understyle-src-attrand no false negative. on:fast path: a single unconditionalon:rule is checked against its sheet’sdisabledstate andmediabefore running.- Diagnostics name the stylesheet (
/css/app.css: random(): …,<style id="theme">: …,inline <style> #2: …), together with the construct and a fix. The “untrusted stylesheet” message now says why.
Added (configuration, not API)
- Optional
window.ReactiveCSSConfig.trustInlineStyles = false:on:/bind:only from same-origin<link>stylesheets, for sites that render user-provided<style>. The default is unchanged.
Changed
:changed()looks for the animations it started after the frame has rendered, instead of forcing a style update at the start of the next frame. Semantics are unchanged (tested: delays, several animations, zero duration, restart, cancellation, removal). The cost in large pages is dominated by Chromium’sgetAnimations()(≈ running animations × page size) and is documented.npm run test:browserandnpm run verifyrun Chromium and WebKit.npm run test:firefoxis separate.- Playground accessibility:
- the toggle switch is a native
<input type="checkbox" role="switch">withbind:; - a native radio-group variant of the tabs demo was added;
- icon-only buttons have accessible names, and clickable
divtiles are buttons; - each demo notes what it does not provide (ARIA state, focus management).
- the toggle switch is a native
Verification added
- CSP tests with real headers (5 policies), security regression tests, cascade tests (layers across sheets, nested layers,
@media/@supports/@container, nesting,:is/:where/:has, pseudo-elements,@import, shadow DOM, running animations), a heap-snapshot lifecycle test (0 retained detached elements; the test detects a deliberate leak),:changed()lifecycle tests, and distribution tests: the minified bundle alone on a plain HTML page, a Node ESM import and a TypeScript consumer of the.d.tsfiles.
Known limitations
- Firefox is unverified: Playwright’s Firefox does not start on the development machine.
- See README → Limitations.
0.3.0
previous(), direction(), :changed(), bind:, key(), outside(), cycle(), closest() scopes, native if() for boolean when(), experimental typed(), and the machine-readable feature registry (describe()). Fixed: state leaking between nested owners of the same rule in CSS.
0.2.0
State engine: state(), on:, set(), toggle(), increment(), decrement(), when(), :when(), state-driven :nth-var().
0.1.x
Reactive values (random(), pick(), live(), on(), mouse(), viewport(), visible()), :just-checked, :just-unchecked, :nth-var(). Performance pass: shared sources, compact bindings, frame budget, native random().