DOM Integration
conceptConnect state to the DOM: two-way form binding with state(v, bind(value)), attribute reflection with bind(attribute) and the reserved --attr-* properties, DOM text through content, and state initialized from an attribute.
On this page · 9 sections+−
Reactive CSS can connect a state to the document in several ways. Each one has a fixed direction, so a value never loops between the DOM and the state.
Form controls: bind(value) and bind(checked)
Add bind(value) to a state on a form control, and the control and its state stay equal in both directions. A checkbox uses bind(checked) with a boolean state.
.signup input[name=name] { --name: state("", bind(value));}.signup input[name=name]:when(--name != "") { border-color: #15803d;}Typing updates --name. A change of --name, from an event rule for example, updates the field. The control keeps all its native behavior.
/* State that the preview line reads lives on the form, the shared owner. */.signup { --greeting: state(""); --news: state(false);}/* The field also owns a two-way state of its own. */.signup input[name=name] { --name: state("", bind(value));}/* Copy the control's value up to the form at input time. */.signup input[name=name]:on(input) { --greeting: set(event(value));}.signup input[name=news]:on(change) { --news: set(event(checked));}/* The field styles itself from its own state. */.signup input[name=name]:when(--name != "") { border-color: #15803d;}.signup .greeting { content: when(--greeting == "", "stranger", "");}.signup:when(--greeting != "") .greeting { content: var(--greeting);}.signup .sub { content: when(--news, " Newsletter: yes.", "");}| name | value | previous | changes |
|---|---|---|---|
| Waiting for the preview to start. | |||
- Interact with the preview to record events.
Supported controls
- Multiple selects, file, date and color inputs are not supported.
- Password, one-time-code and payment fields (
autocomplete="cc-…") are never bound. Otherwise CSS could read their live values. - The rule must be unconditional, with no
@media,@supports,@containeror:when()that could stop it from matching: a binding lasts as long as the element matches. - The stylesheet must be trusted: same-origin, or inline with the default
trustInlineStyles.
Who can read the state
The control is the state’s owner. Like every state, it can be read only by the owner and its descendants. A text field has no descendants, so in practice only rules for the field itself read a bind(value) state.
To show the value somewhere else, give the state to an element that contains both, and copy the value at input time with an event rule:
.signup { --greeting: state("");}.signup input[name=name]:on(input) { --greeting: set(event(value));}.signup .greeting { content: var(--greeting);}This copy is one-way: changing --greeting does not change the field. The live example above uses both forms: --name styles the field, and --greeting on the form feeds the preview line.
Radio groups and ancestor state
A radio group is several controls that share one value, so the state must live on an ancestor. Copy the chosen value into it with a change rule:
fieldset.size { --size: state("m");}fieldset.size input[type=radio]:on(change) { --size: set(event(value));}fieldset.size:when(--size == "l") { font-size: 1.25rem;}event(value) is the value of the radio that was chosen. The copy is one-way, so mark the initial radio checked in the HTML. A string state (state("m")) accepts any value; a keyword state accepts only keywords. To restore it when a form resets, give the state to the <form> and add form:on(reset) { --size: set("m"); }. The same pattern works for any control that writes an ancestor’s state: :on(input) for text fields, event(checked) for checkboxes.
Initialization
For state(v, bind(value)) and state(v, bind(checked)), the first value comes from, in order:
- a value stored with
persist(), when the state has one and it is valid; - the control, when the user or the browser already changed it from its HTML default (autofill, a restored form, early typing);
- the declared initial value
v, which is then written to the control.
A form reset restores the declared initial value of every bind(value) or bind(checked) state in that form.
Native behavior is kept
- Typing never rewrites the field: the caret and the selection stay where they are.
- Labels, validation, autofill and form submission work as before. Style validation with native
:invalidand:user-invalid. - A range input clamps values, so a state outside its range shows clamped.
Attribute reflection: state(v, bind(attribute))
bind() with an attribute name mirrors a state into one allowlisted attribute of the element that owns it.
/* The state is reflected into aria-pressed. */.toggle { --on: state(false, bind(aria-pressed));}.toggle:on(click) { --on: toggle();}.toggle .label { content: when(--on, "On", "Off");}.toggle:when(--on) .knob { background: #15803d;}.toggle:when(--on) .knob::after { translate: 20px 0;}- One-way, state → attribute. Each state change writes the attribute in the same task, so the DOM never lags behind the state. Changes that scripts make later are not read back: the next state change overwrites them, and one diagnostic says so.
- Attribute first. When the state starts, a valid attribute value in the HTML wins over the declared initial value. A button written as
aria-pressed="true"starts pressed. An invalid value is reported and ignored. A persisted value wins over both. - Restore. When the state goes away, the original attribute is restored, but only if it still holds what Reactive CSS wrote.
- Types. Booleans for
aria-expanded,aria-pressedand the other ARIA booleans and fordisabled,hiddenandrequired; a number foraria-valuenowandtabindex; a boolean or a keyword that can holdmixedforaria-checked.
The initial value is required. state(bind(aria-expanded)) is rejected with “state() needs an initial value first, e.g. state(false, bind(aria-expanded))”: with no attribute in the HTML, there would be no value. Write state(false, bind(aria-expanded)).
—attr-*: attributes from a value
A custom property named --attr-<attribute> writes that attribute on the element the rule matches. The value usually reads a state owned by an ancestor:
.item { --open: state(false);}.item > button:on(click) { --open: toggle();}.item > button { --attr-aria-expanded: var(--open);}.item { --open: state(false);}.item > button:on(click) { --open: toggle();}/* Writes aria-expanded="true" or "false" on the button. */.item > button { --attr-aria-expanded: var(--open);}.item .body { display: none;}.item:when(--open) .body { display: block;}.item .chevron { rotate: when(--open, 180deg, 0deg);}--attr- is the only custom-property prefix with DOM semantics. Every other custom property is plain CSS: --disabled, --hidden or --aria-expanded change nothing in the DOM, whatever their names.
What can be written
0.9.0 added aria-hidden, aria-busy, aria-checked, aria-invalid, aria-label, aria-valuenow and aria-valuetext to the list.
Never writable: event handlers (on*), URL attributes (href, src, action, formaction, srcset, xlink:href), style, id, class, name and value. Any other --attr-* name gives a diagnostic and stays a plain custom property.
Rules
- Lower case. Custom property names are case-sensitive and HTML attribute names are not, so the suffix must be lower case.
--attr-Aria-expandedis a diagnostic, and nothing is written. - The cascade decides. Each attribute is its own property, so rules for different attributes combine, and for one attribute specificity, order, layers,
@media,:when()and!importantpick the winner. - No value restores. When no rule applies, or the winning value is empty, such as a
when()without a value for the current case, the attribute returns to what it was before the first write. The counter example below uses--attr-disabled: when(--count <= 0)this way. - One owner per attribute. A
state(…, bind(name))on the same element owns that attribute; an--attr-namefor it is ignored with a diagnostic. - Foreign changes win. If a script changes the attribute after a write, the binding stops writing and does not restore it.
- No loops. Attributes written by Reactive CSS never make reactive rules re-match, and attributes never feed a state, except once at initialization.
- HTML meaning is checked.
disabledis written only where HTML gives it a meaning, such as buttons and form controls; elsewhere you get one warning and no write. - Not in event rules. An
:on()rule describes a moment, so an--attr-*declaration inside it is dropped with a warning. Put it in an ordinary rule.
Setting attributes does not make a component accessible by itself: the roles, the keyboard behavior and the pattern are still yours to get right.
Before 0.9.0, --attr-* names were ordinary custom properties. A stylesheet that already uses names such as --attr-hidden, --attr-disabled or --attr-data-id for another purpose now writes those attributes, in any trusted stylesheet, as soon as Reactive CSS runs on the page. Rename such properties, for example to --x-hidden. This is the one change in 0.9.0 that can alter a page that worked with 0.8.1.
The attribute semantics behind --attr-* have been tested since 0.5.3. The namespace itself is experimental.
DOM text: content
On an element, a content declaration whose value reads state also writes the element’s DOM text.
.counter { --count: state(0);}.counter .inc:on(click) { --count: increment(1);}.counter .value { content: var(--count);}.counter { --count: state(0);}.counter .inc:on(click) { --count: increment(1);}.counter .dec:on(click) { --count: decrement(1);}/* DOM text: the output's text node follows the state. */.counter .value { content: var(--count);}/* A real disabled attribute while the count is 0. */.counter .dec { --attr-disabled: when(--count <= 0);}When content becomes text
All of these must hold:
- The rule targets elements. No selector in its list has a pseudo-element.
::beforeand::afterstay native generated content. - The value reads state. It contains
var(),when(),previous()ordirection(). - The value has no image, counter or attribute. No
url(),image-set(),cross-fade(),element(), gradient,counter(),counters()orattr(). - The stylesheet is trusted, as for event rules.
Everything else is plain CSS and is left alone: .v::before { content: var(--n); }, content: "x", content: counter(n) and content: url(a.png) on an element.
How the text is written
- The native declaration is kept as written. On an element, a string or number
contentrenders nothing in Chromium and WebKit, so the browser shows only the DOM text. - Text, never markup. The value is written into one Text node.
- Existing children are never removed. Only an element that is empty or holds a single text node is written. An element with child elements is left as it is, with a diagnostic.
- Foreign changes win. If something else changes that text or adds children, the binding stops writing and does not restore.
- The original text returns when no
contentrule applies any more. - Unsupported values. The computed value must be one string, number or keyword. A
var()that resolves to an image at runtime is left to the browser without a diagnostic.
Because the result is real DOM text, unlike generated content, it can be selected, copied, found with the browser’s search and read by assistive technology. It is not announced automatically: add aria-live in the HTML where that is wanted, as the counter’s <output> does.
Reactive content is experimental. It is not a general replacement for textContent: it writes scalar values into leaf elements and nothing more. Its interaction with native content has been verified in Chromium and WebKit only; Firefox is unverified.
State from an attribute: state(attr())
attr(name, fallback) as the initial value reads an attribute of the owner once, when the state starts. After that the state and the attribute are independent.
.disclosure { --open: state(attr(data-open, false));}.disclosure:when(--open) .body { display: block;}- The fallback gives the type:
attr(data-open, false)is a boolean,attr(data-n, 0)a number. A missing or invalid attribute gives the fallback. 0.8.1 accepted only string fallbacks. - It reads the owner element only, never an ancestor, and only in trusted stylesheets.
- To keep an attribute in sync with a state, use
state(v, bind(attribute))instead.
Upgrading from 0.8
Code written for 0.8 with bind:, text: or attr: is covered in Migrating from 0.8.