Skip to content

Template syntax reference

Syntax Meaning
{{ expr }} Interpolate a Python expression in text or attribute values
:attr="expr" Bind an attribute to an expression. None/False drop the attribute, True keeps it valueless (:disabled="busy"), anything else is stringified. On a component, passes the typed value as a prop
@click="handler" A bare name calls that method; an expression is evaluated, so @click="remove(item)" works; simple statements run inline, so @click="count += 1" and @click="show = not show" work without a method. Any DOM event works the same way (@input, @keydown, @submit ...)
@event.modifier @submit.prevent, @keydown.enter, @click.stop, @keydown.ctrl.s.prevent: see event modifiers
@bind="field" Two-way binding on inputs, textareas, selects, checkboxes and radio buttons, converting to the field's declared type. @bind.lazy, .trim, .number: see binding form controls
<If cond="..."> / <ElseIf cond="..."> / <Else> Conditional rendering. Branch elements must be adjacent siblings
<For each="item in items" key="item['id']" index="i"> Loop. key gives rows stable identity across reorders (always use it for dynamic lists); index is optional
<PascalCaseTag ... /> Renders a child component (see below)

Expressions are real Python, evaluated against the component's state plus any loop variables. self is the component, so {{ self.count }} is the explicit spelling of {{ count }}. One caveat: comprehensions and lambdas inside {{ }} can't see scope variables (CPython eval semantics) — compute those in the code block instead.

Inline handlers

An event attribute accepts three forms. Pick the smallest one that fits:

<button @click="increment">+1</button>              <!-- 1. call a method -->
<button @click="remove(item)">remove</button>        <!-- 2. an expression -->
<button @click="count += 1">+1</button>              <!-- 3. inline statements -->
  1. A bare name calls that method. If the method declares a parameter it receives the event payload, otherwise it is called with no arguments.
  2. An expression is evaluated in the template's scope, so loop variables and the event payload are available: @click="remove(item)", @input="search(event.value)". Whatever it returns is returned from the dispatch, which matters for emit (section 4).
  3. Statements run when the value does not parse as an expression. Assignment, augmented assignment and ;-separated statements all work:
<button @click="show = not show">toggle</button>
<button @click="count += 1; history.append(count)">record</button>
<For each="item in items" key="item['id']">
  <li @click="selected = item">{{ item['text'] }}</li>
</For>

A bare name on the left of = writes component state, so count += 1 updates self.count on the component that owns the template. Assigning to a loop variable only changes it inside that handler; it never becomes state. self.count += 1 is the explicit spelling if you prefer it.

After any of the three runs, the component re-renders. Keep inline handlers to a statement or two; once logic needs a name, a comment, or a test, move it to a method.

The event object

A handler that declares a parameter, or an expression that uses event, gets the event's details. It is a dict that also allows attribute access, so event.key and event["key"] both work:

def on_key(self, event):
    if event.key == "Enter" and event.ctrlKey:
        self.send()
Key On
type every event: "click", "keydown" ...
value inputs, textareas and selects: the current text
checked checkboxes and radio buttons
key, code, repeat keyboard events: key is the character or name ("a", "Enter", "ArrowUp"); code the physical key ("KeyA")
ctrlKey, shiftKey, altKey, metaKey keyboard and mouse events
clientX, clientY, offsetX, offsetY, button, buttons mouse and pointer events (deltaX, deltaY on wheel)
localX, localY, width, height mouse and pointer events: the pointer's position inside the element the handler is on, and that element's size
form submit: the form's fields as {name: value}, a list when several fields share a name (file inputs are left out)

Asking for a key the event doesn't have raises an AttributeError that lists the keys it does have.

Reacting to an element's size: @resize

@resize isn't a browser event: it fires when the element is first laid out and again whenever its size changes, with event.width and event.height in whole pixels (the content box). Use it to lay something out for the space it really has, such as fewer labels on a narrow screen:

<div @resize="fit"> ... </div>
wide: bool = True

def fit(self, event):
    self.wide = event.width >= 600

It needs a page, so it never fires in pre-rendering or headless tests: start with a sensible default.

Event modifiers

Add modifiers after the event name to handle the browser's behaviour in the template instead of in your method:

<form @submit.prevent="save">...</form>             <!-- no page reload -->
<a href="/help" @click.prevent="open_help">help</a> <!-- no navigation -->
<input @keydown.enter="search" @keydown.esc="clear" />
<textarea @keydown.ctrl.enter="send"></textarea>
<div @click="close" class="backdrop">
  <div @click.stop="" class="dialog">...</div>     <!-- clicks inside don't close it -->
</div>
Modifier Effect
.prevent preventDefault(): no form submission, link navigation, browser shortcut
.stop stopPropagation(): parents don't see the event
.self only when the event happened on this element itself, not a child
.once at most once
.capture, .passive listener options, for capture-phase and scroll-performance cases
.enter, .tab, .esc, .space, .delete (Delete or Backspace), .up, .down, .left, .right key events: only for that key
any key name in kebab-case .a, .f1, .page-down: KeyboardEvent.key lower-cased, PageDown becomes page-down
.ctrl, .alt, .shift, .meta only while that key is held (on keyboard and mouse events)
.exact and no other of those keys: @click.ctrl.exact ignores Ctrl+Shift+click
.left, .middle, .right mouse events: that button. @click.right handles right-clicks (the context menu), so add .prevent to suppress the menu
  • Modifiers only: @submit.prevent="" with an empty value prevents the default and calls nothing.
  • Several handlers for one event are fine when their modifiers differ: @keydown.enter="search" and @keydown.esc="clear" on one input.
  • Filters come first. .prevent and .stop apply only when the key, button, .self and .ctrl-style conditions match, whatever order you write them in. @keydown.ctrl.s.prevent="save" blocks the browser's Save dialog for Ctrl+S and leaves every other key alone.
  • Typos are caught: @submit.prevnt or @click.enter is a template error naming the modifier, not a handler that silently never runs.
  • Modifiers belong to page events. A component's own events (@remove on <TodoItem>) take none.

