Documents v0.9.0 · previews run the real runtime · experimental features are marked exp

Score history

Two independent scoreboards. Each shows the current score, the previous score, the last change (signed), the direction (up or down) and a short visual bump or dip on every change. +1, −1 and Reset.

Live previewreal runtime
View source

How it works

  • increment()line 3Adds a step to a number or dimension state. In an :on() rule it is the mutation --count: increment(1).
  • decrement()line 4Subtracts a step from a number or dimension state. In an :on() rule it is the mutation --count: decrement(1).
  • set()line 5Sets a state to a value. In an :on() rule it is a mutation of the state on the left, --x: set(5), with typed expressions.
  • previous()lines 19, 21, 24, 25The value of a state before its last effective change.

Reactive CSS tracks transitions, not only current values: previous() and direction() come from the state engine, and :changed() restarts native keyframes on every change. No timers.

Left to native HTML and CSS

  • Keyframes and reduced motion: the @keyframes animations, and @media (prefers-reduced-motion: reduce), which turns them off (:changed() then clears at once).
  • Numbers: a registered @property --delta { syntax: "<integer>" } makes the browser compute the signed change; text: writes it.

What would normally need JavaScript

All of it. CSS has no memory of clicks: a counter, its previous value and a restartable “just changed” animation need script. A plain-JavaScript version is short (see docs/example-comparison.md).

Limitations

  • Semantics of previous(). It is the value before the last effective change. Tested: 20 clicks in one task give 20 and previous 19, and 15 rapid real clicks are all counted. Intermediate values are not kept: there is no history().
  • Reset counts as a change, intentionally. “Previous” then shows the score before the reset, and the direction is “down”. There is no way to clear the history.
  • Reset is disabled at 0 (0.5.3). A reset at 0 changes nothing, because an unchanged value is not a change. Disabling it says so natively. A focused Reset that disables itself drops focus to the page.
  • data-direction keeps the last direction. It stays “down” after a reset to 0 until the next change, like the text.
  • No delta-dependent styles. Conditions compare operands but do not do arithmetic.

Accessibility

  • Buttons with accessible names, visible focus, and keyboard operation (tested).
  • The current score is DOM text in a polite live region (announcement not verified with screen readers).
  • The direction is DOM text (”▲ up”); screen readers may read the arrow symbol.
  • Motion is off under reduced motion (tested).

Implementation

