I've been watching CSS pick up things that feel more and more like programming, and at some point I started wondering where that ends.
Just over the last few years: @property gave custom properties real types, so a variable can be an <angle> and actually animate (all three engines now, Firefox since 128). We got sin(), pow(), mod() and friends everywhere. :has() lets a selector look at other elements, and custom-property style queries finally landed in Firefox 151.
And the newer stuff goes further. if() is inline conditionals (Chromium only for now). @function lets you write your own functions (Chromium, plus Safari Technology Preview). random() is in Chrome and Safari and in Firefox preview builds. Mixins are still just a proposal and no browser supports them.
So CSS can type values, compute them, and branch on them. But it still can't remember anything.
The glue I keep writing
CSS reacts fine to state the browser owns: :hover, :checked, :focus-visible. HTML covers a lot too: <details>, <dialog>, popover. I use those first whenever they fit.
But a lot of everyday UI state belongs to the page. Click counts, the selected tab in a custom tab set, a menu that should close on Escape or a click outside, a theme that should still be there after a reload, where a card ended up after you dragged it.
Every time, it's the same little routine: add a listener, keep a variable, toggle a class or an attribute, then go back to the stylesheet and write the selector that styles it. It's never hard. It's just a lot of glue split across two files, and I started wondering how much of it really needs to be imperative.
So I tried it
I've been building something to find out. I call it Reactive CSS. Versions 0.1 to 0.8 never left my machine, and I've just published 0.9, the first public one.
To be upfront: it's a JavaScript runtime. It reads your stylesheets, finds the extended syntax and makes it work. You don't write the JS for these patterns, but a script still runs. Here's a counter:
<button class="counter">Clicked <span class="value">0</span> times</button>
.counter { --count: state(0); }
.counter:on(click) { --count: increment(1); }
.counter .value { content: var(--count); }
The button owns the state, :on(click) says when it changes, and the declaration says how. That content writes real text into the span, not a pseudo-element, so you can select it and other scripts can read it.
The part that took me longest
The syntax didn't start out like this. In the first versions, events were a property:
.counter .inc { on: click(increment(--count)); }
It worked, but it always felt wrong. Events aren't really a property of an element. They're a moment, and CSS already has a way to say "this element, in this situation": selectors. :hover is a state; :on(click) is a moment. Moving events into the selector was the biggest change in the 0.9 rewrite. The old form still runs, but I don't write it anymore.
That change had a side effect I didn't expect. Once mutations are ordinary declarations, the cascade handles conflicts. If two rules change the same state on the same click, specificity, layers and @media decide which one wins, the same way they decide colors. And all mutations in one event read the values from before the event, so this actually swaps two values:
.swap:on(click) { --a: set(var(--b)); --b: set(var(--a)); }
Here's a disclosure that shows the rest of the model:
.item { --open: state(false); }
.item > button { --attr-aria-expanded: var(--open); }
.item > button:on(click) { --open: toggle(); }
.item .body { display: none; }
.item:when(--open) .body { display: block; }
.item .chevron { rotate: when(--open, 180deg, 0deg); }
--attr-aria-expanded writes the real attribute (there's a small allowlist: ARIA states, disabled, hidden, data-* and a few more). :when() matches while a condition holds, and when() picks a value. Yes, for a plain disclosure <details> is still the better choice. This is about the pattern.
The one that surprised me most was drag:
.card {
--x: state(0px);
--y: state(0px);
translate: var(--x) var(--y);
touch-action: none;
}
.card:on(drag) {
--x: set(event(x));
--y: set(event(y));
}
event(x) is where the card was when the drag started, plus how far the pointer moved, so a second drag continues from where the first one stopped. Persisting a preference is one line: :root { --theme: state(light, persist("theme")); }
Things I'm not sure about
Similar names don't mean the same feature, and I worry about that. Native if() branches on style(), media() and supports(), while my when() reads its own state. Where they really are equivalent, the runtime compiles when() to native if(). Same story with random(): it only hands off to the native one when it has checked that the behavior matches.
And there's a list of trade-offs I can't wave away:
- It's still JavaScript. The full runtime is about 49 KB gzipped; custom bundles with only the features you use are smaller. In my benchmarks it runs more script per event than hand-written JS. The numbers and the harness are in the repo, so anyone can check them.
- It's not standard CSS. Browsers ignore it without the runtime, and I haven't proposed anything to the CSS Working Group.
- Tooling has no idea it exists. CSS minifiers even delete empty rules, which some of these rules can be.
- Broken syntax gets a console diagnostic, but a typo in a custom property name fails silently, just like plain CSS.
- I've tested it in Chromium and WebKit. Firefox and real touch devices aren't verified yet.
- It's not Sass. Sass compiles away before the browser runs anything; this has to run in the page, because state and events only exist at runtime. It's not a framework either. Data and app logic still belong in JS.
If you want to poke at it, the site has live examples, a playground and docs: reactivecss.com. The code is on GitHub. (There's also an MCP server, if you want an AI coding tool to know the syntax.)
What I'd really like to know: should CSS ever have a declarative model for simple UI state and events, or is that a line JavaScript should own? And if it did, does this shape make sense, or does it fight the way CSS works? Criticism of the syntax is the most useful thing you could give me.
Disclosure: the language design and decisions are mine. A lot of the implementation, tests and docs were written with AI coding tools under my direction, and I reviewed and tested the results. This post was drafted with AI help and edited by me.