Toasts with HTMX — the clean way to say "it worked"
· web
A “Saved!” notification. Green box, top-right corner, fades after four seconds. That’s it. That’s the entire feature.
And yet, in 2025, the default answer is: install React, wire up a context provider, pull in a toast library, configure a portal, hydrate 200 kB of JavaScript, and pray your bundle analyzer doesn’t make you cry. For a green box.
Let’s talk about what happens when you refuse to do that.
Step 1 — Zero JS, pure HTMX + CSS
HTMX can swap fragments of HTML into your page, even outside the normal target. Out-of-band swaps. This one feature is genuinely all you need.
<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/[email protected]"></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 drops the toast into your fixed container. Four seconds later it hits /_empty, gets an empty body, and the toast vanishes. No client-side timers. No event listeners. No JavaScript whatsoever.
CSS handles the entrance:
.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 90% of the feature. Done. If auto-dismiss is all you need, stop reading. You’ve already shipped something better than most dashboards built with full component libraries. Seriously.
Step 2 — A close button, still no JS
But here’s the thing — users like control. They want to swat that toast away before the timer runs out. Fair enough. You can still do it without a single line of JavaScript.
<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>Click the button. HTMX replaces the element with nothing. The toast is gone. No scripts, no state management, no teardown logic. It’s absurd how well this works.
Step 3 — Hyperscript (client-side, still declarative)
Look, the /_empty endpoint works. But hitting your server with an HTTP request just to remove a DOM node? That starts to feel like an aesthetic crime after a while. If it bothers you — and it should, a little — Hyperscript lets you move the logic client-side without writing actual JavaScript.
<script src="https://unpkg.com/[email protected]"></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>Read that _ attribute out loud:
on load, wait 4 seconds, then add .fading, then wait 300 ms, then remove me
It reads like English. And for once, that’s not an insult. No webpack. No import React. No special runtime beyond a small script tag. Just markup that describes its own behavior. Declarative UI is a superpower.
Step 4 — The three-line JS version
Eventually you stare at that 6 kB Hyperscript import and ask yourself: am I really pulling in a dependency for two timers? You are not. Three lines of vanilla JS finish the job.
<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>HTMX handles the rendering. JavaScript handles the lifespan. No /_empty endpoint, no Hyperscript, no runtime cost worth measuring. This is the pragmatic layer — the one where you stop optimizing for purity and start optimizing for sanity.
Step 5 — The backend (Go, naturally)
Why wouldn’t it be Go? Here’s the whole server:
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 watch an HTML-native UI do the job that modern stacks need megabytes to attempt.
So what did we actually build here? A toast system that starts at zero JavaScript and tops out at three lines. Four progressively honest approaches, each one trading a tiny bit of purity for a tiny bit of pragmatism. The server sends HTML. The browser renders HTML. Nobody had to negotiate with a bundler.
HTMX doesn’t reject JavaScript. It rejects ceremony. It lets you think in HTML again, build from the server outward, and reach for JS only when the alternative is genuinely worse. A minimal toolchain doesn’t mean a minimal experience. It means you ship faster, sleep better, and never have to debug a toast provider’s context boundary at 2 AM.
A bit of HTML, a sprinkle of CSS, and — fine — three lines of JavaScript. Because /_empty is ugly and life is short.
