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

Product configurator

Two product cards. Each has a quantity (1–10, buttons and a number field), a color (radio group), gift wrapping (checkbox), a live total (real DOM text, 0.5.1) and a native reset. It is a local UI: no checkout, no network requests, nothing stored.

Live previewreal runtime
View source

How it works

  • bind:lines 5, 6, 7Connects a native form control two-way with the nearest state of that name.
  • increment()line 15Adds a step to a number or dimension state. In an :on() rule it is the mutation --count: increment(1).
  • decrement()line 16Subtracts a step from a number or dimension state. In an :on() rule it is the mutation --count: decrement(1).
  • attr:lines 15, 16Derives one allowlisted HTML attribute of the matched element, such as disabled or aria-expanded, from a reactive value.

Form controls and buttons driving the same state, a total computed by native CSS math from that state, quantity buttons that are natively disabled at a bound (0.5.3), and independent component instances.

Left to native HTML and CSS

  • The price math: calc() and clamp() on integer cents. A native @property --dollars { syntax: "<integer>" } makes the browser compute the dollar amount, and text: writes it.
  • Everything else native:
    • the number field’s own arrows and keyboard;
    • :invalid for typed values outside 1–10;
    • radio group arrow keys;
    • the reset button;
    • the color swatch transition.

What would normally need JavaScript

Keeping the buttons and the field in sync, the bounded quantity, and the total: CSS cannot read a number field’s value. Native CSS alone can style by :checked (color, gift) but cannot compute a total from a typed quantity.

Limitations

  • Formatting. The total is real DOM text since 0.5.1 ($<span>595</span>.00), so it can be selected, copied and found. But text: prints one value without formatting: every price is a whole dollar amount, which keeps “.00” fixed in the HTML. Prices with cents would need two values and zero padding, or script (Intl.NumberFormat).
  • <output> and reset. The total is not in an <output> element. On form reset, WebKit replaces an output’s children with its default text, which destroyed the bound span in testing.
  • Bounds (0.5.3). The buttons are natively disabled at a bound, written in the same task as the click. 12 synchronous clicks, rapid real clicks and repeated Enter all stop at 10 (tested). The 0.5.2 clamp rules are gone.
  • Focus (0.6.0). At a bound the button reports aria-disabled="true" and stays focusable. A keyboard user can press Enter at 10 without losing focus (tested). With native disabled (0.5.3), the focused button lost focus to the page.
  • Typed values outside 1–10 are kept in the state (bind: does not validate). The field shows native :invalid, and the total uses clamp(1, qty, 10). At 50, + is disabled; − steps down from 50.
  • Defaults written twice: state(1) and value="1", state(black) and checked.

Accessibility

  • Native controls: a fieldset and legend for each group, labels, real buttons with accessible names, and visible focus.
  • The total is DOM text in a polite live region. Whether screen readers announce its changes was not verified with a real screen reader.
  • aria-disabled vs disabled. At a bound the button is aria-disabled="true": announced as unavailable, but still focusable and clickable. A more specific on: rule replaces the action with a clamp (set(--qty, 10)), so clicks at the bound do nothing, including 12 same-task clicks (tested). aria-disabled itself blocks nothing. The shopping cart keeps native disabled, which blocks clicks itself but drops focus.

Implementation

2 files · 40 lines
style.cssreactive
/* ---- state ------------------------------------------------------------------------------   The form owns three states. bind: keeps each control and its state in sync both ways,   including the native reset button (states return to their initial values). */.product { --qty: state(1); --color: state(black); --gift: state(false); }.product .count { bind: --qty; }.product .color input { bind: --color; }.product .gift input { bind: --gift; }/* Quantity buttons (0.6.0). The form owns --qty. At a bound the button reports   aria-disabled="true" (attr:) and stays focusable, so keyboard focus never falls to the page;   aria-disabled does not block anything by itself, so a more specific rule replaces the action   with a clamp. (0.5.3 used native disabled, which drops focus at the bound; the shopping cart   example keeps native disabled for comparison.) Conditions are current for every click, even   several in one task (0.5.1). */.product .plus  { on: click(increment(--qty)); attr: aria-disabled when(--qty >= 10); }.product .minus { on: click(decrement(--qty)); attr: aria-disabled when(--qty <= 1); }.product:when(--qty >= 10) .plus { on: click(set(--qty, 10)); }.product:when(--qty <= 1) .minus { on: click(set(--qty, 1)); }.qty button[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed; }/* ---- price: native CSS math ---------------------------------------------------------------   All prices in cents (integers), so the arithmetic is exact. A typed quantity outside 1–10 is   shown as :invalid by the browser; the total uses the clamped quantity. */.product {  --extra: when(--color == silver, 1000, 0);  --total: calc((4900 + var(--extra)) * clamp(1, var(--qty), 10) + var(--gift) * 500);}/* Real DOM text (text:, 0.5.1). A registered integer property makes the browser compute the   number; text: writes it into the <span>. Every price here is a whole dollar amount, so the   cents stay ".00" in the HTML (there is no number formatting). The HTML holds the initial   total, shown before the script runs. */@property --dollars { syntax: "<integer>"; inherits: true; initial-value: 0; }.product { --dollars: calc(var(--total) / 100); }.product .dollars { text: var(--dollars); }.product .total { font-weight: 700; font-variant-numeric: tabular-nums; }/* ---- visual feedback ------------------------------------------------------------------------ */.swatch { height: 90px; border-radius: 10px; background: var(--color); border: 1px solid #ccc; transition: background-color 200ms; }.product:when(--gift) .swatch { background-image: linear-gradient(90deg, transparent 45%, #d32f2f 45% 55%, transparent 55%); }@media (prefers-reduced-motion: reduce) { .swatch { transition: 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/product-configurator/. 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.

Related

Interactive quiz

Three questions with three options each. The first answer counts. After answering, the quiz shows whether it was right, marks the correct option, shows an explanation and enables Next. At the end it shows the score and a message, and "Try again" starts a fresh session. Question data and the answer key are static HTML (class="correct").

0.8 syntaxfocus:attr::when():nth-var()

Live form preview

A profile form (display name, role, accent, email notifications) and a preview card that follows every change immediately. Native reset restores everything. There is no backend: the Save button is disabled, so neither the button nor Enter submits, and nothing is stored (the test checks storage and cookies).

0.8 syntaxbind::when()attr:text:

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.

0.8 syntaxincrement()decrement()set()previous()