LiveTemplate follows a two-tier progressive complexity model:
lvt-* Attributes — debounce, reactive DOM, lifecycle hooks. Only when HTML can't express it.This guide walks through Tier 1 from the simplest case to full-featured applications.
A form inside a LiveTemplate handler just works. No lvt-* attributes, no hidden fields, no special setup:
<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")
state.Items = append(state.Items, Todo{Title: title})
return state, nil
}
What happens: The framework auto-intercepts all forms. When no action is specified, it routes to Submit(). This works at all three transport levels: no-JS (POST + page reload), fetch (DOM patch), and WebSocket.
When a form needs multiple actions, the button name IS the action:
<form method="POST">
<input type="text" name="Title" value="{{.Title}}">
<button name="save">Save</button>
<button name="save-draft" formnovalidate>Save Draft</button>
</form>
func (c *Controller) Save(state State, ctx *livetemplate.Context) (State, error) {
// Validated save
return state, nil
}
func (c *Controller) SaveDraft(state State, ctx *livetemplate.Context) (State, error) {
// Save without validation (formnovalidate skips HTML validation)
return state, nil
}
The clicked button's name determines which method is called. Button value becomes data:
{{range .Items}}
<form method="POST">
<input type="hidden" name="id" value="{{.ID}}">
<span>{{.Title}}</span>
<button name="toggle">{{if .Done}}Undo{{else}}Done{{end}}</button>
<button name="delete" value="{{.ID}}">Delete</button>
</form>
{{end}}
func (c *Controller) Toggle(state State, ctx *livetemplate.Context) (State, error) {
id := ctx.GetString("id") // from hidden input
// toggle item...
return state, nil
}
func (c *Controller) Delete(state State, ctx *livetemplate.Context) (State, error) {
id := ctx.GetString("value") // from button value
// delete item...
return state, nil
}
Buttons with a name attribute work as actions even outside any <form> (requires JS client — fetch or WebSocket):
<h1>Counter: {{.Counter}}</h1>
<button name="increment">+</button>
<button name="decrement">-</button>
The button's name routes to the corresponding Go method. Button value and data-* attributes are sent as action data:
<button name="delete" value="{{.ID}}">Delete</button>
<button name="edit" data-id="{{.ID}}" data-mode="quick">Quick Edit</button>
No-JS fallback: For progressive enhancement without JavaScript, wrap buttons in a
<form method="POST">instead (see Section 2).
Note: Auto-wiring the form schema from template statics is not yet implemented. Currently you must call
ctx.WithFormSchema(ExtractFormSchema(statics))manually. For production validation, usectx.BindAndValidate()with struct tags.formnovalidateon buttons is not yet respected server-side.
HTML validation attributes (required, pattern, min, max, minlength, maxlength, type) can be extracted by the framework. Use ctx.ValidateForm() instead of writing Go struct tags:
<form method="POST">
<input type="email" name="Email" required minlength="5" maxlength="100">
{{if .lvt.HasError "email"}}
<span class="error">{{.lvt.Error "email"}}</span>
{{end}}
<input type="number" name="Age" min="18" max="120">
{{if .lvt.HasError "age"}}
<span class="error">{{.lvt.Error "age"}}</span>
{{end}}
<input type="text" name="Code" pattern="[A-Z]{3}">
<button type="submit">Submit</button>
</form>
func (c *Controller) Submit(state State, ctx *livetemplate.Context) (State, error) {
if err := ctx.ValidateForm(); err != nil {
return state, err // Errors auto-displayed via .lvt.HasError/.lvt.Error
}
// All fields valid
state.Email = ctx.GetString("Email")
state.Age = ctx.GetInt("Age")
return state, nil
}
No Go struct tags needed. The required, type="email", minlength="5", min="18" attributes are the validation rules.
Use formnovalidate on buttons that should skip validation:
<button type="submit">Save</button>
<button name="save-draft" formnovalidate>Save Draft</button>
Use the standard <dialog> element with command/commandfor for native modal dialogs:
<!-- Open button -->
<button command="show-modal" commandfor="edit-dialog">Edit</button>
<!-- Dialog with form -->
<dialog id="edit-dialog">
<form name="save">
<h2>Edit Item</h2>
<input name="title" value="{{.Title}}">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit">Save</button>
<button type="button" command="close" commandfor="edit-dialog">Cancel</button>
</form>
</dialog>
command="show-modal" opens the dialog via .showModal() — backdrop, focus trapping, and Escape key handling are all native to <dialog>command="close" closes it via .close()<dialog> when a form submission succeeds — so the dialog stays open for validation errors but closes on successcommand/commandfor for browsers that don't yet support the Invoker Commands API natively (Firefox, Safari). The polyfill uses feature detection (commandForElement) and becomes a no-op when browsers add native supportLinks inside the LiveTemplate wrapper are auto-intercepted for SPA navigation:
<nav>
<a href="/todos">Todos</a>
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
</nav>
The framework fetches the page via fetch(), extracts the wrapper content, and replaces the DOM. No full page reload. Browser history (pushState) is updated automatically.
Opt-out for links that should navigate normally:
<a href="/api/export.csv" download>Export</a> <!-- download attr: skipped -->
<a href="https://external.com">External</a> <!-- different origin: skipped -->
<a href="/legacy-page" lvt-nav:no-intercept>Old Page</a> <!-- explicit opt-out -->
During form submission, the framework automatically:
aria-busy="true" on the form<fieldset> elements inside the form (if present)<form method="POST">
<fieldset>
<input name="title">
<button type="submit">Save</button>
</fieldset>
</form>
<style>
form[aria-busy="true"] fieldset {
opacity: 0.5;
pointer-events: none;
}
</style>
No lvt-* attributes needed. The <fieldset> wrapping is the signal.
Use standard onsubmit for confirmation dialogs:
<form method="POST" onsubmit="return confirm('Delete this item?')">
<input type="hidden" name="id" value="{{.ID}}">
<button name="delete">Delete</button>
</form>
Use native <details> and <summary>:
<details>
<summary>Advanced Options</summary>
<div>
<input name="advanced_setting" value="{{.AdvancedSetting}}">
</div>
</details>
Works without JavaScript. Keyboard accessible by default.
Add a Change() method to your controller to enable live updates as the user types — no lvt-* attributes needed:
<form method="POST">
<input name="Name" value="{{.Name}}">
<div class="preview">Hello, {{.Name}}!</div>
<button type="submit">Save</button>
</form>
func (c *Controller) Change(state State, ctx *livetemplate.Context) (State, error) {
if ctx.Has("Name") { state.Name = ctx.GetString("Name") }
return state, nil
}
What happens: The server detects the Change() method and sends capabilities: ["change"] in the initial render. The client auto-wires debounced input events (300ms default) on form fields with dynamic values. The preview updates live as the user types. If no Change() method exists, the form is submit-only.
Override the default debounce per input with lvt-mod:debounce:
<input name="Name" value="{{.Name}}" lvt-mod:debounce="500">
A todo app using Tier 1 only (zero lvt-* attributes):
<h1>Todos ({{.ActiveCount}} remaining)</h1>
<form method="POST">
<input type="text" name="Title" required minlength="1" placeholder="New todo...">
{{if .lvt.HasError "title"}}
<span class="error">{{.lvt.Error "title"}}</span>
{{end}}
<button type="submit">Add</button>
</form>
<ul>
{{range .FilteredItems}}
<li data-key="{{.ID}}">
<form method="POST">
<input type="hidden" name="id" value="{{.ID}}">
<span>{{.Title}}</span>
<button name="toggle">{{if .Done}}Undo{{else}}Done{{end}}</button>
<button name="delete">Delete</button>
</form>
</li>
{{end}}
</ul>
<form name="filter" method="POST">
<button name="filter" value="all">All</button>
<button name="filter" value="active">Active</button>
<button name="filter" value="done">Done</button>
</form>
func (c *TodoController) Submit(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if err := ctx.ValidateForm(); err != nil {
return state, err
}
state.Items = append(state.Items, Todo{ID: uuid.New(), Title: ctx.GetString("Title")})
return state, nil
}
func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
// toggle by ctx.GetString("id")
return state, nil
}
func (c *TodoController) Delete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
// delete by ctx.GetString("id")
return state, nil
}
func (c *TodoController) Filter(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.ActiveFilter = ctx.GetString("filter")
return state, nil
}
LiveTemplate supports three transport layers that degrade gracefully: WebSocket → fetch (HTTP) → no-JS (POST + page reload). All Tier 1 features (Sections 1-11) work across all three layers. This is controlled by the ProgressiveEnhancement config flag (default: true).
When JavaScript is unavailable, forms submit as standard HTML POST requests. The server uses the Post-Redirect-Get (PRG) pattern:
<form method="POST">lvt-flash cookie (10-second max-age, consumed immediately on the next GET)This is why all Tier 1 examples use <form method="POST"> — they work without JavaScript by design.
When JavaScript is available but WebSocket is not connected, the JS client intercepts form submissions and sends them via fetch(). The server responds with a JSON tree update, and the client patches the DOM. No page reload occurs.
This transport is also used as the automatic fallback when a WebSocket connection disconnects.
Full bidirectional communication. Actions are sent as WebSocket messages, and the server can push updates at any time. Server push (Session.TriggerAction(), ctx.BroadcastAction()) is only available in this mode.
The server determines the client's transport from the HTTP request:
| Signal | Transport | Response |
|---|---|---|
| WebSocket upgrade header | WebSocket | Upgrade to WebSocket, send JSON trees |
Accept: application/json |
fetch (JS client) | JSON tree update |
Standard browser Accept: text/html |
No JS | Full HTML page (PRG pattern for POST) |
For a complete feature-by-transport breakdown, see the Transport Compatibility table in the reference doc.
tmpl := livetemplate.New("app",
livetemplate.WithProgressiveEnhancement(false),
)
When disabled, POST requests from non-JS browsers return JSON instead of HTML. Only disable this if all clients have JavaScript.
lvt-* AttributesUse lvt-* attributes only when standard HTML cannot express the behavior. For the complete attribute reference, see the Client Attributes Reference.
For interactions outside the form submit lifecycle — hover effects, focus/blur tracking:
<!-- Server-rendered tooltip on hover (use CSS :hover for static tooltips instead) -->
<div lvt-on:mouseenter="showTooltip" lvt-on:mouseleave="hideTooltip">
{{.Label}}
{{if .TooltipVisible}}<span class="tooltip">{{.TooltipText}}</span>{{end}}
</div>
Prefer Tier 1 when possible: For buttons that trigger actions, use
<form>+<button name="action" value="save">instead oflvt-on:click. See Section 2.
See Client Attributes Reference — Event Bindings for the full list of lvt-on:{event} bindings.
HTML has no mechanism for debounce or throttle. Debounce waits until the user stops (ideal for typing). Throttle limits frequency (ideal for scroll/resize). Both are essential for search inputs and scroll handlers:
<!-- Wait 300ms after user stops typing -->
<input lvt-on:input="search" lvt-mod:debounce="300" name="query" placeholder="Search...">
<!-- Fire scroll handler at most once per 100ms -->
<div lvt-on:window:scroll="loadMore" lvt-mod:throttle="100">...</div>
See Client Attributes Reference — Rate Limiting for details.
Filter events by key and listen at the window level for global shortcuts:
<!-- Submit search on Enter key only -->
<input lvt-on:keydown="submitSearch" lvt-key="Enter" name="query">
<!-- Global Escape key to close modal -->
<div lvt-on:window:keydown="closeModal" lvt-key="Escape">
Modal content...
</div>
See Client Attributes Reference — Keyboard Events for valid key values.
Declarative DOM mutations tied to the action lifecycle (pending, success, error, done):
<!-- Button with loading state -->
<button name="save"
lvt-el:toggleAttr:on:pending="disabled"
lvt-el:addClass:on:pending="opacity-50"
lvt-el:removeClass:on:done="opacity-50">
Save
</button>
<!-- Reset form after successful submission -->
<form method="POST" lvt-el:reset:on:success>
<input name="title" placeholder="New todo">
<button type="submit">Add</button>
</form>
Available reactive actions: lvt-el:addClass:on:*, lvt-el:removeClass:on:*, lvt-el:toggleClass:on:*, lvt-el:setAttr:on:*, lvt-el:toggleAttr:on:*, lvt-el:reset:on:*.
See Client Attributes Reference — Reactive Attributes for the full pattern.
Declarative UI behaviors for scroll management, visual feedback, and animations. Configuration uses CSS custom properties (defaults provided by livetemplate.css):
<!-- Chat messages: auto-scroll to bottom, stick if user is near bottom -->
<div lvt-fx:scroll="bottom-sticky" style="--lvt-scroll-threshold: 100px" class="chat-messages">
{{range .Messages}}
<div>{{.Text}}</div>
{{end}}
</div>
<!-- Preserve scroll position across updates (e.g., search results) -->
<div lvt-fx:scroll="preserve" class="results">
{{range .Results}}
<div>{{.Title}}</div>
{{end}}
</div>
<!-- Highlight updated items -->
<div lvt-fx:highlight="flash">{{.UpdatedContent}}</div>
<!-- Fade in new content -->
<div lvt-fx:animate="fade">{{.NewContent}}</div>
Scroll modes: bottom (always scroll to bottom), bottom-sticky (scroll only if user is near bottom), top (scroll to top), preserve (maintain current scroll position across updates).
CSS custom properties: --lvt-scroll-behavior, --lvt-scroll-threshold, --lvt-highlight-color, --lvt-highlight-duration, --lvt-animate-duration.
Directives also support lifecycle and DOM event triggers via :on: syntax. Without :on:, the directive fires on every DOM content update. With :on:{state}, it fires on a lifecycle state. With :on:{event}, it fires on a native DOM event:
<!-- Highlight on successful save action -->
<div lvt-fx:highlight:on:save:success="flash">Save confirmed</div>
<!-- Highlight on click (DOM event trigger, no server round-trip) -->
<div lvt-fx:highlight:on:click="flash">Click to highlight</div>
See Client Attributes Reference — Directives for all scroll, highlight, and animation options.
A search interface combining debounced input, loading states, keyboard shortcuts, and scroll preservation.
Change()vslvt-input: UseChange()(Section 10) when you want generic live-update on all form inputs — nolvt-*needed. Uselvt-inputwhen you need per-element control: a specific action name, custom debounce, or only some inputs triggering server calls.
<h1>Search</h1>
<!-- Global Escape key clears the search -->
<div lvt-on:window:keydown="clearSearch" lvt-key="Escape">
<!-- lvt-on:input fires directly without a form — Tier 2 event binding -->
<input name="Query" value="{{.Query}}"
lvt-on:input="search" lvt-mod:debounce="300"
lvt-el:addClass:on:pending="border-blue-500"
lvt-el:removeClass:on:done="border-blue-500"
placeholder="Type to search...">
<form method="POST">
<button name="clearSearch"
lvt-el:toggleAttr:on:pending="disabled">
Clear
</button>
</form>
<div class="results" lvt-fx:scroll="preserve">
{{if .Query}}
<p>{{len .Results}} results for "{{.Query}}"</p>
{{end}}
{{range .Results}}
<div data-key="{{.ID}}" lvt-fx:animate="fade">
<h3>{{.Title}}</h3>
<p>{{.Summary}}</p>
</div>
{{end}}
</div>
</div>
type SearchController struct {
DB *sql.DB
}
type SearchState struct {
Query string
Results []Result
}
func (c *SearchController) Search(state SearchState, ctx *livetemplate.Context) (SearchState, error) {
state.Query = ctx.GetString("Query")
if state.Query == "" {
state.Results = nil
return state, nil
}
results, err := c.DB.Search(state.Query)
if err != nil {
return state, err
}
state.Results = results
return state, nil
}
func (c *SearchController) ClearSearch(state SearchState, ctx *livetemplate.Context) (SearchState, error) {
state.Query = ""
state.Results = nil
return state, nil
}
See also: Progressive Complexity Reference for a quick-lookup table of HTML attributes and their framework behaviors, Client Attributes Reference for the complete lvt-* attribute listing, and Ephemeral Components Guide for implementing client-side toast/alert patterns.