VSN.js guide: state, bindings, and behaviors
VSN keeps the page as the starting point. Write useful HTML, attach behavior to selected elements, and make the parts that change reactive. This guide explains how those pieces fit together; the reference lists the individual APIs.
Behaviors and scopes
A CFS behavior uses a CSS selector to find its roots. Declarations initialize state, functions provide actions, and nested selectors attach behavior to descendants:
#dialog !as(dialog) {
open: false;
.dialog-toggle {
@aria-expanded :< dialog.open;
on click() {
dialog.open = !dialog.open;
}
}
.dialog-panel {
@aria-hidden :< !dialog.open;
}
}
Use !as(name) when a stable alias makes ownership clearer. self, parent, and root refer to the current, parent, and behavior-root scopes. Nested selectors normally match descendants; & substitutes the parent selector, as in &.active. A nested behavior can use !group("name") to expose its live instances as a collection on its parent behavior.
Scopes inherit from their parents. An assignment can update state owned by an ancestor rather than creating an unrelated local copy. Prefer an explicit alias when several nested behaviors have similarly named state.
CFS supports plain objects and arrays. Mutations to nested reactive values are observable as well as replacing a whole value. Keep expressions readable, and remember the whitespace around subtraction.
Bindings and form state
Use explicit direction when you know who owns the value:
vsn-bind:from="name": state writes to the elementvsn-bind:to="name": the element supplies statevsn-bind="name": automatic behavior, including two-way form controls
On display elements, an automatic binding can seed state from existing text during normal mounting or hydration. If the state is already authoritative, :from avoids ambiguity.
Binding values are scope paths, such as profile.name or item.0. Use dotted paths with vsn-bind; bracket indexing belongs in CFS expressions. For plain text output, vsn-text writes textContent. For HTML output, see safe dynamic HTML.
CFS directives express the same direction explicitly:
.profile {
name: "Ada";
active: false;
input {
@value := name;
}
.status {
@class :< { selected: active };
@aria-label :< name;
}
}
@ addresses DOM attributes/properties; $ addresses styles. :< means state to element, :> means element to state, and := means two-way. Try the bindings example and form validation example.
Events and actions
Inline event attributes run CFS in the element's scope:
<form vsn-on:submit!prevent="save();">
<input vsn-bind="name" />
<button type="submit">Save</button>
</form>
Define save() in the containing behavior. Functions are synchronous unless declared async. CFS event blocks are useful when the behavior should live outside the markup:
.profile {
name: "Ada";
saved: false;
save() {
saved = true;
}
on keyup!escape() {
saved = false;
}
}
Flags specialize event handling. !prevent prevents the default action, !stop stops propagation, !once runs once, and !outside listens for an event outside the element. Keyboard and modifier-key flags filter events; !debounce(300) delays a burst of input events. See the event flag reference.
Use native controls and meaningful labels first. Then connect ARIA state to the same state driving visibility. The tabs, modal, and dropdown examples show the surrounding interaction details.
Conditional UI and lifecycle
Choose the lifetime you need:
vsn-show="open"keeps the element mounted and toggleshiddenvsn-if="open"mounts and unmounts it as the condition changes
A conditional element's construct, destruct, and event cleanup follow its mounted lifetime. Use CFS construct { ... } and destruct { ... } blocks, or inline vsn-construct and vsn-destruct attributes, for work tied to binding and unbinding.
Transitions are opt-in:
<div vsn-if="open" vsn-transition="fade">Panel content</div>
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 150ms ease;
}
The runtime applies enter/leave phase classes and waits for a leave before completing removal. vsn-enter and vsn-leave run CFS hooks when their phases start. Keep the containing state declarations in the owning behavior. See fragment lifecycle for dynamic content and cleanup.
Lists and row identity
Repeat the contents of a template with vsn-each:
<ul>
<template vsn-each="users as user, index" vsn-key="user.id">
<li>
<strong vsn-bind:from="user.name"></strong>
<span>Row <span vsn-bind:from="index"></span></span>
</li>
</template>
</ul>
Declare users as an array in the owning behavior. Each row receives item and optional index state. A unique, stable vsn-key preserves row identity through updates and reorders, including focus, form values, animations, and child behaviors. Without a key, rows are recreated. An array index is usually not the identity you want when reordering.
The todo, nested list, and accessible table examples cover different row structures.
Requests and partial pages
vsn-get connects an element or form to a request and optional HTML swap. A form can submit its fields while existing markup displays request state:
<section id="search">
<form vsn-get="/search" method="get"
vsn-target="#results" vsn-swap="inner"
vsn-loading="loading" vsn-error="error" vsn-data="response">
<label>Search <input name="query" value="vsn" /></label>
<button type="submit">Search</button>
</form>
<p vsn-show="loading">Loading…</p>
<p vsn-show="error" vsn-text="error"></p>
<div id="results">Results appear here.</div>
</section>
<script type="text/vsn">
#search {
loading: false;
error: "";
response: "";
}
</script>
Your server supplies /search. vsn-swap="none" makes a data-only request. Other options cover methods, headers, bodies, form selection, history, and focus restoration; see request directives. A non-2xx response does not swap HTML and dispatches vsn:getError.
For custom workflows, use an async CFS function:
use fetch as request;
.profile {
name: "Ada";
async load() {
response = await request("/api/profile");
name = await response.text();
}
}
use resolves an available global; it is not a package import. Custom fetch workflows should handle response status and failure according to the application's needs. Explore server requests and async search.
Safe dynamic HTML
vsn-html and request swaps sanitize untrusted HTML by default. Untrusted fragments do not activate scripts or vsn-* behavior attributes. Prefer text bindings for text, and keep executable behavior in application-owned markup.
Explicit trust is for fragments under your application's control:
<button type="button" vsn-get!trusted="/trusted/fragment"
vsn-target="#panel">Load fragment</button>
<div id="panel"></div>
Trusted fragments can contain VSN behavior scripts, which the engine processes. Trust is a security boundary, not a convenient way to make a sanitizer complaint disappear. Never use it for arbitrary user-supplied content.
The templates plugin adds the html tagged template for composing HTML in CFS. The sanitize plugin configures the sanitizer, including DOMPurify when available. Neither makes untrusted executable behavior safe merely by existing.
Server rendering and hydration
Serve semantic HTML, initial text, and form values from the server. The ordinary auto-mount path enhances those elements in place. For an explicit hydration path, register your behavior source and pass the serialized state to hydrate():
import { Engine } from "vsn";
const engine = new Engine();
engine.registerBehaviors("#profile { name: 'Ada'; }");
await engine.hydrate(document.getElementById("profile"), {
state: { name: "Grace" }
});
Hydration preserves existing server-rendered values when a client state path has not been initialized. Provided state is applied to the mounted root scope. Use either explicit initialization or automatic mounting for the same page; do not mount it twice hoping for extra reactivity.
Reusable behaviors
A reusable behavior needs a clear markup contract: selectors, expected state, emitted events, accessibility requirements, and cleanup. Keep one CFS behavior per module and use a stable namespaced class:
<section class="acme-dialog">
<button class="acme-dialog__trigger" type="button">Toggle</button>
<div class="acme-dialog__panel" vsn-show="open">Content</div>
</section>
<script type="text/vsn" src="/behaviors/dialog.vsn"></script>
The external .vsn file supplies plain CFS source. The loader also accepts .cfs; see inline and external sources. Reserve the vsn- class prefix for first-party behavior libraries. Continue to the reference for extension points, or use the examples as small implementation starting points.