Toasts with HTMX — the clean way to say “it worked”
· web
Frontend engineers love pain. Otherwise, I can’t explain React. For years, the simplest “Saved!” notification required a JS framework, a component library, a toast provider, and a 200-kB hydration blob.
HTMX fixes that. It brings HTML back into the conversation — simple, declarative, and boring in the best possible way. Let’s build toast notifications with it, starting with zero JavaScript and climbing slowly (and reluctantly) toward three lines of JS.
Step 1 — Zero JS, pure HTMX + CSS
HTMX can swap fragments of HTML into your page, even outside the normal target. That’s the key to a clean, no-JS toast system.
<div id="toasts" class="toasts" aria-live="polite" aria-atomic="true"></div>
<form hx-post="/save" hx-target="#result" hx-swap="innerHTML">
<input name="title" placeholder="Title" />
<button type="submit">Save</button>
</form>
<div id="result"></div>
<script src="https://unpkg.com/htmx.org@2.0.3"></script>When the server handles /save, it responds with both the normal content and an “out-of-band” toast fragment:
<p>Saved!</p>
<div id="toasts" hx-swap-oob="true">
<div
class="toast toast--ok"
hx-get="/_empty"
hx-trigger="load delay:4s"
hx-target="this"
hx-swap="outerHTML"
>
<strong>Saved</strong> — Changes stored.
</div>
</div>HTMX sees hx-swap-oob="true" and inserts this toast into the fixed container.
Four seconds later it calls / _empty, gets an empty body, and removes the toast.
No client-side timers, no event listeners, no JS.
CSS does the slide and fade:
.toasts {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
opacity: 0;
transform: translateY(8px);
animation: toast-in 0.2s ease-out forwards;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}That’s the first 90 % done. If you’re fine with auto-dismiss only — stop here. You’ve already out-engineered most dashboards on the internet.
Step 2 — A close button, still no JS
Users like control. Let them close it manually.
You can still do it server-side with the same / _empty trick:
<div id="toasts" hx-swap-oob="true">
<div
class="toast toast--ok"
hx-get="/_empty"
hx-trigger="load delay:4s"
hx-target="this"
hx-swap="outerHTML"
>
<div><strong>Saved</strong> — Changes stored.</div>
<button
class="toast__close"
hx-get="/_empty"
hx-target="closest .toast"
hx-swap="outerHTML"
>
×
</button>
</div>
</div>No scripts. HTMX replaces the element with nothing. It’s ridiculous and elegant at the same time.
Step 3 — Hyperscript (client-side, still declarative)
If the / _empty endpoint feels like an aesthetic crime, you can move logic to the client without writing JS.
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
<div
class="toast toast--ok"
_="on load wait 4s then add .fading then wait 300ms then remove me"
>
<div><strong>Saved</strong> — Changes stored.</div>
<button class="toast__close" _="on click remove closest .toast">
×
</button>
</div>That line reads like English, and for once that’s not an insult:
on load → wait 4 s → fade → wait 300 ms → remove me
Hyperscript is nice when you want declarative behavior without reaching for a framework.
No webpack, no import React, no special runtime.
Just markup.
Step 4 — The three-line JS version
Eventually you realise: a 6 kB Hyperscript import for two timers is overkill. Three lines of vanilla JS are enough:
<script>
document.addEventListener("click", (e) => {
if (e.target.matches(".toast__close")) {
const t = e.target.closest(".toast");
t.classList.add("fading");
setTimeout(() => t.remove(), 250);
}
});
document.addEventListener("htmx:oobAfterSwap", (e) => {
const t = e.target.querySelector(".toast");
if (!t) return;
setTimeout(() => {
t.classList.add("fading");
setTimeout(() => t.remove(), 300);
}, 4000);
});
</script>That’s it.
HTMX handles rendering; JS handles lifespan.
No / _empty, no Hyperscript, no runtime cost.
It’s the pragmatic middle ground — the “I’m still sane” layer.
Step 5 — The backend (Go, naturally)
If you want to see it live:
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, pageHTML)
})
http.HandleFunc("/save", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<p>Saved!</p>
<div id="toasts" hx-swap-oob="true">
<div class="toast toast--ok">
<strong>Saved</strong> — Changes stored.
<button class="toast__close">×</button>
</div>
</div>`)
})Run go run main.go, open localhost:8080, and enjoy an HTML-native UI doing the job modern stacks need megabytes to attempt.
Why this matters
HTMX doesn’t reject JavaScript — it rejects unnecessary ceremony. It lets you build interactive systems that start from the server, not from a webpack build. You can think in HTML again.
Toasts are a small example, but they prove the point: a minimal toolchain doesn’t mean a minimal experience. It just means you ship faster and sleep better.
HTMX isn’t nostalgia.
It’s what happens when the pendulum finally swings back from frontend maximalism.
A bit of HTML, a sprinkle of CSS, and, fine, three lines of JavaScript — because life is short and /_empty is ugly.