Binding form controls

@bind="field" keeps a control and a field in sync both ways:

<input @bind="name" />                                  <!-- str -->
<input type="number" @bind="age" />                     <!-- int -->
<textarea @bind="notes"></textarea>
<input type="checkbox" @bind="subscribed" />            <!-- bool: checked -->
<input type="radio" value="s" @bind="size" />
<input type="radio" value="m" @bind="size" />           <!-- the checked one's value -->
<select @bind="country">
  <option value="in">India</option>
  <option value="nl">Netherlands</option>
</select>
  • Types follow the field's declaration. age: int = 0 stores an int; price: float | None = None a float, or None when the box is empty. Text that doesn't fit (letters in a number box, an empty box for a field that can't be None) leaves the field at its last good value while the user is still typing, instead of turning it into a string.
  • Checkboxes bind a bool to the checked state; radio buttons store the value of the one that is checked (converted, so value="2" with size: int stores 2).
  • Modifiers: @bind.lazy updates when the user leaves the field (the change event) rather than on every keystroke; @bind.trim strips surrounding spaces; @bind.number parses a number even when the field has no declared type.
  • Your own handler alongside: @bind and @input on one element both run, the binding first, so your handler already sees the new value:
<input @bind="query" @input="search()" />

Input masks

data-mask shapes what's typed into an input as the user types, such as a phone or card number:

<input @bind="phone" data-mask="(999) 999-9999" inputmode="numeric" />
<input @bind="card" data-mask="9999 9999 9999 9999" />
<input @bind="plate" data-mask="aa-9999" />
  • In a mask, 9 is a digit, a a letter, * a letter or digit, and \ makes the next character literal; anything else is shown as it is, filled in around what's typed (but never after the last character, so Backspace works as expected).
  • It happens in the page, before your @bind or @input sees the event, so the cursor stays where the user is typing, even in the middle. Characters that don't fit (a letter where a digit goes) are refused, and extra ones are dropped. Your field gets the shaped text, "(555) 123-4567".
  • pyweft.masks has the same rules in Python: masks.apply("(999) 999-9999", "5551234567") shapes a starting value, masks.complete(mask, text) checks every slot is filled, and masks.typed(mask, text) gives just the typed characters ("5551234567"), for storing.

Moving focus and scrolling: ref

Mark an element with ref="name", then move keyboard focus to it or scroll to it from Python:

<template>
  <form @submit.prevent="save">
    <input ref="title" @bind="title" />
    <If cond="error"><p ref="problem" class="error">{{ error }}</p></If>
    <button>Save</button>
  </form>
</template>

<code>
title: str = ""
error: str = ""

def on_init(self):
    self.focus("title")           # ready to type as soon as it shows

def save(self):
    if not self.title:
        self.error = "Give it a title"
        self.scroll_to("problem")  # the <p> appears in this same render
        self.focus("title")
</code>
  • Requests wait for the render, so they can name an element that the same handler makes appear, like problem above.
  • ref on a component tag (<ui.TextField ref="email" />) names the component; focus() then goes to the first thing inside it that takes focus, such as its input.
  • In a loop, make each name unique: ref="row-{{ i }}".
  • A name that isn't on the page when the request is carried out is ignored.
Method Does
focus(ref) focuses the element (or the first focusable thing inside it)
focus_first(ref), focus_last(ref) focuses the first or last focusable element inside
scroll_to(ref) scrolls the page just enough to show the element; scroll_to(ref, align="start") brings it to the top of the window (or "center", "end"), as a link to a section would
keep_in_view(ref) shifts the element sideways just enough to stay inside the window: for a popup opening near the edge. keep_in_view(ref, vertical=True) shifts it up or down too, for a popup placed at a point, such as a context menu at the pointer; flip=True moves a popup opened beside its parent (a submenu) to the parent's other side when it doesn't fit
save_focus(), restore_focus() remembers what has focus, and later puts it back, for popups

ref is not written to the page as an attribute.

Component styles

A <style> block in a .weft file is scoped to that component: its rules match only elements this component's template renders.

<template>
  <li class="row"><span>{{ item['text'] }}</span></li>
</template>

<style>
.row { display: flex; gap: .5rem; }
span:hover { text-decoration: underline; }
</style>

Write selectors as if the component were the whole page. span:hover above cannot reach a <span> rendered by a child or a parent, so two components can both use .row without a naming scheme. Under the hood every element gets a data-w-<component> marker and each selector has [data-w-<component>] appended, which you will see in the browser's element inspector.

Two rules of thumb:

  • A parent can style the outermost element of a child it places, so .list > .row in the parent works for spacing children. It cannot reach inside them.
  • Page-wide rules (body, fonts, CSS variables) go in a .css file in the app directory, which is included unscoped. var(--name) defined there is usable from any component's <style>.

@media, @supports and @container blocks are scoped too; @keyframes, @font-face and @import are passed through as written.