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.

PrefixRoleExample
: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 -->
<div :class="classes" v-if="show">...</div>

<!-- stx: each prefix has one meaning -->
<div x-class="classes" :if="show">...</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 :if relate.

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:

<Form :initialValues="{ name: 'John Doe', notifications: true }" />
<Form initialValues={{ name: 'John Doe', 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:

<Form @submit="handleEditProfile" />   <!-- 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:

FormWhat it is
@if (cond) … @elseif … @else … @endifBlade-style statement (the canonical form)
v-if="cond" / v-else-if / v-elseVue sugar — compiles to @if
:if="cond" / :else-if / :elseattribute form
x-if="cond" / x-else-if / x-elseAlpine 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/@else and v-if/v-else chains, which are promoted to the reactive runtime form just like :if/:else.
  • A condition over server data ($user, env, a @foreach loop 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> @else <span>done</span> @endif
@endif

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.

<div :if="isLoggedIn()">Welcome back!</div>
<div :if="!isLoggedIn()">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.

<div :show="isOpen()">
  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>
  <li :for="item in items()" :key="item.id">
    <span x-text="item.name"></span>
  </li>
</ul>

Always use :key with :for to help the runtime efficiently update the list.

Supports destructuring and index:

<div :for="(item, index) in items()" :key="item.id">
  <span x-text="index + 1"></span>. <span x-text="item.name"></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).

<script server>
const serverRows = [{ id: 1, name: 'alpha' }]
</script>

<!-- These three are equivalent, and all render. -->
<ul><li :for="row in serverRows">{{ row.name }}</li></ul>
<ul><template :for="row in serverRows"><li>{{ row.name }}</li></template></ul>
<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:

iterablerunswhy
a signal — rows, rows()clientreactivity is the point of :for
a literal — [1, 2, 3]clientalready worked; moving it gains nothing
a name in neither scopeclientso a typo still reaches the runtime warning
a signal that shadows a server nameclientthe 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 -->
<li :for="user in users()" :key="user.id">...</li>

<!-- Bad: index as key (breaks reordering) -->
<li :for="(user, i) in users()" :key="i">...</li>

Complete Directives Table

DirectivePurposeExample
:ifConditional rendering (add/remove DOM):if="count() > 0"
:showConditional visibility (toggle display):show="menuOpen()"
:forList rendering:for="item in items()"
:keyUnique 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.

<span x-text="count()"></span>
<p x-text="'Hello, ' + name()"></p>
<td x-text="item.price.toFixed(2)"></td>

x-html -- HTML Content

Sets the innerHTML of an element. Use with caution -- only with trusted content.

<div x-html="markdownContent()"></div>

Security warning: Never use x-html with user-provided content. It can introduce XSS vulnerabilities.

x-class -- Dynamic CSS Classes

Adds or removes CSS classes based on conditions.

Object syntax (recommended):

<div x-class="{ 'bg-blue-500': isActive(), 'opacity-50': isDisabled() }">
  Styled element
</div>

String syntax:

<div x-class="currentTheme()">Themed element</div>

You can combine x-class with static class:

<button class="px-4 py-2 rounded" x-class="{ 'bg-blue-500 text-white': selected(), 'bg-gray-200': !selected() }">
  Toggle
</button>

x-style -- Dynamic Inline Styles

Sets inline styles from an object.

<div x-style="{ color: textColor(), fontSize: size() + 'px' }">
  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.

<script client>
const name = state('')
const agreed = state(false)
const color = state('blue')
</script>

<input type="text" x-model="name" placeholder="Your name" />
<p>Hello, <span x-text="name()"></span></p>

<label>
  <input type="checkbox" x-model="agreed" />
  I agree to the terms
</label>

<select x-model="color">
  <option value="red">Red</option>
  <option value="blue">Blue</option>
  <option value="green">Green</option>
</select>
<p>Selected: <span x-text="color()"></span></p>

x-href -- Dynamic URL

Sets the href attribute reactively.

<a x-href="'/users/' + userId()">View Profile</a>

x-src -- Dynamic Image Source

Sets the src attribute reactively.

<img x-src="user().avatarUrl" alt="Avatar" />

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>

<div x-cloak>
  <span x-text="greeting()"></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 wroteCloaked?
<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 -->
<button @click="submit()">{{ busy() ? 'Sending…' : 'Send invitation' }}</button>

<!-- Visible immediately, and the label still reacts -->
<button @click="submit()" :disabled="busy()">
  <span :text="busy() ? 'Sending…' : 'Send invitation'">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.

<button x-no-cloak @click="submit()">{{ 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

BindingPurposeExample
x-textSet text contentx-text="count()"
x-htmlSet HTML content (use with caution)x-html="rendered()"
x-classToggle CSS classesx-class="{ active: isOn() }"
x-styleSet inline stylesx-style="{ color: c() }"
x-modelTwo-way form bindingx-model="name"
x-hrefDynamic link URLx-href="url()"
x-srcDynamic image sourcex-src="imgUrl()"
x-cloakHide until hydrated (usually stamped for you)x-cloak (no value)
x-no-cloakOpt out of automatic cloakingx-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

<button @click="increment()">Click me</button>
<form @submit="handleSubmit()">...</form>
<input @input="handleInput()">
<div @mouseenter="showTooltip()" @mouseleave="hideTooltip()">Hover me</div>

Inline Expressions

Simple expressions can be used directly:

<button @click="count.set(count() + 1)">+1</button>
<button @click="open.set(!open())">Toggle</button>
<button @click="items.set([])">Clear</button>

The $event Variable

Access the native event object with $event:

<input @input="search.set($event.target.value)">
<div @click="handleClick($event)">Click position tracking</div>

Event Modifiers

Modifiers are chained after the event name with dots.

ModifierEffectExample
.preventCalls event.preventDefault()@submit.prevent="save()"
.stopCalls event.stopPropagation()@click.stop="handle()"
.onceFires handler only once@click.once="init()"
.selfOnly fires if target is the element itself@click.self="close()"
.passivePassive event listener (performance). Touch and wheel events are passive by default@scroll.passive="onScroll()"
.nonpassiveA blocking touch or wheel listener, for a handler that calls preventDefault()@wheel.nonpassive="zoom($event)"
.captureUse capture mode@click.capture="log()"

Combine modifiers:

<form @submit.prevent.stop="handleSubmit()">
  <button type="submit">Submit</button>
</form>

<a @click.prevent.stop="navigate('/about')">About</a>

Key Modifiers

For keyboard events, chain a key name after the event:

ModifierKey
.enterEnter
.escapeEscape
.tabTab
.spaceSpace
.upArrow Up
.downArrow Down
.leftArrow Left
.rightArrow Right
.deleteDelete
.backspaceBackspace
<input @keydown.enter="submitSearch()">
<input @keydown.escape="clearInput()">
<div @keydown.up="selectPrevious()" @keydown.down="selectNext()">
  <!-- Keyboard-navigable list -->
</div>

System Modifiers

Require a modifier key to be held:

ModifierKey
.ctrlControl
.altAlt / Option
.shiftShift
.metaCommand (Mac) / Windows key
<input @keydown.ctrl.s="save()">
<input @keydown.meta.enter="send()">
<div @click.ctrl="selectMultiple()">
  Hold Ctrl and click to multi-select
</div>

Mouse Button Modifiers

<div @click.left="handleLeft()">Left click only</div>
<div @click.right="handleContext()">Right click only</div>
<div @click.middle="openInNewTab()">Middle click only</div>

Complete Events Table

EventWhen it FiresCommon Use
@clickElement is clickedButtons, toggles, navigation
@dblclickElement is double-clickedEdit-in-place
@submitForm is submittedForm handling
@inputInput value changesReal-time search, validation
@changeInput value committedSelect dropdowns, checkboxes
@focusElement gains focusShow help text
@blurElement loses focusValidate on blur
@keydownKey is pressed downKeyboard shortcuts
@keyupKey is releasedKey release actions
@mouseenterMouse enters elementTooltips, hover effects
@mouseleaveMouse leaves elementHide tooltips
@scrollElement is scrolledInfinite 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

VuestxNotes
v-if:ifSame behavior
v-show:showSame behavior
v-for:forSame behavior
:key:keySame
:classx-classDifferent prefix
:stylex-styleDifferent prefix
:hrefx-hrefDifferent prefix
:srcx-srcDifferent prefix
v-modelx-modelDifferent prefix
v-textx-textDifferent prefix
v-htmlx-htmlDifferent prefix
@click@clickSame
@submit.prevent@submit.preventSame
v-cloakx-cloakDifferent prefix

From Alpine.js

AlpinestxNotes
x-if:ifDifferent prefix
x-show:showDifferent prefix
x-for:forDifferent prefix
:classx-classSame prefix
x-textx-textSame
x-htmlx-htmlSame
x-modelx-modelSame
@click@clickSame
x-cloakx-cloakSame

Common Mistakes

:class is wrong -- use x-class. The : prefix is for directives (control flow). Class binding is a data-to-DOM binding, so it uses x-class.

<!-- Wrong -->
<div :class="{ active: isActive() }">

<!-- Correct -->
<div x-class="{ active: isActive() }">

:href is wrong -- use x-href. Same reasoning. Binding a URL to the href attribute is a binding operation.

<!-- Wrong -->
<a :href="url()">Link</a>

<!-- Correct -->
<a x-href="url()">Link</a>

:text is wrong -- use x-text. All attribute bindings use the x- prefix.

<!-- Wrong -->
<span :text="name()"></span>

<!-- Correct -->
<span x-text="name()"></span>

x-if is wrong -- use :if. All control flow directives use the : prefix.

<!-- Wrong -->
<div x-if="show()">Content</div>

<!-- Correct -->
<div :if="show()">Content</div>

x-for is wrong -- use :for. List rendering is control flow, not binding.

<!-- Wrong -->
<li x-for="item in items()">...</li>

<!-- Correct -->
<li :for="item in items()" :key="item.id">...</li>

Putting It All Together

Here is a complete example using all three prefixes:

<script client>
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>

<div class="max-w-md mx-auto p-6">
  <h1 class="text-2xl font-bold mb-4">Todos</h1>

  <!-- @submit with .prevent modifier -->
  <form @submit.prevent="addTodo()" class="flex gap-2 mb-4">
    <!-- x-model for two-way binding -->
    <input x-model="newTodo" placeholder="What needs doing?" class="flex-1 border rounded px-3 py-2" />
    <button type="submit" class="bg-blue-500 text-white px-4 py-2 rounded">Add</button>
  </form>

  <!-- @click for filter buttons, x-class for active state -->
  <div class="flex gap-2 mb-4">
    <button @click="filter.set('all')" x-class="{ 'font-bold underline': filter() === 'all' }">All</button>
    <button @click="filter.set('active')" x-class="{ 'font-bold underline': filter() === 'active' }">Active</button>
    <button @click="filter.set('done')" x-class="{ 'font-bold underline': filter() === 'done' }">Done</button>
  </div>

  <!-- :for for list rendering, :key for identity -->
  <ul>
    <li :for="todo in filtered()" :key="todo.id" class="flex items-center gap-2 py-2 border-b">
      <!-- @click to toggle, x-class for strikethrough -->
      <input type="checkbox" @click="toggleTodo(todo.id)" />
      <span x-text="todo.text" x-class="{ 'line-through text-gray-400': todo.done }" class="flex-1"></span>
      <button @click="removeTodo(todo.id)" class="text-red-500 text-sm">Remove</button>
    </li>
  </ul>

  <!-- :show for conditional visibility, x-text for dynamic text -->
  <p :show="remaining() > 0" class="mt-4 text-sm text-gray-500">
    <span x-text="remaining()"></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()"