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.
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.
| name | value | previous | changes |
|---|---|---|---|
| Waiting for the preview to start. | |||
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.
@keyframes animations, and @media (prefers-reduced-motion: reduce), which turns them off (:changed() then clears at once).@property --delta { syntax: "<integer>" } makes the browser compute the signed change; text: writes it.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).
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().data-direction keeps the last direction. It stays “down” after a reset to 0 until the next change, like the text./* ---- 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; }}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.
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.
// 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));}/* 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; }}/* ---- 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; }}<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>/* 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; }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.
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.
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.
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.
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.