on:event event binding attribute
- Value
- Name of a declared
<handler>(not an expression). - Modifiers
.prevent.stop.once.enter(and other key names)- Semantics
- Adds a listener; the browser never executes the attribute as script.
- Level
- L1
One consistent family of attribute bindings, all ordinary attribute names under the real HTML parser, so the same source is browser-parseable and framework-compilable. Behavior stays local to the element — you can tell what an element does by reading the element itself (locality of behaviour) — but typed and reactive, never a string reaching into a global.
Level 1 · reserved directionAn unprefixed attribute supplies a fixed literal. A declared component prop parses that literal according to its type; an undeclared or native attribute keeps its ordinary HTML meaning. A prefix marks a binding. The colon is verified across Chromium, Firefox, and WebKit to be an ordinary attribute-name character (not an XML namespace), so every form survives the parser and round-trips through outerHTML.
The shapes have direct framework precedent3: from:attr supplies values to an element as Vue's v-bind does, while bind: (two-way) and on: (event) match Svelte verbatim. Angular [(ngModel)], its banana-in-a-box, is the canonical one-way-in-plus-event-out two-way model that the bind: writability rules follow.
| Form | Meaning |
|---|---|
attr="…" | Fixed literal. A declared component prop parses it using its type; an undeclared or native attribute follows HTML's attribute rules. |
from:attr="expr" | One-way expression binding, evaluated and checked against the element contract. The target updates when a prop, state value, or other dependency changes. |
bind:prop="path" | Two-way binding to a writable path. |
on:event="handler" | Event binding to a declared handler. |
class:token="expr" / style:prop="expr" | Live bindings: toggle one class or set one style property, then update it whenever the expression's dependencies change. |
$key="expr" | List identity for reactive reconciliation (a $each modifier). |
For example, when a component declares count as number and point as object, both plain attributes produce typed values without a colon:
<x-plot count="3" point="{ x: 3, y: 5 }"></x-plot>
<x-plot from:count="nextCount" from:point="{ x: currentX, y: 5 }"></x-plot>The first invocation supplies fixed values. The second evaluates expressions that read nextCount and currentX. A plain structured value uses HTML Next's object literal syntax, not JSON; references such as currentX require from:. The prefix selects reactive expression evaluation, not a data type.
A live binding writes only a result that has its destination's declared type. If one evaluation has the wrong type, it leaves the destination at its last successfully written value; before the first successful write, the destination retains its default or null. The binding still watches its dependencies and can write again when the result becomes valid. This rule applies equally when the expression is a direct reference or a function call. A value of the right type that fails min, max, or another value constraint does get written and sets validity. Reactivity shows the full sequence.
from:x, class:x, and style:x all subscribe to the values their expressions read and recompute when those values change. bind:x also writes changes back; on:x invokes a handler. The suffix names the affected attribute, property, event, class, or style property.
These forms answer two separate questions: is the supplied text a literal or an expression, and when is it used? A literal is parsed through the destination's declared type; it is not necessarily a JavaScript string.
| Form | Interpretation | When it takes effect |
|---|---|---|
value="2" | Constant literal; a number destination receives JavaScript 2, while a string destination receives "2". | A declaration initializes, or a handler uses the constant when it runs. |
expr:value="count + 1" | Expression evaluated against the component's current values. Its result must satisfy the destination's declared type. | Once each time a <set> or <dispatch> step runs. It creates no subscription. |
from:value="draft.title" | Computed value with a live dependency on draft.title. | Recomputed when that dependency changes; the receiving element's effect then runs. |
For a <set expr:value> handler step, a wrong-typed result skips that state write. The handler can run again later; there is no subscription. A from: binding, by contrast, re-evaluates on every dependency change and leaves its previous value in place during an invalid evaluation.
<state name="count" type="number" value="1"></state>
<state name="post" type="object({ id: string })" value="{ id: '42' }"></state>
<state name="draft" type="object({ title: string })" value="{ title: '' }"></state>
<state name="revision" type="integer" value="0"></state>
<handler name="increment">
<set name="count" expr:value="count + 1"></set>
</handler>
<event name="publish" type="object({ title: string })"></event>
<handler name="publish">
<dispatch event="publish" expr:value="draft"></dispatch>
</handler>
<data name="saveDraft" method="patch" src="/api/drafts/{id}" send="change">
<param name="id" from:value="post.id"></param>
<param name="title" from:value="draft.title"></param>
<param name="clientRevision" expr:value="revision"></param>
</data>The title parameter recomputes as the draft changes. The data resource's send="change" policy turns a changed from:value body parameter into a write; a read resource instead refetches. clientRevision is sampled for that write but changing revision alone does not schedule one. The dispatch runs only when its handler is invoked. from: on a dispatch would mean a live effect and would send on a dependency change, so handler steps use expr:value for expressions.
from:name="expr" binds an attribute or a contract-declared property. It normalizes before resolving: strip from:, ASCII-lowercase the remainder, look the key up in the generated platform manifest, and assign using the returned canonical spelling, which may be an attribute or a DOM property. The expression is evaluated when the element is created and again whenever its dependencies change. A dependency can be a prop or a state value. There is deliberately no separate raw-property syntax: the manifest is authoritative, so an author never hand-picks an exact IDL name, and no binding reaches an arbitrary DOM property outside the contract.
Replacing an element's content is not a binding but a templating directive: escaped text is $value, sanitized markup is $html. Inline {expression} inserts escaped text among surrounding content without replacing the element's contents. Raw, unsanitized HTML is available only through the dedicated trusted-HTML type (see Types), never an ordinary string.
A computed value could declare both its live read and the action to take when a binding writes to it:
<state name="fraction" type="number" value="0.25"></state>
<computed name="percentage"
read="$fraction * 100"
write="fraction: $value / 100"></computed>
<x-stepper bind:value="percentage"></x-stepper>The read expression would recompute when $fraction changes. The bind:value target is the concrete writable path percentage; it is not an expression. When the component reports a new numeric value through bind:value, the write expression would set the writable fraction state. This keeps the inverse mapping in one declaration and reuses bind: at the call site, instead of pairing from:value and to:value on each invocation. The fraction: part is a proposed writable destination, not general expression assignment.
This syntax is exploratory. Design still needs to choose read versus the existing <computed from> spelling, define the component event that supplies $value, check the write result against the destination type, and prevent feedback loops. A read expression need not have an inverse: for hasQuery = query != '', writing false can clear the query, but writing true cannot reconstruct text that was never supplied. Level 1 computeds remain read-only and cannot be bind: destinations.
A binding evaluates to a typed value, and how that value lands depends on the value and the attribute's kind (the generated manifest carries each attribute's kind). One default rule covers almost everything:
| Value | Result on the attribute |
|---|---|
false · null · undefined | removed (the "no value" signal) |
true | present, with an empty value |
| a string | set verbatim, an empty string is present-but-empty, distinct from removed |
| a number | stringified |
| a list | space-joined, for token-list attributes |
The false/null → removed rule is what makes boolean attributes work with no special case: from:disabled="isDisabled" adds disabled when true and removes it when false, never the disabled="false" trap (which is actually disabled). When you want the characters "false", bind a string, from:data-state="'false'", or an identifier that resolves to one.
URL attributes (href, src, action, …) are stringified and then have dangerous schemes stripped, the same posture the sanitizer applies to $html, so a bound javascript: URL is dropped. Enumerated true/false attributes (the aria-* family, contenteditable) take the literal strings "true"/"false" rather than presence, so a bound boolean coerces to that string: from:aria-expanded="isOpen" yields aria-expanded="false" when closed rather than removing it. Both are the manifest doing the work; the author writes the same from:attr either way.
Because the mapping is fixed and manifest-driven, every target serializes identically, the equivalence contract applied to attribute writes: the polyfill and the React/Vue/Svelte outputs each compile to their own idiom while producing the same observable attribute.
bind:prop="path" reflects the value and writes user input back to path. It is the forms workhorse, and it requires a writable path plus an element contract that supports updates.
<input bind:value="search.query">
<input type="checkbox" bind:checked="filters.inStock">A writable path is a member or index access chain rooted at a <state> cell, the only mutable source. Everything else is read-only, and bind: to it is a conformance error, caught statically because the root's declaration is known:
| Path | bind: | Why |
|---|---|---|
draft.title, rows[i].done | writable | rooted at <state>; a plain access chain |
a <computed> | error | derived; write its inputs instead |
a <data> .value | error | a fetched resource is read-only |
| a prop | error (inside the component) | props are one-way in; a component surfaces two-way by exposing a bindable prop and <dispatch>ing changes, which the consumer binds with bind: on the invocation |
a + b, x | filter | error | an expression is not an assignable location |
Writability flows from the root: a $each local or $with alias is writable exactly when it aliases a writable path, an item of a <state> collection is, an item of a <data> collection is not. Writing a sub-path updates that path in the state cell and re-runs its dependents; the implementation may model state as mutable-with-tracking or as a structural update, the observable result is the same.
A GET <data> result is read-only, so you do not bind: to it. Copy the fetched value into a <state> draft and bind controls to the draft. If that draft should autosave, a writable <data method="patch" send="change"> observes the fields it sends. The fetched value, in-progress edit, and write effect remain distinct.
Binding distinguishes an input's initial value attribute (serialized/default state) from its live value property (current control state). from:value updates the attribute from an expression; bind:value tracks the live property.
Conditional presentation uses keyed live bindings, one class token or style property at a time. Like from:, each binding runs when the element is created and again when a prop, state value, or other dependency read by its expression changes. class:btn--busy="$saving" adds or removes the class as saving changes; style:--progress="concat($pct, '%')" updates that property as pct changes. The expression language defines + for numbers only; concat produces a string. The key is the attribute name (a literal class token or CSS property) and the value is a single pure expression, so nothing packs a key/value list into one attribute value.4 Both compose with any literal class or style. Purely visual transforms remain CSS's job (see Styling); these bindings update presentation keys.
<!-- one class or style property per keyed binding; the value is a single pure expression -->
<button class="btn" class:btn--busy="$saving" class:btn--danger="$variant = 'destructive'">
<div style:--progress="concat($upload.percent, '%')"></div>on:event="handler" wires a DOM event to a declared handler. The form echoes native onclick and joins the binding family, but the browser does not execute on:click as script: the value names a <handler>, it is not an expression. Behavior and visible markup stay cleanly separated, the handler lives in the definition's <defs> region (see Components), the content just points at it by name.
<!-- content references behavior by name; no steps inline -->
<button type="button" on:click="startEdit">Edit</button>
<!-- An internal button can ask the component's owner to run a command. -->
<button type="button" on:click="requestPublish">Publish</button>
<!-- behavior lives in the definition's <defs> region -->
<defs>
<handler name="startEdit">
<set name="editing" value="true">
</handler>
<handler name="requestPublish">
<dispatch event="publish" expr:value="draft">
</handler>
</defs>The event that invoked a handler is referenced through $$event. It is the native event supplied to the listener, with that event's ordinary properties: for example $$event.key for a keyboard event, or $$event.detail for a component CustomEvent. The name is reserved and read-only. It is available in that invocation's handler expressions and guards, not in a persistent template binding or computed state.
<event name="activate" type="event"></event>
<handler name="requestActivation">
<dispatch event="activate" expr:value="$$event"></dispatch>
</handler>
<button type="button" on:click="requestActivation">Activate</button>Component dispatch creates a native CustomEvent whose detail is the dispatched value.6 The receiving handler's $$event is that component event, and $$event.detail is its payload. In this example the payload is the original click event, passed by reference. The event type checks that payload against the native Event interface; a payload of another type is still checked against its event declaration. See Native event values.
The payload can also select incoming data or combine it with component state:
<!-- Forward an incoming CustomEvent's payload. -->
<dispatch event="selection" expr:value="$$event.detail"></dispatch>
<!-- Combine the native event with the component's current selection. -->
<dispatch event="selection-with-source" expr:value="{ source: $$event, item: $selectedItem }"></dispatch>
<!-- Store selected event fields in mutable state. -->
<set name="interaction.pointer" expr:value="{ x: $$event.clientX, y: $$event.clientY }"></set>Each destination retains its declared type checks. A structure may explicitly retain the event itself, but that native object does not become reactive: state observes the assigned value, not changes to the event's internal fields. Selected scalar fields are sampled when the step runs. Native dispatch-time fields keep native behavior; for example currentTarget is cleared when dispatch finishes.6
Nested dispatch invokes another handler with its own $$event; returning to the outer handler restores its triggering event. Forwarding a payload does not redispatch the source event, automatically unwrap nested details, or couple cancellation of the new event to cancellation of the source. A controller that receives the source event can use its native operations during synchronous dispatch. The component event's detail is the payload itself, not a second { detail: payload } object.
This follows native CustomEvent, Lit's component-event convention, and Svelte's earlier typed createEventDispatcher API.678 The $$event expression spelling is an HTML Next addition.
A <dispatch> without target dispatches from the current component's root. Its optional target names a $ref declared in that same component definition. Each instance resolves its own refs; target is not a DOM ID, selector, or expression, and does not change native id or commandfor behavior.
<defs>
<event name="validate" type="unknown"></event>
<event name="show-toast" type="object({ message: string, tone: string })"></event>
<handler name="checkCustomer">
<dispatch target="customer" event="validate"></dispatch>
</handler>
<handler name="notifySaved">
<dispatch target="notifications" event="show-toast"
expr:value="{ message: 'Changes saved', tone: 'success' }"></dispatch>
</handler>
</defs>
<button type="button" on:click="checkCustomer">Check customer</button>
<ui-combobox $ref="customer"></ui-combobox>
<ui-toast-region $ref="notifications"></ui-toast-region>The targeted element receives a native CustomEvent. Its controller may listen with host.on('validate', callback), or its root may bind on:validate to a declarative handler. Dispatch does not discover or invoke a JavaScript function by name. The dispatching component's event declaration supplies the payload type check and native event flags, just as for an untargeted dispatch.
A ref inside $each identifies a collection. A targeted dispatch sends a separate native event to every currently rendered element in that collection, in rendered order. The payload expression is evaluated and checked against its declared type once for the handler step, including when the collection is empty; each event's detail holds that same value. A receiver may mutate a shared object payload; delivery to later receivers does not repeat the sender's type check. The target list is sampled before invoking the first listener: newly rendered targets do not join that step, and targets removed before their turn are skipped. Each event has independent propagation and cancellation; canceling one does not cancel dispatch to another target. The original triggering event remains $$event throughout the step.
An undeclared ref name is an authoring error. A declared ref that currently renders no element, including an empty collection or a false $if branch, dispatches nothing. Props and context remain the channels for reactive inputs; targeted events request a discrete interaction, and ordinary component events can report its outcome.
A handler is an ordered, enumerable list of declarative steps. The vocabulary is deliberately tiny and never names a userland JavaScript function. A <dispatch> uses the native event channel, which a controller or another DOM listener can observe.
| Step | Effect |
|---|---|
<set name value> or <set name expr:value> | Write a local state cell. value is a typed constant; expr:value is evaluated when the handler runs. |
<dispatch event target? value?> or <dispatch event target? expr:value?> | Dispatch a native component event from the current root or to a component-local ref, with an optional typed constant or action-time expression as its payload. |
$if (on a step) | Guard a step; it runs only when the expression is truthy, the same $if directive used in templating. |
State changes live in <handler> steps or bind:, never inline in the template. Assignment is absent from the expression language, so expressions stay pure. Reactive network synchronization belongs to a declared <data> effect; a component that requests a one-shot command dispatches an event2 and lets its owner decide whether to submit a form or call imperative code. The payoff is that a component's entire declarative behavior is enumerable from its markup.
Opening a dialog or toggling a popover is not component logic, it is a platform verb on a specific element. The platform already ships the declarative answer: invoker commands (command / commandfor), shipped in Chromium, WebKit, and Firefox.1 Use them directly, HTML Next adds nothing here.
<!-- platform verbs: native invoker commands, no <handler> block needed -->
<button command="show-modal" commandfor="editor">Edit</button>
<dialog id="editor">…</dialog>
<button command="toggle-popover" commandfor="menu">Menu</button>
<div id="menu" popover>…</div>These are different jobs: the native command/commandfor attributes invoke a built-in platform verb on a target element by id (show-modal, toggle-popover), while a <handler> runs your own state logic from on:event. Reach for the invoker attributes for element verbs; reach for a handler for state.
A handler runs as declarative steps, so the operations you would otherwise call on the event object, preventDefault, stopPropagation, and the addEventListener options, have no imperative place to live. They ride the event name as dotted modifiers, each naming a real DOM operation: on:submit.prevent, on:click.stop, on:click.once, on:scroll.passive, on:click.capture. The dotted syntax itself is Vue's prior art, not an HTML native; key filters such as on:keydown.enter are convenience layered on top. A component declares emitted events in its contract; a consumer listens with the same form: <x-dialog on:saved="refresh">, where refresh is one of its own handlers. The same on: family carries the lifecycle events on:connect/on:disconnect (see Lifecycle).
$key="expr", a modifier on $each (see Templating), gives each iterated item a stable identity, so reactive updates and reordering are correct rather than index-positional. Keyed reconciliation is well-trodden prior art5: React introduced key, and Vue, Svelte, Angular, and Lit each carry their own form.
Frameworks expose a ref handle so imperative code can reach a node. HTML Next has no imperative handler, so there is nothing to dereference, and each concrete use of a ref maps to a declarative form:
What a ref was for | HTML Next |
|---|---|
inputRef.current.value (read a control) | it is already state: bind:value="draft.title" |
ref.current.focus() on mount | autofocus |
dialogRef.current.showModal() | <button command="show-modal" commandfor="dlg"> |
| toggle a popover | <button command="toggle-popover" commandfor="menu"> |
| play / pause media | invoker commands for media (emerging) |
| scroll into view, measure, other imperative calls | open: no declarative form yet, a genuine platform gap |
The pattern: reading is bind:; platform verbs are invoker commands addressed by id (the way label[for] and [aria-controls] already cross-reference); and the residual imperative cases are an acknowledged open area, not a hidden ref.
Inline on* handlers (the browser executes them) and other frameworks' directive prefixes (@, v-, #, ., use:, transition:, animate:) are non-conforming as literal attributes. Because there is no raw property binding, the dangerous sinks (innerHTML, outerHTML, srcdoc, on*) are simply unreachable by a binding: markup is set with $html (sanitized) or the trusted-HTML type, never a string assigned to a property.
<handler> (not an expression)..prevent .stop .once .enter (and other key names)name<defs> region, alongside <state> and <data>.<set> and <dispatch>, each optionally guarded with $if. Ordered; no route to arbitrary code.command/commandfor); see also MDN: Invoker Commands API.dispatch verb behind <dispatch>).from:attr); Svelte bind: and on: element directives (two-way and event, matched here verbatim); Angular two-way binding (the [(ngModel)] banana-in-a-box: one-way-in plus event-out, the model behind the bind: writability rules).:class="{ open: x }" and Lit classMap/styleMap pack a key/value map into one attribute value; Angular [class.x]/[style.x]/ngClass and Solid classList are the same keyed idea.trackBy), and Lit repeat with a key function.detail; dispatch-time fields retain their native lifetime.CustomEvent.detail.detail payloads. This earlier API is deprecated in favor of callback props and the $host() rune; it is historical precedent, not a dependency of this proposal.