Complete reference for LiveTemplate form handling and lvt-* HTML attributes.
For server-side Go API: See pkg.go.dev/github.com/livetemplate/livetemplate
LiveTemplate follows a progressive complexity model. Standard HTML forms work without any lvt-* attributes. Use lvt-* only for behaviors HTML cannot express (debounce, loading states, reactive DOM, etc.).
All <form> elements within a LiveTemplate-managed region are automatically intercepted. Forms without explicit action routing default to the Submit() method on the controller:
<!-- No lvt-* needed — auto-routes to Submit() -->
<form method="POST">
<input type="text" name="title" placeholder="New todo...">
<button type="submit">Add</button>
</form>
func (c *Controller) Submit(state State, ctx *livetemplate.Context) (State, error) {
title := ctx.GetString("title")
// ...
return state, nil
}
The button's name IS the action. Button value carries optional data:
<form method="POST">
<input type="text" name="Title" value="{{.Title}}">
<button name="save">Save</button>
<button name="save-draft" formnovalidate>Save Draft</button>
</form>
<button name="save"> routes to Save(). <button name="save-draft"> routes to SaveDraft().
Use the name attribute on the form itself:
<form name="search" method="POST">
<input name="query" value="{{.Query}}">
<button type="submit">Search</button>
</form>
Routes to Search() on the controller when using the JS client, which reads form.name. A plain HTML POST does not include the form's name attribute, so for no-JS compatibility use <button name="search"> instead.
Data can be passed via hidden inputs, button value, or data-* attributes:
{{range .Items}}
<form method="POST">
<input type="hidden" name="id" value="{{.ID}}">
<button name="toggle">{{if .Done}}Undo{{else}}Done{{end}}</button>
<button name="delete" value="{{.ID}}">Delete</button>
</form>
{{end}}
ctx.GetString("id")ctx.GetString("value")data-* on button: ctx.GetString("key")The client resolves the action name in this order (first match wins):
lvt-form:action="X" on the form → action is X (explicit routing, highest precedence)lvt-submit="X" on the form → action is X (backward compatible)name attribute → action is the button nameform name="X" → action is X"submit" → routes to Submit()Note: The form field name
actionis not reserved. A form field<input name="action" value="approve">flows through toActionDataas normal data. Uselvt-form:actionon the<form>element for routing.
Forms that should NOT be auto-intercepted (external URLs, downloads):
<form action="/api/export" method="POST" lvt-form:no-intercept>
<button type="submit">Export CSV</button>
</form>
Links that should NOT be auto-intercepted (external pages, legacy routes):
<a href="/legacy-page" lvt-nav:no-intercept>Legacy Page</a>
Note: Use
lvt-form:no-intercepton<form>elements andlvt-nav:no-intercepton<a>elements. These are semantically distinct: form interception vs. link/navigation interception.
| Mechanism | No JS | JS + HTTP | JS + WebSocket |
|---|---|---|---|
button name="action" |
Native POST | Client extracts | Client extracts |
form name |
N/A (use button) | Client reads | Client reads |
| Hidden inputs | Native POST | In FormData | In FormData |
LiveTemplate uses lvt-* attributes to bind DOM events to server-side actions. These are for interactions that standard HTML forms cannot express.
<!-- Click events -->
<button lvt-on:click="submit">Submit</button>
<button lvt-on:click="delete" lvt-data-id="{{.ID}}">Delete</button>
<!-- Form submission -->
<form lvt-form:action="save">
<input type="text" name="title" required>
<button type="submit">Save</button>
</form>
<!-- Input events -->
<input lvt-on:change="validate" name="email">
<input lvt-on:input="search" name="query">
<!-- Hover events -->
<div lvt-on:mouseenter="showTooltip" lvt-on:mouseleave="hideTooltip">
Hover for tooltip
</div>
<!-- Click events -->
<button lvt-on:click="handleClick">Click me</button>
<!-- Keydown events -->
<input lvt-on:keydown="handleKey" name="search">
<!-- With key filtering -->
<input lvt-on:keydown="submit" lvt-key="Enter" name="query">
<div lvt-on:window:keydown="closeModal" lvt-key="Escape">
Modal content
</div>
<!-- Global keyboard events -->
<div lvt-on:window:keydown="handleShortcut" lvt-key="Escape">
<!-- Scroll events -->
<div lvt-on:window:scroll="loadMore" lvt-mod:throttle="100">
Pass data to Tier 2 event handlers using lvt-data-* attributes. For Tier 1 forms, use standard HTML instead: hidden inputs (<input type="hidden" name="id" value="{{.ID}}">), button value, or data-* attributes on buttons. See Standard HTML — Data Passing above.
<button lvt-on:click="delete" lvt-data-id="{{.ID}}">
Delete
</button>
<button lvt-on:click="update"
lvt-data-id="{{.ID}}"
lvt-data-status="{{.Status}}"
lvt-data-priority="{{.Priority}}">
Update Item
</button>
// Action "delete" with lvt-data-id
func (c *Controller) Delete(state State, ctx *livetemplate.Context) (State, error) {
id := ctx.GetString("id")
// Delete item with id
return state, nil
}
// Action "update" with multiple lvt-data-* attributes
func (c *Controller) Update(state State, ctx *livetemplate.Context) (State, error) {
id := ctx.GetString("id")
status := ctx.GetString("status")
priority := ctx.GetInt("priority")
// Update item
return state, nil
}
Available methods:
ctx.GetString(key string) stringctx.GetInt(key string) intctx.GetFloat(key string) float64ctx.GetBool(key string) boolctx.Has(key string) boolForms emit JavaScript events during the action lifecycle that you can listen to.
const form = document.querySelector('form');
// Fires when action starts
form.addEventListener('lvt:pending', (e) => {
console.log('Submitting...');
// Show loading spinner
});
// Fires on validation errors
form.addEventListener('lvt:error', (e) => {
console.log('Errors:', e.detail.errors);
// Display error messages
});
// Fires on successful action (no errors)
form.addEventListener('lvt:success', (e) => {
console.log('Saved!');
// Show success message, redirect, etc.
});
// Always fires when action completes (success or error)
form.addEventListener('lvt:done', (e) => {
console.log('Completed');
// Hide loading spinner
});
Lifecycle events also bubble to the document level:
// Listen for any action lifecycle events
document.addEventListener('lvt:pending', (e) => {
console.log('Action starting:', e.detail.action);
});
document.addEventListener('lvt:success', (e) => {
console.log('Action succeeded:', e.detail.action);
});
form.addEventListener('lvt:success', (e) => {
console.log(e.detail);
// {
// action: "save",
// data: {...},
// meta: {
// success: true,
// errors: {}
// }
// }
});
Reactive attributes allow declarative DOM manipulation in response to action lifecycle events or native DOM events, without writing JavaScript.
lvt-el:{method}:on:{trigger}="param"
lvt-el:{method}:on:{action}:{trigger}="param"
Where {trigger} is a lifecycle state or any native DOM event (see below).
| Event | Description |
|---|---|
pending |
Action started, waiting for server response |
success |
Action completed successfully (no validation errors) |
error |
Action completed with validation errors |
done |
Action completed (regardless of success/error) |
In addition to lifecycle states, lvt-el: supports native DOM events as triggers.
These execute client-side with no server round-trip.
| Trigger | DOM Event | Use case |
|---|---|---|
click |
click |
Toggle visibility on click |
focusin |
focusin |
Open panel when focus enters (bubbles) |
focusout |
focusout |
Close panel when focus leaves (bubbles) |
mouseenter |
mouseenter |
Show on hover |
mouseleave |
mouseleave |
Hide on hover end |
click-away |
(synthetic) | Close when clicking outside element |
| Any other | Corresponding DOM event | Custom behavior |
| Method | Description | Param |
|---|---|---|
reset |
Calls form.reset() |
None |
addClass |
Adds CSS class(es) | Space-separated classes |
removeClass |
Removes CSS class(es) | Space-separated classes |
toggleClass |
Toggles CSS class(es) | Space-separated classes |
setAttr |
Sets an attribute | name:value format |
toggleAttr |
Toggles a boolean attribute | Attribute name |
Global - Reacts to any action:
<!-- Reset form on any successful action -->
<form name="save" method="POST" lvt-el:reset:on:success>
<input name="title">
<button type="submit">Save</button>
</form>
Action-Specific - Reacts only to a specific action:
<!-- Reset form only when 'create-todo' succeeds -->
<form name="create-todo" method="POST" lvt-el:reset:on:create-todo:success>
<input name="title">
<button type="submit">Add Todo</button>
</form>
Loading States:
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:addClass:on:pending="opacity-50 cursor-wait"
lvt-el:toggleAttr:on:done="disabled"
lvt-el:removeClass:on:done="opacity-50 cursor-wait">
Save
</button>
Form Reset on Success:
<form name="create-todo" method="POST" lvt-el:reset:on:success>
<input type="text" name="title" placeholder="New todo">
<button type="submit">Add</button>
</form>
Accessibility States:
<button name="submit"
lvt-el:setAttr:on:pending="aria-busy:true"
lvt-el:setAttr:on:done="aria-busy:false">
Submit
</button>
Error Indicators:
<!-- Visual feedback on form-level errors -->
<!-- Note: For field-specific validation errors, use .lvt.HasError and .lvt.Error helpers -->
<div
lvt-el:addClass:on:error="border-red-500"
lvt-el:removeClass:on:success="border-red-500">
<form name="save" method="POST">
<input name="email">
<button type="submit">Save</button>
</form>
</div>
Input Validation State:
<!-- For form inputs with validation errors -->
<input
type="email"
name="email"
lvt-el:setAttr:on:error="aria-invalid:true"
lvt-el:setAttr:on:success="aria-invalid:false">
Multiple Actions on Same Element:
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:toggleAttr:on:done="disabled"
lvt-el:addClass:on:pending="loading"
lvt-el:removeClass:on:done="loading"
lvt-el:addClass:on:success="success"
lvt-el:addClass:on:error="error">
Save
</button>
Note: When multiple reactive attributes target the same lifecycle event, all matching methods execute in DOM order. For example, lvt-el:addClass:on:pending="loading" and lvt-el:addClass:on:pending="disabled" will both add their respective classes.
<!-- Toggle dropdown visibility on click -->
<div lvt-el:toggleClass:on:click="open"
lvt-el:removeClass:on:click-away="open">
...
</div>
<!-- Show tooltip on hover -->
<div lvt-el:addClass:on:mouseenter="visible"
lvt-el:removeClass:on:mouseleave="visible">
...
</div>
<!-- Open suggestions on focus, close on blur -->
<div lvt-el:addClass:on:focusin="open"
lvt-el:removeClass:on:focusout="open"
lvt-el:removeClass:on:click-away="open">
<input type="text" ...>
<ul data-suggestions>...</ul>
</div>
When the same reactive attribute applies to multiple actions, use bracket syntax to avoid repetition:
<!-- Shorthand: bracket syntax -->
<button
lvt-on:click="save"
lvt-el:addClass:on:[save,delete]:pending="opacity-50"
lvt-el:toggleAttr:on:[save,delete]:pending="disabled">
Save
</button>
<!-- Equivalent expanded form -->
<button
lvt-on:click="save"
lvt-el:addClass:on:save:pending="opacity-50"
lvt-el:addClass:on:delete:pending="opacity-50"
lvt-el:toggleAttr:on:save:pending="disabled"
lvt-el:toggleAttr:on:delete:pending="disabled">
Save
</button>
Bracket expansion works for lvt-el:*, lvt-fx:*, and lvt-form:* prefixes, including boolean attributes (no …). Bracket syntax works everywhere in templates, including inside {{range}} and {{if}} blocks.
Note: Attribute values must be quoted (… or …). Unquoted values like
lvt-el:addClass:on:[a,b]:pending=loadingwill produce incorrect output. Bracket expansion operates on raw template source, so patterns inside<script>or<style>blocks would also be expanded if they match — though thelvt-el:/lvt-fx:/lvt-form:prefixes make false matches unlikely in practice.
LiveTemplate provides server-side validation with automatic error display.
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type TodoInput struct {
Title string `json:"title" validate:"required,min=3,max=100"`
Tags string `json:"tags" validate:"required"`
}
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input TodoInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err // Errors automatically sent to client
}
// Input is valid, proceed
state.Todos = append(state.Todos, Todo{Title: input.Title})
return state, nil
}
<form name="add" method="POST">
<div>
<label for="title">Title</label>
<input
type="text"
name="title"
id="title"
{{if .lvt.HasError "title"}}aria-invalid="true"{{end}}>
{{if .lvt.HasError "title"}}
<small class="error">{{.lvt.Error "title"}}</small>
{{end}}
</div>
<button type="submit">Add Todo</button>
</form>
In templates:
{{.lvt.HasError "field"}} - Check if field has error{{.lvt.Error "field"}} - Get error message for field{{.lvt.Errors}} - Get all errors mapControl how often events are processed using debounce and throttle.
Wait for user to stop typing before triggering action.
<!-- Wait 300ms after user stops typing -->
<input
lvt-on:input="search"
lvt-mod:debounce="300"
name="query"
placeholder="Search...">
Use for: Search inputs, auto-save, validation
Limit event frequency to at most once per interval.
<!-- Fire at most once every 100ms -->
<div lvt-on:window:scroll="loadMore" lvt-mod:throttle="100">
Use for: Scroll events, resize events, mouse tracking
Directives provide declarative behavior for common UI patterns.
Control scroll behavior after DOM updates.
<!-- Scroll to bottom -->
<div lvt-fx:scroll="bottom" class="chat-messages">
{{range .Messages}}
<div>{{.Text}}</div>
{{end}}
</div>
<!-- Sticky scroll (only if user is near bottom) -->
<div lvt-fx:scroll="bottom-sticky" style="--lvt-scroll-threshold: 100">
{{range .Logs}}
<div>{{.}}</div>
{{end}}
</div>
<!-- Scroll to top -->
<div lvt-fx:scroll="top">...</div>
<!-- Preserve scroll position -->
<div lvt-fx:scroll="preserve">...</div>
| Attribute | Description |
|---|---|
lvt-fx:scroll |
Scroll mode: bottom, bottom-sticky, top, preserve |
--lvt-scroll-behavior |
CSS custom property: auto (default), smooth |
--lvt-scroll-threshold |
CSS custom property: pixel threshold for sticky scroll (default: 100). Parsed as an integer; px suffix is accepted but optional |
bottom-sticky first-run behavior: On the first encounter (fresh element), bottom-sticky scrolls to bottom unconditionally using scrollTo() with behavior: "instant" (a valid Web API value that jumps without animation). Subsequent updates only scroll if the user is within the threshold. Use data-key on the scrollable element to reset this when content changes (e.g., switching chat sessions).
data-lvt-targetresolution for scroll effects triggered by DOM events is described in Target Resolution.
Temporarily highlight elements after updates.
<!-- Highlight updated item -->
<div lvt-fx:highlight="flash" style="--lvt-highlight-color: #ffc107; --lvt-highlight-duration: 500ms">
{{.UpdatedContent}}
</div>
| Attribute | Description |
|---|---|
lvt-fx:highlight |
Highlight mode: flash |
--lvt-highlight-color |
CSS custom property: background color (default: #ffc107) |
--lvt-highlight-duration |
CSS custom property: duration (default: 500ms) |
Apply entrance animations to elements.
<!-- Fade in -->
<div lvt-fx:animate="fade">New content</div>
<!-- Slide in -->
<div lvt-fx:animate="slide" style="--lvt-animate-duration: 300ms">Slide content</div>
<!-- Scale in -->
<div lvt-fx:animate="scale">Pop content</div>
| Attribute | Description |
|---|---|
lvt-fx:animate |
Animation type: fade, slide, scale |
--lvt-animate-duration |
CSS custom property: duration (default: 300ms) |
lvt-fx: attributes support three trigger modes:
Implicit (no :on:) -- fires on every DOM content update:
<div lvt-fx:scroll="bottom-sticky">...</div>
<div lvt-fx:highlight="flash">...</div>
Lifecycle (:on:{state}) -- fires on action lifecycle state:
<div lvt-fx:highlight:on:success="flash">Saved!</div>
<div lvt-fx:highlight:on:save:success="flash">Save confirmed</div>
DOM Event (:on:{event}) -- fires on any native DOM event:
<div lvt-fx:highlight:on:click="flash">Click to highlight</div>
<div lvt-fx:highlight:on:mouseenter="flash">Hover to highlight</div>
<div lvt-fx:animate:on:click="fade">Click to animate</div>
DOM event triggers resolve data-lvt-target before applying the effect. This lets a button control a different element:
<button lvt-fx:scroll:on:click="bottom"
data-lvt-target="#chat-log"
aria-label="Scroll to bottom">↓</button>
The button scrolls #chat-log to the bottom on click. Without data-lvt-target, the effect applies to the trigger element itself. data-lvt-target supports #id resolution and closest:selector (walks up the DOM from the trigger element, equivalent to element.closest(selector)).
data-lvt-target is also used by lvt-scroll-away to identify which scrollable container to observe.
Triggers a load_more action when the element scrolls into view, enabling infinite scroll without custom JavaScript. Uses an IntersectionObserver internally.
{{if .HasMore}}
<div lvt-scroll-sentinel data-key="sentinel">
<small aria-busy="true">Loading older messages…</small>
</div>
{{end}}
| Attribute | Description |
|---|---|
lvt-scroll-sentinel |
Marks the element as an infinite-scroll trigger. When it enters the viewport, the client sends a load_more action to the server |
The action name load_more is hardcoded and not configurable. It maps to a LoadMore() handler on the server via livetemplate's standard snake_case-to-PascalCase action routing. The handler increments a page counter and returns more items. Conditionally render the sentinel with {{if .HasMore}} so it disappears when all items are loaded. The observer automatically cascades — if the sentinel is still visible after new items load, it fires again.
The data-key="sentinel" ensures stable identity across re-renders so morphdom patches correctly when surrounding content changes. One sentinel per page is the expected use case — the action name is not configurable.
Show or hide an element based on scroll position of a target container. When the user scrolls away from the specified edge beyond a threshold, the element gains a visible class; when they return, it's removed.
<button class="scroll-bottom-btn"
lvt-scroll-away="bottom"
data-lvt-target="#chat-log"
style="--lvt-scroll-threshold: 200"
aria-label="Scroll to bottom">↓</button>
| Attribute | Description |
|---|---|
lvt-scroll-away |
Edge to watch: bottom |
data-lvt-target |
Scrollable container to observe (required). See Target Resolution |
--lvt-scroll-threshold |
CSS custom property: pixel distance from edge to toggle visibility (default: 200). The CSS property is read independently by each directive — lvt-fx:scroll defaults to 100 and lvt-scroll-away defaults to 200 when the property is absent. Parsed as an integer; px suffix is accepted but optional |
The directive toggles a visible class on the element — your CSS controls the actual show/hide:
.scroll-bottom-btn { display: none; }
.scroll-bottom-btn.visible { display: flex; }
Pairs naturally with lvt-fx:scroll:on:click="bottom" on the same element:
<button lvt-fx:scroll:on:click="bottom"
lvt-scroll-away="bottom"
data-lvt-target="#chat-log">↓</button>
Use the native <dialog> element with command/commandfor for modal dialogs. No lvt-* attributes needed — this is a Tier 1 pattern.
The client polyfills the Invoker Commands API for browsers that don't support it natively (Firefox, Safari as of April 2026). The polyfill calls .showModal() / .close() on the target <dialog>, providing backdrop, focus trapping, and Escape key handling across all browsers. Feature detection via commandForElement makes the polyfill a no-op when native support lands.
| Button Attribute | Target | Effect |
|---|---|---|
command="show-modal" commandfor="dialog-id" |
<dialog id="dialog-id"> |
Calls .showModal() |
command="close" commandfor="dialog-id" |
<dialog id="dialog-id"> |
Calls .close() |
Any form inside a <dialog> that completes successfully will have its parent dialog closed automatically. This means the dialog stays open for validation errors but closes on success — no extra attributes needed.
A <form method="dialog"> inside a <dialog> closes the dialog immediately on submit (before the server responds). Use this only when you don't need server-side validation feedback inside the dialog.
See Progressive Complexity Guide — Dialogs for the full walkthrough.
For modals whose visibility is controlled by server state (e.g., confirmation dialogs triggered by a server action), use the lvt/components/modal package. See the todos example.
The client automatically preserves certain client-side state across server-pushed DOM updates. These behaviors require no attributes — they are built into the morphdom diffing pass.
User-toggled checked state on <input type="checkbox"> and <input type="radio"> survives DOM updates. The client copies the live DOM's .checked property onto the incoming virtual element before morphdom compares them, so morphdom sees no diff and leaves the element alone.
<!-- User checks this box; a server-pushed update won't uncheck it -->
<label><input type="checkbox" name="select" value="item-1"> Item 1</label>
Radio group caveat: Browser mutual exclusion fires synchronously during the morphdom pass. If you need to force-reset a radio group from the server, add data-lvt-force-update to all radios in the group, not just the one being checked.
When a <dialog> is opened via showModal(), the browser adds it to the top layer — a special rendering context above all other content. The open attribute alone doesn't preserve this state; morphdom's attribute sync and child reconciliation can disrupt the top-layer positioning even when open is retained. The client prevents this by skipping the entire dialog element and its subtree while open is present. The server continues sending updates while the dialog is open, but the client skips the dialog subtree during morphdom (the rest of the page still updates normally). After the dialog closes, the next server update reconciles the dialog's DOM with the current server state.
Adding data-lvt-force-update to the <dialog> overrides this skip: the client applies morphdom to the dialog's content while it remains open, allowing the server to update dialog contents in real time (e.g., live validation feedback inside a modal form).
<!-- Dialog stays open across server refreshes -->
<dialog id="settings">
<form method="POST" name="SaveSettings">
<input name="theme">
<button type="submit">Save</button>
</form>
</dialog>
Native <datalist> dropdowns are fragile — ANY DOM mutation on the page (not just to the datalist itself) dismisses the popup, and unlike checkbox state, dropdown-open has no DOM representation. The client defers the entire morphdom pass while document.activeElement is an <input> connected to a <datalist> via the list attribute.
<input type="text" list="suggestions" name="query">
<datalist id="suggestions">
<option value="alpha">
<option value="beta">
</datalist>
When the user blurs the input, the deferred morphdom pass runs, applying all pending changes (not just to the datalist, but to the entire page). Adding data-lvt-force-update to the connected <input> overrides this deferral, allowing the morphdom pass to proceed immediately even while the datalist dropdown is open.
Any form element that currently has focus is skipped during morphdom updates, preserving in-progress user input. Once the element loses focus, the next server update reconciles its value with the current server state.
<!-- User typing here won't be interrupted by server updates -->
<input type="text" name="search" value="{{.Query}}">
To override this for a specific input — e.g., when a server-controlled value must always win — add data-lvt-force-update to the element.
data-lvt-force-updateAll automatic preservation behaviors can be overridden by adding data-lvt-force-update to the element in the server template. When present, the server's value wins over the client-side state. The client strips the attribute from the live DOM after applying the update; because it lives in the server template, the server re-sends it on every render, so it continuously forces the server value.
<!-- Server always controls this checkbox -->
<input type="checkbox" name="locked" data-lvt-force-update {{if .Locked}}checked{{end}}>
| Preserved State | Mechanism | Override |
|---|---|---|
Checkbox/radio checked |
Property copied to virtual DOM | data-lvt-force-update on the input |
Dialog open |
morphdom update skipped while dialog is open | data-lvt-force-update on the dialog |
| Datalist dropdown | Entire morphdom pass deferred while datalist input focused | data-lvt-force-update on the connected <input> (overrides deferral for the entire pass) |
| Focused input elements | morphdom update skipped | data-lvt-force-update on the input |
lvt-ignoreFor cases where automatic preservation doesn't cover your needs, two attributes provide explicit control:
lvt-ignore — Skips the element and its entire subtree during morphdom diff. Use this for third-party widgets (maps, rich-text editors) whose DOM is managed by external JavaScript. Checked on the live DOM element, so it can be set from templates or client JS. Equivalent to Phoenix LiveView's phx-update="ignore".
lvt-ignore-attrs — Skips attribute diffing but still diffs children. Use this when client-set attributes (e.g., open on <details>) need to survive server updates while child content remains server-managed.
Both can be overridden by data-lvt-force-update when the server needs to take control — adding it to an lvt-ignore element re-enables morphdom for that subtree for the current update.
Handle file uploads with progress tracking.
<form method="POST">
<input type="file" lvt-upload="avatar" name="avatar">
<button name="save-profile" type="submit">Save</button>
</form>
<input type="file" lvt-upload="documents" name="docs" multiple>
| Attribute | Description |
|---|---|
lvt-upload |
Upload identifier for tracking |
Files are automatically uploaded when the form is submitted, with progress events emitted.
By default, forms reset after successful submission. Use lvt-form:preserve to keep form values:
<form name="search" method="POST" lvt-form:preserve>
<input name="query">
<button type="submit">Search</button>
</form>
Show loading state on submit buttons:
<form method="POST">
<input name="title">
<button name="save" type="submit" lvt-form:disable-with="Saving...">Save</button>
</form>
Use standard onsubmit for confirmation dialogs:
<form method="POST" onsubmit="return confirm('Are you sure?')">
<button name="delete">Delete</button>
</form>
Complete reference of all lvt-* and data-* template attributes.
lvt-on:)| Attribute | Description | Example |
|---|---|---|
lvt-on:click |
Click event | <button lvt-on:click="save"> |
lvt-on:change |
Input change event | <select lvt-on:change="sort"> |
lvt-on:input |
Input event (every keystroke) | <input lvt-on:input="search"> |
lvt-on:keydown |
Keydown event | <input lvt-on:keydown="submit"> |
lvt-on:keyup |
Keyup event | <input lvt-on:keyup="handle"> |
lvt-on:focus |
Focus event | <input lvt-on:focus="highlight"> |
lvt-on:blur |
Blur event | <input lvt-on:blur="validate"> |
lvt-on:mouseenter |
Mouse enter event | <div lvt-on:mouseenter="show"> |
lvt-on:mouseleave |
Mouse leave event | <div lvt-on:mouseleave="hide"> |
lvt-on:click-away |
Click outside element | <div lvt-on:click-away="close"> |
lvt-on:window:keydown |
Global keydown | <div lvt-on:window:keydown="close"> |
lvt-on:window:keyup |
Global keyup | <div lvt-on:window:keyup="handle"> |
lvt-on:window:scroll |
Window scroll | <div lvt-on:window:scroll="load"> |
lvt-on:window:resize |
Window resize | <div lvt-on:window:resize="adjust"> |
lvt-on:window:focus |
Window focus | <div lvt-on:window:focus="refresh"> |
lvt-on:window:blur |
Window blur | <div lvt-on:window:blur="pause"> |
| Attribute | Description | Example |
|---|---|---|
lvt-data-<key> |
Pass data to action | lvt-data-id="{{.ID}}" |
lvt-value-<key> |
Pass value to action | lvt-value-count="{{.Count}}" |
Note: Both lvt-data-* and lvt-value-* attributes are accessible via ctx.GetString(), ctx.GetInt(), etc.
lvt-el:)| Attribute | Description | Example |
|---|---|---|
lvt-el:reset:on:{trigger} |
Reset form on trigger | lvt-el:reset:on:success |
lvt-el:addClass:on:{trigger} |
Add class(es) on trigger | lvt-el:addClass:on:pending="loading" |
lvt-el:removeClass:on:{trigger} |
Remove class(es) on trigger | lvt-el:removeClass:on:done="loading" |
lvt-el:toggleClass:on:{trigger} |
Toggle class(es) on trigger | lvt-el:toggleClass:on:click="active" |
lvt-el:setAttr:on:{trigger} |
Set attribute on trigger | lvt-el:setAttr:on:pending="aria-busy:true" |
lvt-el:toggleAttr:on:{trigger} |
Toggle boolean attr on trigger | lvt-el:toggleAttr:on:pending="disabled" |
Note: {trigger} can be a lifecycle state (pending, success, error, done), any native DOM event (click, focusin, focusout, mouseenter, mouseleave, etc.), or the synthetic click-away. For action-specific: lvt-el:reset:on:create-todo:success.
lvt-mod:)| Attribute | Description | Example |
|---|---|---|
lvt-key |
Filter keyboard events by key | lvt-key="Enter" |
lvt-mod:debounce |
Debounce delay in milliseconds | lvt-mod:debounce="300" |
lvt-mod:throttle |
Throttle interval in milliseconds | lvt-mod:throttle="100" |
lvt-form:, lvt-nav:)| Attribute | Description | Example |
|---|---|---|
lvt-form:action |
Explicit action routing on form | <form lvt-form:action="checkout"> |
lvt-form:preserve |
Keep form values after submit | <form lvt-form:preserve> |
lvt-form:disable-with |
Button text during submit | lvt-form:disable-with="Saving..." |
lvt-form:no-intercept |
Opt-out of form interception | <form lvt-form:no-intercept> |
lvt-nav:no-intercept |
Opt-out of link interception | <a lvt-nav:no-intercept> |
lvt-fx:)| Attribute | Description | Example |
|---|---|---|
lvt-fx:scroll |
Scroll behavior | lvt-fx:scroll="bottom" |
lvt-fx:highlight |
Highlight effect | lvt-fx:highlight="flash" |
lvt-fx:animate |
Entrance animation | lvt-fx:animate="fade" |
Directives use CSS custom properties for configuration: --lvt-scroll-behavior, --lvt-scroll-threshold, --lvt-highlight-color, --lvt-highlight-duration, --lvt-animate-duration. DOM event triggers resolve data-lvt-target to apply effects to a different element.
| Attribute | Description | Example |
|---|---|---|
lvt-scroll-sentinel |
Infinite-scroll trigger (IntersectionObserver sends hardcoded load_more action) |
<div lvt-scroll-sentinel> |
lvt-scroll-away |
Show/hide based on scroll position | lvt-scroll-away="bottom" |
| Attribute | Description | Example |
|---|---|---|
lvt-upload |
File upload identifier | lvt-upload="avatar" |
| Attribute | Description | Example |
|---|---|---|
lvt-ignore |
Skip this element and its entire subtree during morphdom diff. Checked on the live DOM (fromEl), usable from both templates and client JS. Equivalent to Phoenix LiveView's phx-update="ignore" |
<div lvt-ignore class="map-widget"> |
lvt-ignore-attrs |
Skip attribute diffing but still diff children. Preserves client-set attributes (e.g. open on <details>) while keeping child content server-managed |
<details lvt-ignore-attrs> |
data-lvt-force-update |
Override all preservation (automatic, lvt-ignore, and lvt-ignore-attrs); server value wins. Client strips it after processing; server re-sends it each render |
<input type="checkbox" data-lvt-force-update> |
| Attribute | Description | Example |
|---|---|---|
data-key |
Stable element identity for the diff engine and morphdom matching. In {{range}} templates, controls which items are updated in-place vs. removed/inserted. On singleton elements, helps morphdom match nodes across updates. Hardcoded keys are valid for singletons; use template expressions ({{.ID}}) in lists |
<dialog data-key="settings-dialog"> |
| Attribute | Description | Example |
|---|---|---|
lvt-scroll-sentinel |
Marks an element as the infinite scroll sentinel. The client's IntersectionObserver watches this element; when it enters the viewport (default 200px rootMargin; see observer module in livetemplate/client), the client dispatches load_more automatically. Only the first matching element per wrapper is observed. Wrap in {{if .HasMore}}...{{end}} to prevent infinite empty-load loops |
<div lvt-scroll-sentinel>Loading more...</div> |
For lvt-key attribute (not data-key):
"a", "b", "c", etc."Enter", "Escape", "Space", "Tab", "Backspace", "Delete""ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight""F1", "F2", etc.e.ctrlKey, e.shiftKey, e.altKey, e.metaKey in event listenersPrefer declarative reactive attributes over JavaScript for common UI patterns:
<!-- Good: Declarative loading state -->
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:addClass:on:pending="opacity-50"
lvt-el:toggleAttr:on:done="disabled"
lvt-el:removeClass:on:done="opacity-50">
Save
</button>
<!-- Avoid: JavaScript for simple loading state -->
<input
lvt-on:input="search"
lvt-mod:debounce="300"
name="query">
<div lvt-on:window:scroll="loadMore" lvt-mod:throttle="100">
<input
type="email"
name="email"
{{if .lvt.HasError "email"}}aria-invalid="true"{{end}}>
{{if .lvt.HasError "email"}}
<span class="error">{{.lvt.Error "email"}}</span>
{{end}}
Use reactive attributes for automatic form reset:
<form name="create-todo" method="POST" lvt-el:reset:on:success>
<input name="title" placeholder="New todo">
<button type="submit">Add</button>
</form>
<button name="save"
lvt-el:setAttr:on:pending="aria-busy:true"
lvt-el:setAttr:on:done="aria-busy:false"
lvt-el:setAttr:on:error="aria-invalid:true">
Save
</button>
document.addEventListener('lvt:connected', () => {
console.log('WebSocket connected');
});
document.addEventListener('lvt:disconnected', () => {
console.log('WebSocket disconnected');
});
form.addEventListener('lvt:pending', (e) => {
const formData = new FormData(e.target);
console.log('Submitting:', Object.fromEntries(formData));
});