2 files · 39 lines
style.cssreactive
/* ---- state and actions ------------------------------------------------------------------------ */.score { --points: state(0); }.score .up    { on: click(increment(--points)); }.score .down  { on: click(decrement(--points)); }.score .reset { on: click(set(--points, 0)); }/* attr: (0.5.3): Reset is natively disabled while the score is 0 (a reset at 0 would be a   no-op anyway: an unchanged value is not a change). The board reports the last direction as   data-direction, for plain attribute selectors, tests or other scripts. */.score .reset { attr: disabled when(--points == 0); }.score .reset:disabled { opacity: 0.45; cursor: not-allowed; }.score { attr: data-direction "none"; }.score:when(direction(--points) > 0) { attr: data-direction "up"; }.score:when(direction(--points) < 0) { attr: data-direction "down"; }/* ---- current, previous and change: real DOM text (text:, 0.5.1) -----------------------------   previous() and direction() are kept by the state engine. A registered integer property makes   the browser compute the signed change. */@property --delta { syntax: "<integer>"; inherits: true; initial-value: 0; }.score { --delta: calc(var(--points) - previous(--points)); }.score .current { text: var(--points); font-size: 48px; font-weight: 700; margin: 8px 0 0; font-variant-numeric: tabular-nums; }.score .prev { text: previous(--points); }.score .delta { text: var(--delta); }.score .history, .score .nochange { color: #555; margin: 0; }.score .history, .score:when(previous(--points) == --points) .nochange { display: block; }.score .nochange, .score:when(previous(--points) == --points) .history { display: none; }.score .direction { margin: 4px 0 12px; font-weight: 600; color: #888; }.score:when(direction(--points) > 0) .direction { text: "▲ up"; color: #166534; }.score:when(direction(--points) < 0) .direction { text: "▼ down"; color: #b42318; }/* ---- visual feedback: native keyframes, started by :changed() on every change --------------- */.score:changed(--points) .current { animation: bump 300ms ease-out; }.score:changed(--points):when(direction(--points) < 0) .current { animation-name: dip; }@keyframes bump { from { transform: scale(1.15); color: #166534; } }@keyframes dip { from { transform: scale(0.88); color: #b42318; } }.score .current { display: inline-block; }@media (prefers-reduced-motion: reduce) {  .score:changed(--points) .current { animation: none; }}
state()Function · hover any red tokenMakes each matching element the owner of an independent state with an initial value.Reference →

Source: the library's examples/score-history/. style.css from its first section comment; the layout above it runs but is not shown. Written in the 0.8 syntax (on:, bind:, text:, attr:), which 0.9.0 still runs; see Migrating from 0.8 for the 0.9 forms.

Compared with plain JavaScript

The same demo, unchanged, next to a vanilla JavaScript version of the same behavior on the same markup. Both run, independently; the JavaScript one loads no Reactive CSS.

Highlight
Score history, implemented twice. Line counts include comments and setup.
Vanilla JavaScript · runs without Reactive CSS34 + 18 = 52 lines
score-history.js
// Score history in plain JavaScript: the same markup and behavior as the library's// examples/score-history demo, with ordinary DOM APIs. Runs without Reactive CSS.for (const board of document.querySelectorAll('.score')) {  const $ = (s) => board.querySelector(s);  const current = $('.current'), reset = $('.reset');  let points = 0;  board.dataset.direction = 'none';  reset.disabled = true;  $('.history').hidden = true;  function set(value) {    if (value === points) return; // an unchanged value is not a change    const prev = points;    points = value;    const up = points > prev;    current.textContent = points;    $('.prev').textContent = prev;    $('.delta').textContent = points - prev;    $('.history').hidden = false;    $('.nochange').hidden = true;    $('.direction').textContent = up ? '▲ up' : '▼ down';    board.dataset.direction = up ? 'up' : 'down';    reset.disabled = points === 0;    // Restart the keyframes on every change, also for repeated changes in one direction.    current.classList.remove('bump', 'dip');    void current.offsetWidth;    current.classList.add(up ? 'bump' : 'dip');  }  $('.up').addEventListener('click', () => set(points + 1));  $('.down').addEventListener('click', () => set(points - 1));  reset.addEventListener('click', () => set(0));}
score-history.css
/* The presentation that score-history.js switches. The layout rules at the top of the demo's   style.css are shared by both versions and not repeated here. */.score .reset:disabled { opacity: 0.45; cursor: not-allowed; }.score .current { font-size: 48px; font-weight: 700; margin: 8px 0 0; font-variant-numeric: tabular-nums; display: inline-block; }.score .history, .score .nochange { color: #555; margin: 0; }.score .direction { margin: 4px 0 12px; font-weight: 600; color: #888; }.score[data-direction="up"] .direction { color: #166534; }.score[data-direction="down"] .direction { color: #b42318; }.score .current.bump { animation: bump 300ms ease-out; }.score .current.dip { animation: dip 300ms ease-out; }@keyframes bump { from { transform: scale(1.15); color: #166534; } }@keyframes dip { from { transform: scale(0.88); color: #b42318; } }@media (prefers-reduced-motion: reduce) {  .score .current.bump, .score .current.dip { animation: none; }}
Reactive CSS · runtime 0.9.039 lines
style.cssreactive
/* ---- state and actions ------------------------------------------------------------------------ */.score { --points: state(0); }.score .up    { on: click(increment(--points)); }.score .down  { on: click(decrement(--points)); }.score .reset { on: click(set(--points, 0)); }/* attr: (0.5.3): Reset is natively disabled while the score is 0 (a reset at 0 would be a   no-op anyway: an unchanged value is not a change). The board reports the last direction as   data-direction, for plain attribute selectors, tests or other scripts. */.score .reset { attr: disabled when(--points == 0); }.score .reset:disabled { opacity: 0.45; cursor: not-allowed; }.score { attr: data-direction "none"; }.score:when(direction(--points) > 0) { attr: data-direction "up"; }.score:when(direction(--points) < 0) { attr: data-direction "down"; }/* ---- current, previous and change: real DOM text (text:, 0.5.1) -----------------------------   previous() and direction() are kept by the state engine. A registered integer property makes   the browser compute the signed change. */@property --delta { syntax: "<integer>"; inherits: true; initial-value: 0; }.score { --delta: calc(var(--points) - previous(--points)); }.score .current { text: var(--points); font-size: 48px; font-weight: 700; margin: 8px 0 0; font-variant-numeric: tabular-nums; }.score .prev { text: previous(--points); }.score .delta { text: var(--delta); }.score .history, .score .nochange { color: #555; margin: 0; }.score .history, .score:when(previous(--points) == --points) .nochange { display: block; }.score .nochange, .score:when(previous(--points) == --points) .history { display: none; }.score .direction { margin: 4px 0 12px; font-weight: 600; color: #888; }.score:when(direction(--points) > 0) .direction { text: "▲ up"; color: #166534; }.score:when(direction(--points) < 0) .direction { text: "▼ down"; color: #b42318; }/* ---- visual feedback: native keyframes, started by :changed() on every change --------------- */.score:changed(--points) .current { animation: bump 300ms ease-out; }.score:changed(--points):when(direction(--points) < 0) .current { animation-name: dip; }@keyframes bump { from { transform: scale(1.15); color: #166534; } }@keyframes dip { from { transform: scale(0.88); color: #b42318; } }.score .current { display: inline-block; }@media (prefers-reduced-motion: reduce) {  .score:changed(--points) .current { animation: none; }}
state()Function · hover any red tokenMakes each matching element the owner of an independent state with an initial value.Reference →
Shared by both: index.html and the 10 layout lines at the top of style.css (not counted above)
index.html
<main>    <h1>Score history</h1>    <div class="boards">      <section class="score" id="home" aria-label="Home team">        <h2>Home</h2>        <p class="current" aria-live="polite">0</p>        <p class="history">Previous: <span class="prev">0</span> · Last change: <span class="delta">0</span></p>        <p class="nochange">No changes yet</p>        <p class="direction">—</p>        <div class="buttons">          <button type="button" class="down" aria-label="Decrease home score">−1</button>          <button type="button" class="up" aria-label="Increase home score">+1</button>          <button type="button" class="reset">Reset</button>        </div>      </section>      <section class="score" id="away" aria-label="Away team">        <h2>Away</h2>        <p class="current" aria-live="polite">0</p>        <p class="history">Previous: <span class="prev">0</span> · Last change: <span class="delta">0</span></p>        <p class="nochange">No changes yet</p>        <p class="direction">—</p>        <div class="buttons">          <button type="button" class="down" aria-label="Decrease away score">−1</button>          <button type="button" class="up" aria-label="Increase away score">+1</button>          <button type="button" class="reset">Reset</button>        </div>      </section>    </div>  </main>
style.css · layout
/* Score history: all behavior is in this file (Reactive CSS + native CSS). */body { margin: 0; font: 16px/1.5 system-ui, sans-serif; color: #1c1c1e; background: #f6f6f4; }main { max-width: 760px; margin: 0 auto; padding: 24px 16px; }.boards { display: grid; gap: 24px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }.score { background: #fff; border-radius: 14px; padding: 20px; box-shadow: 0 1px 3px rgb(0 0 0 / 0.1); }.score h2 { margin: 0; }.buttons { display: flex; gap: 8px; }.buttons button { font: inherit; padding: 8px 14px; border-radius: 8px; border: 1px solid #bbb; background: #fafafa; }:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
What the JavaScript does

Keeps the score in a variable per board, attaches three listeners, and on each change writes text, hidden, disabled and data-direction, and restarts the animation by swapping a class.

Result
style.css · Reactive CSS runtime 0.9.0
waiting for interaction
What the Reactive CSS does

Declares --points on each .score. The runtime attaches the listeners and keeps the previous value; text: and attr: write real DOM text and attributes, and :changed() restarts the native keyframes.

  • Neither version is much shorter. The difference is where the behavior lives: with Reactive CSS, state, events and presentation are declared together in the stylesheet, without application-specific JavaScript.
  • Both write real DOM text and attributes, so copy, find-in-page and assistive technology see the same thing. The tests on this site click both and compare every board after each step.
  • Each needs a script: the JavaScript version its own, the CSS version the Reactive CSS runtime (49,278 bytes gzip in 0.9.0). Without its script, neither does anything.
Related

Accessible toggle button

A pressed-state toggle button and a disclosure button. Their aria-pressed and aria-expanded attributes are real DOM attributes, kept in sync with Reactive CSS state.

0.8 syntaxstate()bind()on:click()

Settings panel

A notifications section whose channel options are disabled while notifications are off, a theme and text-size preview, an "Advanced settings" disclosure, and a native reset. Nothing is saved.

0.8 syntaxbind:attr:toggle()set()

Shopping cart

Three cart lines with − / + / Remove, a subtotal per line, the item count and total, an "empty cart" message, and Checkout and "Empty cart" buttons that are disabled while the cart is empty. Local only: Checkout does nothing.

0.8 syntaxincrement()decrement()set()attr: