Prefix Convention
On this page 37
stx uses three distinct prefixes for template attributes. Each prefix has a specific role. Learning these three prefixes is all you need to build interactive UIs.
| Prefix | Role | Example |
|---|---|---|
: | Directives (control flow) | :if, :show, :for, :key |
x- | Bindings (data to DOM) | x-text, x-class, x-href, x-model |
@ | Events (user actions) | @click, @submit, @keydown.enter |
Why Three Prefixes?
Vue uses : (v-bind shorthand) for both bindings and directives. This conflates two different concepts:
<!-- Vue: ':' means different things in different contexts -->
<divclass=""v-if=""></div>
<!-- stx: each prefix has one meaning -->
<divx-class=""if=""></div>
In stx:
:always means "control whether/how this element renders"x-always means "bind data to a DOM property"@(as an attribute) always means "listen for a user event"
The
@symbol is also used for Blade-style block directives —@if,@foreach,@auth, etc. Those are statements in the markup, not element attributes. The rule of thumb:@name(...)as a standalone statement is a directive;@event="…"as an attribute on an element is a client event listener. See the conditionals section below for how@if,v-if, and:ifrelate.
Component props: prop={expr} is :prop="expr"
On a component tag, a braced attribute value is the JSX spelling of a dynamic prop. stx rewrites it to the colon form before evaluating, so the two below are identical:
<ForminitialValues="" />
<ForminitialValuesname: ''notifications:true }} />
The outer braces delimit the expression; ={{ ... }} therefore unwraps to the
object literal { ... }. Nested braces and braces inside quotes are handled,
and the value may span lines.
It is a prop, not an event. A server-evaluated prop cannot reference a
function that only exists in a <script client> block, so a handler still uses
the event prefix:
<Formsubmit="" /> <!-- not onSubmit={handleEditProfile} -->
Conditionals: @if, v-if, :if are the same thing in different syntax
@if, v-if, :if, and x-if are interchangeable sugar for one
conditional — not three separate lifecycles. Pick the spelling you like:
| Form | What it is |
|---|---|
@if (cond) … @elseif … @else … @endif | Blade-style statement (the canonical form) |
v-if="cond" / v-else-if / v-else | Vue sugar — compiles to @if |
:if="cond" / :else-if / :else | attribute form |
x-if="cond" / x-else-if / x-else | Alpine sugar — same as :if |
What decides whether a conditional is reactive is the data it reads, not the
keyword you typed. On a page that uses signals (<script client> with
state() / derived() / …):
- A condition that reads a signal is wired into the client signals runtime
(
bindIf/bindIfChain) and re-evaluates whenever that signal changes — including@if/@elseif/@elseandv-if/v-elsechains, which are promoted to the reactive runtime form just like:if/:else. - A condition over server data (
$user, env, a@foreachloop variable) is evaluated once on the server and the unmatched branches are dropped from the HTML. On a page with no signals at all, every conditional renders once on the server.
<script client>
const loading = state(true)
</script>
@if($user) {{-- server data → rendered once --}}
{{-- both of these re-render when `loading` flips; the keyword is just style --}}
<span:if="loading">loading</span>
@if(loading) <span>still loading</span> <span>done</span>
So v-if is sugar for @if, x-if is sugar for :if, and a signal-driven
@if/v-if chain behaves exactly like the :if/:else equivalent. Use whichever
reads best in context.
Two gotchas worth internalizing:
$is not decoration.@if($user)reads a<script server>variable;:if="loading"reads a client signal scope. Same conditional, but they look in different places — a name that exists in one scope may not exist in the other.- Reading signals in a condition follows the signals call/bare rule. In a
template attribute the auto-unwrap proxy lets you write the bare name
(
:if="loading"); call syntax (:if="loading()") also works because the binder retries evaluation without the proxy (#1733). Both are fine — but if you ever see an element silently stay hidden, suspect a signal/function mix-up here first.
Directives (: prefix)
Directives control the structure and visibility of elements. They determine whether an element exists or how it repeats.
:if -- Conditional Rendering
Adds or removes the element from the DOM based on a condition.
<divif="">Welcome back</div>
<divif="">Please log in</div>
When the condition is false, the element is completely removed from the DOM (not just hidden).
:show -- Conditional Visibility
Toggles the element's visibility via display: none. The element stays in the DOM.
<divshow="">
This content is hidden but still in the DOM when isOpen() is false.
</div>
Use :show when you toggle frequently (cheaper than adding/removing DOM nodes). Use :if when the element is rarely shown or has expensive children.
:for -- List Rendering
Repeats an element for each item in an array.
<ul>
<lifor=""key="">
<spanx-text=""></span>
</li>
</ul>
Always use :key with :for to help the runtime efficiently update the list.
Supports destructuring and index:
<divfor=""key="">
<spanx-text=""></span>. <spanx-text=""></span>
</div>
:for over <script server> data runs on the server
:for is normally expanded by the signals runtime in the browser. When its
iterable is a <script server> binding, it is expanded on the server
instead, as if it had been written @foreach (stacksjs/stx#2051).
<scriptserver
const serverRows = [{ id: 1, name: 'alpha' }]
</script>
<!-- These three are equivalent, and all render. -->
<ul><lifor="">{{ row.name }}</li></ul>
<ul><templatefor=""lirownamelitemplateul
<ul>@foreach(serverRows as row)<li>{{ row.name }}</li>@endforeach</ul>
This is the same per-expression decision @if makes, and the same one
@foreach already made in the other direction: an @foreach over a signal
becomes a client :for, and a :for over server data becomes a server loop.
The data decides where the loop runs, not the keyword.
What stays on the client, deliberately:
| iterable | runs | why |
|---|---|---|
a signal — rows, rows() | client | reactivity is the point of :for |
a literal — [1, 2, 3] | client | already worked; moving it gains nothing |
| a name in neither scope | client | so a typo still reaches the runtime warning |
| a signal that shadows a server name | client | the signal is what you are iterating |
Before this, a :for over server data rendered zero rows — not an error,
just an empty list with one console warning that guessed at signals. One app
read that warning, concluded the signals-and-:for pattern was unreliable, and
was rebuilt on hand-written getElementById wiring. The warning now names the
cause when a name really is out of scope, and this case no longer produces one.
:key -- Identity for List Items
Tells the runtime how to track items across re-renders. Must be a unique, stable identifier.
<!-- Good: unique ID -->
<lifor=""key=""></li>
<!-- Bad: index as key (breaks reordering) -->
<lifor=""key=""></li>
Complete Directives Table
| Directive | Purpose | Example |
|---|---|---|
:if | Conditional rendering (add/remove DOM) | :if="count() > 0" |
:show | Conditional visibility (toggle display) | :show="menuOpen()" |
:for | List rendering | :for="item in items()" |
:key | Unique identity for list items | :key="item.id" |
Bindings (x- prefix)
Bindings connect reactive data to DOM attributes and properties. When the signal changes, the DOM updates automatically.
x-text -- Text Content
Sets the text content of an element.
<spanx-text=""></span>
<px-text=""></p>
<tdx-text=""></td>
x-html -- HTML Content
Sets the innerHTML of an element. Use with caution -- only with trusted content.
<divx-html=""></div>
Security warning: Never use
x-htmlwith user-provided content. It can introduce XSS vulnerabilities.
x-class -- Dynamic CSS Classes
Adds or removes CSS classes based on conditions.
Object syntax (recommended):
<divx-class="">
Styled element
</div>
String syntax:
<divx-class="">Themed element</div>
You can combine x-class with static class:
<buttonclass=""x-class="">
Toggle
</button>
x-style -- Dynamic Inline Styles
Sets inline styles from an object.
<divx-style="">
Styled text
</div>
x-model -- Two-Way Binding
Binds a form input to a signal. Changes to the input update the signal, and changes to the signal update the input.
<scriptclient
const name = state('')
const agreed = state(false)
const color = state('blue')
</script>
<inputtype=""x-model=""placeholder="" />
<p>Hello, <spanx-text=""></span></p>
<label>
<inputtype=""x-model="" />
I agree to the terms
</label>
<selectx-model="">
<optionvalue="">Red</option>
<optionvalue="">Blue</option>
<optionvalue="">Green</option>
</select>
<p>Selected: <spanx-text=""></span></p>
x-href -- Dynamic URL
Sets the href attribute reactively.
<ax-href="">View Profile</a>
x-src -- Dynamic Image Source
Sets the src attribute reactively.
<imgx-src=""alt="" />
x-cloak -- Hide Until Hydrated
Hides an element until the signals runtime has processed it. Prevents flash of unprocessed content.
<style
[x-cloak] { display: none !important; }
</style>
<divx-cloak>
<spanx-text=""></span> <!-- Won't flash "greeting()" before hydration -->
</div>
stx also adds x-cloak for you
You rarely write it. stx stamps it automatically, and the rule decides whether your markup is visible before hydration, so it is worth knowing:
| What you wrote | Cloaked? |
|---|---|
<button>{{ busy() ? 'Sending…' : 'Send' }}</button> | Yes — reactive text cloaks the element holding it |
<button :disabled="busy()">Send</button> | No — attribute bindings never cloak |
<div :if="ready()">…</div> | Yes — conditionals and :show cloak |
<div class="panel"><button>{{ label }}</button></div> | Only the <button>, never the panel |
Reactive text cloaks. Attribute bindings do not. A {{ }} that the server
cannot resolve would otherwise paint as literal braces, so the element is hidden
until the runtime fills it in.
The catch is that x-cloak is display: none, so anything cloaked is gone
until the bundle runs — and stays gone if the bundle never arrives. For a label
that is the right trade. For a control, it means the button your user is
meant to press does not exist on a slow connection, and the failure is invisible
in review because it looks correct in a browser with fast JS.
So prefer moving reactivity off the text and onto attributes:
<!-- Cloaked: the only control on the panel is hidden until hydration -->
<buttonclick="">{{ busy() ? 'Sending…' : 'Send invitation' }}</button>
<!-- Visible immediately, and the label still reacts -->
<buttonclick=""disabled="">
<spantext="">Send invitation</span>
</button>
x-no-cloak -- Render It Anyway
Opts an element out of automatic cloaking, on both the reactive-text and the conditional pass. The element renders as the server produced it and hydration corrects it afterwards.
<buttonx-no-cloakclick="">{{ busy() ? 'Sending…' : 'Send invitation' }}</button>
Use it when being visible matters more than being correct for the first frame —
primary actions, and anything a user would be stuck without. The cost is real:
until the runtime binds, an unresolved {{ }} shows as literal braces. If you
can express the initial state as static text plus an attribute binding, as
above, that is better than opting out.
Complete Bindings Table
| Binding | Purpose | Example |
|---|---|---|
x-text | Set text content | x-text="count()" |
x-html | Set HTML content (use with caution) | x-html="rendered()" |
x-class | Toggle CSS classes | x-class="{ active: isOn() }" |
x-style | Set inline styles | x-style="{ color: c() }" |
x-model | Two-way form binding | x-model="name" |
x-href | Dynamic link URL | x-href="url()" |
x-src | Dynamic image source | x-src="imgUrl()" |
x-cloak | Hide until hydrated (usually stamped for you) | x-cloak (no value) |
x-no-cloak | Opt out of automatic cloaking | x-no-cloak (no value) |
Events (@ prefix)
Events attach handlers to user interactions. The @ prefix is followed by the event name and optional modifiers.
Basic Events
<buttonclick="">Click me</button>
<formsubmit=""></form>
<inputinput="">
<divmouseenter=""mouseleave="">Hover me</div>
Inline Expressions
Simple expressions can be used directly:
<buttonclick="">+1</button>
<buttonclick="">Toggle</button>
<buttonclick="">Clear</button>
The $event Variable
Access the native event object with $event:
<inputinput="">
<divclick="">Click position tracking</div>
Event Modifiers
Modifiers are chained after the event name with dots.
| Modifier | Effect | Example |
|---|---|---|
.prevent | Calls event.preventDefault() | @submit.prevent="save()" |
.stop | Calls event.stopPropagation() | @click.stop="handle()" |
.once | Fires handler only once | @click.once="init()" |
.self | Only fires if target is the element itself | @click.self="close()" |
.passive | Passive event listener (performance). Touch and wheel events are passive by default | @scroll.passive="onScroll()" |
.nonpassive | A blocking touch or wheel listener, for a handler that calls preventDefault() | @wheel.nonpassive="zoom($event)" |
.capture | Use capture mode | @click.capture="log()" |
Combine modifiers:
<formsubmitpreventstop="">
<buttontype="">Submit</button>
</form>
<aclickpreventstop="">About</a>
Key Modifiers
For keyboard events, chain a key name after the event:
| Modifier | Key |
|---|---|
.enter | Enter |
.escape | Escape |
.tab | Tab |
.space | Space |
.up | Arrow Up |
.down | Arrow Down |
.left | Arrow Left |
.right | Arrow Right |
.delete | Delete |
.backspace | Backspace |
<inputkeydownenter="">
<inputkeydownescape="">
<divkeydownup=""keydowndown="">
<!-- Keyboard-navigable list -->
</div>
System Modifiers
Require a modifier key to be held:
| Modifier | Key |
|---|---|
.ctrl | Control |
.alt | Alt / Option |
.shift | Shift |
.meta | Command (Mac) / Windows key |
<inputkeydownctrls="">
<inputkeydownmetaenter="">
<divclickctrl="">
Hold Ctrl and click to multi-select
</div>
Mouse Button Modifiers
<divclickleft="">Left click only</div>
<divclickright="">Right click only</div>
<divclickmiddle="">Middle click only</div>
Complete Events Table
| Event | When it Fires | Common Use |
|---|---|---|
@click | Element is clicked | Buttons, toggles, navigation |
@dblclick | Element is double-clicked | Edit-in-place |
@submit | Form is submitted | Form handling |
@input | Input value changes | Real-time search, validation |
@change | Input value committed | Select dropdowns, checkboxes |
@focus | Element gains focus | Show help text |
@blur | Element loses focus | Validate on blur |
@keydown | Key is pressed down | Keyboard shortcuts |
@keyup | Key is released | Key release actions |
@mouseenter | Mouse enters element | Tooltips, hover effects |
@mouseleave | Mouse leaves element | Hide tooltips |
@scroll | Element is scrolled | Infinite scroll, scroll spy |
Migration from Other Frameworks
If you are coming from Vue, Alpine.js, or an earlier version of stx, here is how the prefixes map.
From Vue
| Vue | stx | Notes |
|---|---|---|
v-if | :if | Same behavior |
v-show | :show | Same behavior |
v-for | :for | Same behavior |
:key | :key | Same |
:class | x-class | Different prefix |
:style | x-style | Different prefix |
:href | x-href | Different prefix |
:src | x-src | Different prefix |
v-model | x-model | Different prefix |
v-text | x-text | Different prefix |
v-html | x-html | Different prefix |
@click | @click | Same |
@submit.prevent | @submit.prevent | Same |
v-cloak | x-cloak | Different prefix |
From Alpine.js
| Alpine | stx | Notes |
|---|---|---|
x-if | :if | Different prefix |
x-show | :show | Different prefix |
x-for | :for | Different prefix |
:class | x-class | Same prefix |
x-text | x-text | Same |
x-html | x-html | Same |
x-model | x-model | Same |
@click | @click | Same |
x-cloak | x-cloak | Same |
Common Mistakes
:classis wrong -- usex-class. The:prefix is for directives (control flow). Class binding is a data-to-DOM binding, so it usesx-class.
<!-- Wrong -->
<divclass="">
<!-- Correct -->
<divx-class="">
:hrefis wrong -- usex-href. Same reasoning. Binding a URL to thehrefattribute is a binding operation.
<!-- Wrong -->
<ahref="">Link</a>
<!-- Correct -->
<ax-href="">Link</a>
:textis wrong -- usex-text. All attribute bindings use thex-prefix.
<!-- Wrong -->
<spantext=""></span>
<!-- Correct -->
<spanx-text=""></span>
x-ifis wrong -- use:if. All control flow directives use the:prefix.
<!-- Wrong -->
<divx-if="">Content</div>
<!-- Correct -->
<divif="">Content</div>
x-foris wrong -- use:for. List rendering is control flow, not binding.
<!-- Wrong -->
<lix-for=""></li>
<!-- Correct -->
<lifor=""key=""></li>
Putting It All Together
Here is a complete example using all three prefixes:
<scriptclient
const todos = state([
{ id: 1, text: 'Learn stx', done: true },
{ id: 2, text: 'Build an app', done: false },
{ id: 3, text: 'Deploy it', done: false },
])
const newTodo = state('')
const filter = state('all')
const filtered = derived(() => {
const f = filter()
const all = todos()
if (f === 'active') return all.filter(t => !t.done)
if (f === 'done') return all.filter(t => t.done)
return all
})
const remaining = derived(() => todos().filter(t => !t.done).length)
function addTodo() {
const text = newTodo().trim()
if (!text) return
todos.update(arr => [...arr, { id: Date.now(), text, done: false }])
newTodo.set('')
}
function toggleTodo(id) {
todos.update(arr =>
arr.map(t => t.id === id ? { ...t, done: !t.done } : t)
)
}
function removeTodo(id) {
todos.update(arr => arr.filter(t => t.id !== id))
}
</script>
<divclass="">
<h1class="">Todos</h1>
<!-- @submit with .prevent modifier -->
<formsubmitprevent=""class="">
<!-- x-model for two-way binding -->
<inputx-model=""placeholder=""class="" />
<buttontype=""class="">Add</button>
</form>
<!-- @click for filter buttons, x-class for active state -->
<divclass="">
<buttonclick=""x-class="">All</button>
<buttonclick=""x-class="">Active</button>
<buttonclick=""x-class="">Done</button>
</div>
<!-- :for for list rendering, :key for identity -->
<ul>
<lifor=""key=""class="">
<!-- @click to toggle, x-class for strikethrough -->
<inputtype=""click="" />
<spanx-text=""x-class=""class=""></span>
<buttonclick=""class="">Remove</button>
</li>
</ul>
<!-- :show for conditional visibility, x-text for dynamic text -->
<pshow=""class="">
<spanx-text=""></span> item(s) remaining
</p>
</div>
Quick Reference Card
DIRECTIVES (:) BINDINGS (x-) EVENTS (@)
:if="condition" x-text="value" @click="handler()"
:show="condition" x-html="html" @submit.prevent="fn()"
:for="item in list" x-class="{ c: bool }" @keydown.enter="fn()"
:key="unique-id" x-style="{ k: v }" @input="fn()"
x-model="signal" @change="fn()"
x-href="url" @focus="fn()"
x-src="imgUrl" @blur="fn()"
x-cloak @mouseenter="fn()"
@mouseleave="fn()"