Pattern Showcase
Every HTMX and Alpine.js pattern this starter supports, live and grouped by what it teaches. Each demo is the real thing — the source shown next to it is what serves it.
Fetch & swap
The core loop: an attribute fires a request, the server renders HTML, HTMX places it.
Partial swap
Click a button, replace a fragment. The simplest HTMX pattern — a GET returns rendered HTML and hx-target places it.
hx-get hx-target hx-swap <button hx-get="/patterns/api/swap"
hx-target="#swap-demo" hx-swap="innerHTML">
Swap this fragment
</button>
<div id="swap-demo">...</div>func PatternSwap(w http.ResponseWriter, r *http.Request) {
view.Render(w, r, http.StatusOK,
partials.PatternSwapResult())
}Skeleton loading
A CSS skeleton renders instantly; HTMX replaces it with real content on a delayed load trigger.
hx-get hx-trigger="load delay:1.5s" <div hx-get="/patterns/api/skeleton"
hx-trigger="load delay:1.5s">
<div class="animate-pulse">...</div>
</div>// The server returns immediately — the
// delay is client-side, so no goroutine
// sleeps on a demo's behalf.
view.Render(w, r, http.StatusOK,
partials.PatternSkeletonContent())Tabs
Tab buttons fetch server-rendered panels; Alpine handles the client-only variant (see the source panel you're using).
hx-get hx-target x-datax-show<button hx-get="/patterns/api/tab/templ"
hx-target="#tab-demo-panel">templ</button>
<div id="tab-demo-panel"></div>name := chi.URLParam(r, "name")
content, ok := patternTabs[name]
if !ok { http.NotFound(w, r); return }
view.Render(w, r, http.StatusOK,
partials.PatternTabPanel(props))Polling
A live server value without WebSockets: the element re-fetches itself on a fixed interval — the whole real-time stack is one attribute.
hx-trigger="load, every 5s" hx-get <div hx-get="/patterns/api/time"
hx-trigger="load, every 5s"
hx-swap="innerHTML">...</div>now := time.Now().Format("15:04:05")
view.Render(w, r, http.StatusOK,
partials.PatternTimeTick(now, load))Search & lists
Server-filtered results with debounce, indicators, and pagination that loads itself.
Live search
Type in a search box; results filter server-side with a debounce, so the server stays the single source of truth.
hx-get hx-trigger="keyup changed delay:300ms" hx-target <input type="search" name="q"
hx-get="/patterns/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"/>q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" { // cleared input clears the results
w.WriteHeader(http.StatusOK)
return
}
results := filterStubData(q)
view.Render(w, r, http.StatusOK,
partials.PatternSearchResults(props))Infinite scroll
Scroll to the bottom and the next page loads automatically; the sentinel row replaces itself with each new page. Inside a scrollable container the trigger is intersect, not revealed — revealed only watches the window scroll.
hx-get hx-trigger="intersect once" hx-swap="outerHTML" - Scroll me into view...
<li hx-get="/patterns/api/scroll?page=2"
hx-trigger="intersect once" hx-swap="outerHTML">
Loading more...
</li>page, _ := strconv.Atoi(r.URL.Query().Get("page"))
items, next := stubPage(page)
view.Render(w, r, http.StatusOK,
partials.PatternScrollPage(props))Active search (typeahead)
Like live search, plus a loading indicator that HTMX toggles for you while the request is in flight.
hx-get hx-trigger="keyup changed delay:200ms" hx-indicator <input hx-get="/patterns/api/typeahead"
hx-trigger="keyup changed delay:200ms"
hx-indicator="#typeahead-indicator"/>
<span id="typeahead-indicator"
class="htmx-indicator">Searching...</span>// Identical server shape to live search —
// the indicator is pure client wiring.Forms & actions
Validation, editing, confirmation, and honest in-flight feedback — all server-round-trip.
Click to edit
Click text to turn it into an edit form; saving swaps the display view back in. Two endpoints, zero client state.
hx-get hx-put hx-swap <div hx-get="/patterns/api/edit/1"
hx-swap="outerHTML">{ value }</div>
<form hx-put="/patterns/api/edit/1"
hx-swap="outerHTML">...</form>// GET returns the form; PUT saves and
// returns the display view.
value := r.FormValue("value")
view.Render(w, r, http.StatusOK,
partials.PatternEditDisplay(props))Inline validation
Alpine flags the field once you leave it (not on the first keystroke), then clears live as you fix it; the server re-checks on submit and returns field errors into the same markup.
hx-post hx-target x-datax-modelx-show@blur<form x-data="{ email: '', touched: false }">
<input type="email" x-model="email"
@blur="touched = true"/>
<p x-show="touched && email
&& !email.includes('@')">
That doesn't look like an email.
</p>
</form>// Server-side mirror of the client rules —
// see ProfileUpdate for the full pattern:
// errors map rendered at 422 into the form.Optimistic UI
A favorite toggle that swaps instantly — current state rides along in hx-vals, so the endpoint stays stateless.
hx-post hx-swap="outerHTML" hx-vals x-transition<button hx-post="/patterns/api/favorite/1"
hx-vals={ favoritedState }
hx-swap="outerHTML" x-transition>
☆ Favorite
</button>favorited := r.FormValue("favorited") == "true"
props := partials.PatternFavoriteProps{
ID: id, Favorited: !favorited}
view.Render(w, r, http.StatusOK,
partials.PatternFavoriteButton(props))Bulk operations
Alpine tracks the checkbox selection client-side; one HTMX POST submits the whole batch as form values.
hx-post hx-target x-datax-modelx-text<form x-data="{ selected: [] }"
hx-post="/patterns/api/bulk"
hx-target="#bulk-demo-result">
<input type="checkbox" name="selected"
x-model="selected"/>
<button :disabled="selected.length === 0">
Archive (<span x-text="selected.length"/>)
</button>
</form>r.ParseForm()
count := len(r.Form["selected"])
view.Render(w, r, http.StatusOK,
partials.PatternBulkResult(count))Confirm + disabled button
The htmx:confirm event pauses the request while a styled Alpine dialog asks — no native window.confirm popup — and hx-disabled-elt holds the button while it runs. hx-confirm stays on as the no-Alpine fallback.
htmx:confirm hx-confirm hx-disabled-elt="this" x-datax-teleportx-showDelete this demo item?
The request is paused on the htmx:confirm event — Delete resumes it, Cancel drops it.
<div x-data="{ open: false, issue: null }"
@htmx:confirm.prevent="issue =
$event.detail.issueRequest; open = true">
<button hx-post="/patterns/api/confirm"
hx-confirm="Delete this demo item?"
hx-disabled-elt="this">Delete item</button>
<!-- styled dialog; Delete resumes with
issue(true), Cancel just closes -->
</div>// The interesting part happens client-side
// before this runs: htmx:confirm pauses the
// request until issueRequest(true) resumes it.
view.Render(w, r, http.StatusOK,
partials.PatternConfirmResult())Loading states
The right way to show in-flight work: a per-element spinner (hx-indicator) and a disabled trigger (hx-disabled-elt) — never a full-screen overlay.
hx-indicator hx-disabled-elt="this" <button hx-get="/patterns/api/slow"
hx-indicator="#slow-spin"
hx-disabled-elt="this"
hx-target="#slow-out">Load slowly</button>
<span id="slow-spin" class="htmx-indicator">
Working...
</span>// Deliberate delay so there is something
// to indicate (capped at 1.5s).
time.Sleep(delay)
view.Render(w, r, http.StatusOK,
partials.PatternSlowContent())Server-driven UX
Responses that steer the page: toasts from headers, multi-region swaps, animated transitions — and an honest 429 when the server says no.
Toast notifications
The response body swaps a tiny status line; the toast itself fires from the HX-Trigger header into an Alpine listener.
HX-Trigger HX-Toast-Type x-datax-forx-transition<button hx-post="/patterns/api/toast"
hx-vals={ toastType }>Success toast</button>
<!-- base layout listens for HX-Trigger
and dispatches a toast event -->view.SetHXTrigger(w, "Toast from the server!")
w.Header().Set("HX-Toast-Type", toastType)
view.Render(w, r, http.StatusOK,
partials.PatternToastResult())Out-of-band swap
One response updates two regions: the button replaces itself, and a badge elsewhere updates via hx-swap-oob riding the same payload.
hx-swap-oob hx-vals hx-swap="outerHTML" The badge is a separate region — it updates via hx-swap-oob in the button's response.
<!-- response contains BOTH: -->
<button id="oob-counter-btn" ...>Add item</button>
<span id="oob-total" hx-swap-oob="true">
3 added
</span>count, _ := strconv.Atoi(r.FormValue("count"))
view.Render(w, r, http.StatusOK,
partials.PatternCounterResponse(
partials.PatternCounterProps{Count: count + 1}))View Transitions
transition:true on the swap animates it through the browser's View Transitions API — cross-fade with zero animation code, instant fallback elsewhere.
hx-swap="innerHTML transition:true" Teal
Swapped with a cross-fade via the View Transitions API - no animation library.
<button hx-get="/patterns/api/transition?step=1"
hx-target="#vt-card"
hx-swap="innerHTML transition:true">
Next card
</button>step, _ := strconv.Atoi(r.URL.Query().Get("step"))
view.Render(w, r, http.StatusOK,
partials.PatternTransitionCard(step))Rate limit: 429 with Retry-After
Click faster than the tier allows and the production limiter refuses — a real 429 with Retry-After, rendered instead of swallowed. Same middleware as the auth and /learn tiers, tighter numbers.
hx-swap="beforeend" htmx:beforeSwap Retry-After <button hx-get="/patterns/api/limited"
hx-target="#limit-log" hx-swap="beforeend">
Hit the tier
</button>
<ol id="limit-log"></ol>
<!-- app.js: swap 429s that carry HTML -->api.With(mw.RateLimiterWith(ctx, 0.5, 3,
http.HandlerFunc(PatternLimitRefused))).
Get("/limited", PatternLimitAllowed)
// the limiter sets Retry-After, then:
func PatternLimitRefused(w, r) {
view.Render(w, r, http.StatusTooManyRequests,
partials.PatternLimitRefused(
w.Header().Get("Retry-After")))
}Alpine islands
The client-only slice: state, teleported modals, and stores shared across components.
Dark mode
Alpine state seeded from the system preference, persisted to localStorage, applied as a class on the root element.
x-data$watchlocalStorageThe toggle in the header is this pattern: Alpine state persisted to localStorage, seeded from the system preference, applied as a class on the root element. This panel restyles with it — try it.
<body x-data="{ dark: localStorage.getItem('dark')
=== 'true' || prefersDark }"
x-init="$watch('dark', v => ...)">// No handler — this pattern is entirely
// client-side; the server ships one class
// toggle and no JavaScript framework.Modal (teleport)
An accessible Alpine modal: x-teleport lifts it to <body> so no ancestor clips it, Escape and backdrop close it, focus lands inside.
x-teleportx-show@keydown.escape.windowx-transitionTeleported modal
This dialog lives at the end of <body> via x-teleport, so no ancestor stacking context can clip it. Escape or the backdrop closes it.
<button @click="open = true">Open modal</button>
<template x-teleport="body">
<div x-show="open" @keydown.escape.window="open = false">
<div @click="open = false" class="backdrop"></div>
<div role="dialog" aria-modal="true">...</div>
</div>
</template>// No handler — entirely client-side.
// Server-rendered pages still work without
// it: the modal content is enhancement.Global store
Alpine.store shares state across unrelated components: two separate x-data islands read and write one store registered in app.js.
Alpine.store$storex-textTwo unrelated x-data islands sharing one Alpine.store registered in app.js.
<!-- two unrelated components: -->
<button @click="$store.demo.inc()">+1</button>
...
<span x-text="$store.demo.count"></span>// app.js, at alpine:init:
Alpine.store("demo", {
count: 0,
inc() { this.count++ },
})