Appearance
Template syntax
Volt has exactly one piece of dynamic syntax in markup: a : prefix. Structure, events, and bindings all share it.
How a name is resolved
:name is resolved in a fixed order, so a given name always means one thing:
- Structural directives —
if,else-if,else,for,key,text,html,model,ref,slot - Explicit escapes —
:on-*(event),:prop-*(property),:attr-*(attribute) :classand:style, which have their own merge semantics- A known DOM event name → an event listener
- Anything else → a property or attribute binding
So :click is an event because click is a DOM event, and :value is a binding because value is not. When in doubt — a custom event on a component, a property that shares a name with an event — use the escape.
Interpolation
html
<p>Hello, { name.get() }.</p>Expressions are real JavaScript, parsed to an AST. Free identifiers resolve to the component instance; loop bindings and a fixed set of globals (Math, JSON, Date, console, …) do not.
A constant interpolation is evaluated at build time and baked into the markup: { 2 + 3 } compiles to the text 5 with no effect at all.
Structure
:if / :else-if / :else
html
<p :if="count.get() > 5">Lots</p>
<p :else-if="count.get() > 0">Some</p>
<p :else>None</p>Conditions are tested in order and short-circuit; only the winning branch is built. Leaving a branch disposes its effects and removes its DOM.
:else-if and :else must directly follow the matching element.
:for and :key
html
<li :for="todo in todos.get()" :key="todo.id">{ todo.text }</li>With an index:
html
<li :for="(todo, i) in todos.get()" :key="todo.id">{ i }. { todo.text }</li>Destructuring works, and stays reactive — each bound name becomes its own accessor rather than a snapshot:
html
<li :for="{ id, text } in todos.get()" :key="id">{ text }</li>Rows are keyed. A row that survives a change is never rebuilt — its item and index update in place, so only the bindings that actually read them re-run. Reordering a list moves existing elements rather than recreating them.
:key is required. Neither possible default is safe, so the choice is yours every time:
| meaning | |
|---|---|
:key="item.id" | a stable identity that survives the item object being replaced |
:key="$index" | positional reuse, for a list that is never reordered |
Keying by position strands DOM state on the wrong row after a reorder — the text updates correctly while focus, input values and animations stay behind. Keying by object identity is correct on reorder but rebuilds the whole list when data is refetched as equal-but-new objects. A :for without :key is a compile error naming both options.
:text and :html
html
<p :text="message.get()"></p>
<article :html="rendered.get()"></article>Both replace the element's children. :html does not sanitise — never pass untrusted input.
:ref
html
<input :ref="inputEl" />Assigns the element to this.inputEl. If that property holds a Signal.State, the element is .set() into it instead. Cleared on unmount.
:model
Two-way binding to a Signal.State:
html
<input :model="name" />
<input type="checkbox" :model="agreed" />
<select :model="choice">…</select>Modifiers: .trim, .number, .lazy (sync on change instead of input).
Events
Any known DOM event name:
html
<button :click="save()">Save</button>
<input :input="onInput($event)" />
<form :submit.prevent="submit()">…</form>$event is available in inline expressions. An expression that is a bare reference or an arrow function is used as the handler directly; anything else is wrapped so it runs on each event.
html
<button :click="handler">…</button> <!-- used as-is -->
<button :click="() => save(1)">…</button> <!-- used as-is -->
<button :click="save(1)">…</button> <!-- wrapped -->Modifiers
| Modifier | Effect |
|---|---|
.stop | stopPropagation() |
.prevent | preventDefault() |
.self | Only when event.target is this element |
.capture .once .passive | Listener options |
.ctrl .alt .shift .meta | Only with that modifier key held |
.enter .escape .tab .space .up .down .left .right .delete .backspace | Only for that key |
html
<input :keydown.enter="submit()" :keydown.escape="cancel()" />
<div :click.self.stop="close()">…</div>How listeners are attached
Events that bubble are delegated: Volt installs one listener per event type on the document and dispatches by walking up from the target. A thousand-row table with two handlers per row costs two listeners, not two thousand, and adding or removing rows never touches the listener registry.
This is mostly invisible, with three consequences worth knowing:
- DevTools shows no listener on the element. The handler is stored on the node under a
$$click-style property; the listener lives ondocument. event.currentTargetis set to the element the handler was attached to during the walk, so.selfand manual comparisons behave as written.stopPropagation()works, including from a.stopmodifier — it halts the walk as well as native bubbling.
Delegation is skipped, and a real listener attached, when:
- the event does not bubble —
focus,blur, media and animation events - you use
.capture,.onceor.passive, which need real listener options
You never choose between them; the compiler picks based on the event name and modifiers.
Custom and component events
:on-* forces an event listener for names Volt does not recognise:
html
<my-element :on-custom-thing="handle($event)"></my-element>This is a real addEventListener, so it applies to elements and web components. Volt components have no event channel — they notify a parent through a callback passed in as an input:
html
<v-counter :onChanged="(n) => onCount(n)"></v-counter>Using :on-* on a Volt component throws, naming the callback prop to use.
Bindings
html
<input :value="text.get()" :disabled="busy.get()" :placeholder="hint" />Volt writes the IDL property when the element has one and falls back to the attribute otherwise. Boolean attributes are removed when falsy, never set to "false".
Force the choice when you need to:
html
<div :attr-data-id="id.get()"></div>
<my-element :prop-config="config.get()"></my-element>:class
Accepts a string, an array, or an object:
html
<div class="card" :class="{ active: isActive.get(), done: isDone.get() }"></div>
<div :class="['a', 'b']"></div>
<div :class="theme.get()"></div>Classes written literally in class are preserved — only what the binding added is ever removed.
A constant :class is folded into the markup: :class="'btn'" becomes class="btn" with no runtime cost.
:style
html
<div :style="{ color: color.get(), fontWeight: 'bold' }"></div>
<div :style="'color: red'"></div>Camel-case keys are hyphenated. Properties the binding previously set and no longer includes are removed.
There is no :show directive — hiding an element is a style binding, and an empty string lets the stylesheet decide the visible value:
html
<div :style="{ display: visible.get() ? '' : 'none' }">…</div>Prefer :if when the content is expensive or genuinely absent; the element is then not in the DOM, the accessibility tree, or the tab order at all.
:spread
html
<div :spread="attrs.get()"></div>Slots
html
<!-- v-card -->
<div class="card">
<header><slot name="title">Untitled</slot></header>
<main><slot></slot></main>
</div>html
<v-card>
<h1 :slot="'title'">Hello</h1>
<p>Body</p>
</v-card>Content without :slot goes to the default slot. A <slot>'s children are its fallback, used when nothing is projected.
Grouping without an element
<template> groups nodes without producing DOM:
html
<template :if="ready.get()">
<h2>Title</h2>
<p>Body</p>
</template>Comments and whitespace
Comments are stripped. Whitespace is condensed the way Vue does it: whitespace-only text between elements that spans a line break is removed, and other runs collapse to a single space. <pre>, <textarea>, <script>, and <style> are left alone.
:portal
Render an element into a different container. Overlays need this: a dialog nested inside a scrolling panel has to escape that panel's overflow: hidden and its stacking context.
html
<div :portal>…</div> <!-- document.body -->
<div :portal="'#modals'">…</div> <!-- CSS selector -->
<div :portal="container">…</div> <!-- an element -->Nothing is left at the declaration site — the surrounding markup compiles exactly as if the element had not been written there.
Two things hold regardless of where the content lands, because Volt tracks both on the reactive scope rather than on the DOM tree:
- Context resolves from where the content was declared. A dialog inside a provider still sees it, even though it renders under
<body>. - Disposal follows the declaring component. Unmounting it removes the portalled content too, which is the leak portals otherwise make easy.
Combine with :if to mount and unmount the overlay:
html
<div :if="open.get()" :portal>…</div>The target is read once. Re-homing live content to a new container is not something an overlay needs, and supporting it would cost every portal a move path it never uses.