:on()
rulesince 0.9.0requires runtimeAn event rule: when the event happens on the matched element (or inside it, for bubbling events), the rule’s state mutations run. The cascade picks one winning mutation per state, per trigger and per element.
On this page · 10 sections+−
Syntax
<selector>:on(<trigger> [, prevent()] [, stop()]) { --<state>: set(<expr>) | toggle() | increment(<step>?) | decrement(<step>?) | cycle(<value>#);}registry syntax: :on(event | keydown(Key) | keyup(Key) | outside(click | pointerdown | focusin) [, prevent()] [, stop()]) { --state: set(expr) | toggle() | increment(n?) | decrement(n?) | cycle(a, b, …) }
Parameters
- triggeran event name,
keydown(<key>),keyup(<key>),outside(click | pointerdown | focusin)ordragrequired - Events:
click,dblclick,contextmenu,pointerdown,pointerup,pointermove,pointerenter,pointerleave,pointercancel,mousedown,mouseup,mousemove,mouseenter,mouseleave,input,change,submit,reset,keydown,keyup,focus,blur,focusin,focusout,animationstart,animationend,animationcancel,transitionend,transitioncancel,scroll,scrollend(where the browser has it) andtoggle. An unknown name is a compile error that lists them. Fordrag, see:on(drag). - prevent()modifieroptional
- Calls
preventDefault()during dispatch. Only for cancelable triggers:click,dblclick,contextmenu,mousedown,pointerdown,keydown,keyup,submitanddrag. With any other trigger, and withoutside(), it is a compile error. - stop()modifieroptional
:on()rules on the element’s ancestors do not run for this event, and its native propagation stops at the element. A compile error withoutside()anddrag.- mutations
--<state>: <action>zero or more - The custom property on the left names the state; the action is
set(),toggle(),increment(),decrement()orcycle(), optionally with!important. A rule with modifiers and no declarations is valid.
Effect
No value. On each event, the winning mutations of every element on the event path change the nearest owner of each named state.
Example
.panel { --total: state(0); --asked: state(0); --done: state(false);}/* Every click inside the panel bubbles up to this rule… */.panel:on(click) { --total: increment(1);}/* …except clicks that stop() ends at its element. */.panel .quiet:on(click, stop()) {}/* prevent() cancels the link's navigation. */.panel .confirm:on(click, prevent()) { --asked: increment(1);}/* A one-time change: the :when() guard stops matching after the first click. */.panel:when(not --done) .once:on(click) { --done: set(true);}.panel .total { content: var(--total); }.panel .asked { content: var(--asked); }.panel .n { content: when(--done, "done", "not yet"); }Correct and incorrect usage
correctOverride a mutation through the cascade
.counter { --count: state(0); }.counter .button:on(click) { --count: increment(1); }.counter .primary.button:on(click) { --count: increment(2); }A click on .primary.button adds 2. The second selector is more specific, so its mutation wins the --count slot for that element; the first rule does not also run.
correctSwap two states in one event
.swap { --a: state(1); --b: state(2); }.swap:on(click) { --a: set(var(--b)); --b: set(var(--a));}Both mutations read the values from before the click, so the order of the declarations does not matter.
correctClose a menu from outside and with Escape
.menu { --open: state(false); }.menu > button:on(click) { --open: toggle(); }.menu:on(outside(click)) { --open: set(false); }.menu:on(keydown(Escape)) { --open: set(false); }One rule per trigger. For popups, the native popover attribute already gives light dismiss: prefer it where it fits.
incorrectA style declaration in an event rule
.card { --open: state(false); }.card:on(click) { --open: toggle(); color: red;}The mutation runs, but color: red is dropped with a warning: an :on() rule describes a moment. Style the result with .card:when(--open) { color: red; }.
incorrectprevent() with an event that cannot be cancelled
.field { --dirty: state(false); }.field input:on(input, prevent()) { --dirty: set(true); }A compile error: input events are not cancelable, so the rule is left unchanged and nothing runs.
incorrect:on() inside :has()
.list { --n: state(0); }.list:has(.item:on(click)) { --n: increment(1); }A compile error. Put :on() on the subject instead: .list .item:on(click) { --n: increment(1); } changes the list’s state, because the nearest owner is used.
Behavior
- Release status. The 0.9.0 release recommendation (hardening report, section H) lists
:on()and its mutations as stable, so this page does not mark it experimental. The runtime'sdescribe()still reportsexperimentaluntil the registry is updated. - Bubbling or target only. For bubbling events (
click,input,change,submit,reset,keydown,keyup,focusin,focusoutand thepointer*andmouse*events except enter and leave), rules run on the target and its ancestors, innermost first.pointerenter,pointerleave,mouseenter,mouseleave,focus,blur,scroll,scrollend,toggleand the animation and transition events run on the target only: a child’s animation does not trigger its parent. - Keys.
keydown(Escape)andkeyup(ctrl+k)take a key name with optional modifiers, which must match exactly. Keyboard events follow the focus path. A bare:on(keydown)runs for every key; read the key withevent(key). - Outside.
outside(click),outside(pointerdown)andoutside(focusin)run on matched elements that the event happened outside of, such as an open menu. - The state on the left.
--count: increment(1)changes the nearest--countowner: the matched element itself, else its closest ancestor with--count: state(…). Without an owner nothing changes and one diagnostic is given. There is noclosest()in:on(): give the state to the element whose rules should change it, or to an ancestor of both. - One winner per state, per trigger, per element. With
.button:on(click) { --count: increment(1) }and.primary.button:on(click) { --count: increment(2) }, a click on a.primary.buttonadds 2, not 3: both rules fill the same slot, and the browser’s cascade (origin,!important, layers, specificity, then order) picks the winner. A plain.buttonstill adds 1. Different states never conflict: a rule for--aand another rule for--bon the same element both run. - Conditions.
@media,@supports,@container,@layer,:hoverand:when()decide which rule wins at the moment of each event. - One transaction per event. Every winning mutation on the path is evaluated against the values from before the event, then all writes are committed: innermost element first, and in source order within an element. So
.swap:on(click) { --a: set(var(--b)); --b: set(var(--a)); }swaps the two values. If an inner and an outer rule write the same state, the outer write wins and is applied once: twoincrement(1)give +1. - Evaluated at the event. Inside a mutation,
mouse(),element(),viewport(),attr(),pick()andrandom()are read once, when the event fires, andevent()reads the event’s own fields. Outside mutations these functions keep their live behavior. - prevent(). Runs in the library’s capturing listener during dispatch, so the browser sees the cancellation in time. The element gets it when any applicable rule for that trigger has it. See
prevent()` and `stop(). A form control keeps its own keys unless it carries the rule itself, and a non-cancelable event gives one diagnostic. - stop(). Ancestors’
:on()rules do not run, andstopPropagation()is called when the event reaches the element, so the page’s listeners on ancestors do not see it either. The element itself and listeners on its descendants still receive the event, and the capture phase is never stopped. - Cleanup. One capturing document listener exists per event type while at least one rule uses that type. It is passive unless a live rule uses
prevent()for that type, and it is removed with the last rule that needs it.
Supported contexts
- Style rules in trusted stylesheets: inline
<style>and same-origin sheets. Event rules from cross-origin sheets are dropped as untrusted. :on()goes in the subject (last) compound of the selector, once per selector, never inside:not(),:is(),:where()or:has(). Nesting works:&:on(click).- In a selector list, every item must use
:on()with the same trigger and modifiers:.a:on(click), .bis an error. Write one rule per trigger. - Only mutations are allowed inside. Plain values (
--x: 5), native properties,--attr-*, nested rules and at-rules are dropped with a warning, because the rule describes a moment, not a style.
Limitations
- Minifiers delete empty rules. A rule with only modifiers, such as
a:on(click, prevent()) {}, is empty. esbuild withminifyorminifySyntax, csso and Lightning CSS remove it silently; whitespace-only minification (esbuildminifyWhitespace) keeps it. Nothing preserves these rules automatically, and the runtime cannot see a rule removed before it loaded, so minify stylesheets with Reactive CSS for whitespace only. Rules with declarations survive all three. - There is no
oncemodifier. For a change that happens once, guard the rule with a state:.b:when(not --done):on(click) { --done: set(true) }. - Stylesheets in shadow roots and
@imported sheets are not compiled. - The browser itself drops
:on()selectors as invalid. Reactive CSS compiles from the stylesheet’s source text, which is why it needs<style>elements or same-origin<link>sheets it can read. - A conditional rule, or several candidate rules for one element, costs one computed-style read per element per event; a single unconditional rule needs none.
Examples
- Counter · API examples, runs the real runtime
- State toggle · API examples, runs the real runtime
- Click coordinates · API examples, runs the real runtime
- Context menu · API examples, runs the real runtime