Components
Optimistic Toggle
A server-backed action toggle — like, star, follow, pin. Clicking flips the appearance instantly via a native <template> of the toggled state, then reconciles with the server's HTML response — rolling back automatically if the request fails. Built on a real <button> with aria-pressed and a few lines of htmx-event JS — no extension required.
Installation
No htmx extension needed. The component ships a tiny behaviour script that listens for htmx:before:request (flip) and htmx:before:swap (cancel the swap and roll back on a 4xx/5xx). It attaches once, page-wide — drop it in your site.js (the docs render it inline).
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/optimistic-toggle.json2. Use it
import { OptimisticToggle } from "@/components/ui/optimistic-toggle"
// children = resting state, optimistic = the just-toggled flash.
// The server POST should reply with a fresh <OptimisticToggle> in the new
// state (hx-swap="outerHTML" is the component default).
<OptimisticToggle
id="like-42"
pressed={post.likedByMe}
ariaLabel="Like"
hx-post="/posts/42/like"
optimistic={<><HeartIcon filled /> Liked</>}
>
<HeartIcon filled={post.likedByMe} /> {post.likedByMe ? "Liked" : "Like"}
</OptimisticToggle>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Optimistic Toggle — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A server-backed action toggle (like / star / follow / pin). Clicking flips
// the appearance INSTANTLY to the toggled state, then reconciles with the
// server's HTML response — rolling back automatically if the request fails.
//
// Built on:
// - Real htmx v4 events, NOT an extension. htmx v4 does not ship a working
// "optimistic" attribute (the bundled src/ext/hx-optimistic.js is an
// unfinished stub — "TODO: this needs to be updated to use the new internal
// API" — and it does not flip aria-pressed nor cancel the error swap). So
// the behaviour is a tiny self-contained script (OPTIMISTIC_TOGGLE_JS,
// emitted once below) keyed on data-slot="optimistic-toggle":
// • htmx:before:request — save the button's innerHTML + aria-pressed,
// then flip aria-pressed and paint the <template>'s optimistic markup
// in (the instant pre-network flip).
// • htmx:before:swap — if the response is 4xx/5xx (ctx.response.status),
// preventDefault() to CANCEL the swap (htmx v4 swaps error bodies by
// default — see repos/htmx/src/htmx.js:1224) and restore the saved
// markup + aria-pressed (rollback).
// • on success htmx's hx-swap="outerHTML" replaces the button with the
// server's authoritative version — no rollback code needed.
// htmx v4 fires htmx:response:error for 4xx/5xx and lets a
// htmx:before:swap listener veto the swap via preventDefault:
// repos/htmx/CHANGELOG.md (htmx:response:error) and htmx.js:1224.
// - A native <template> holds the optimistic markup. <template> content is
// inert/not rendered until cloned, so it never shows until the script pulls
// its innerHTML. repos/mdn/files/en-us/web/html/reference/elements/template/index.md
// - A real <button> with aria-pressed: the platform gives us role=button and
// Space/Enter activation for free, and aria-pressed carries the toggle
// state. APG: Button (toggle) pattern — the accessible NAME must stay
// constant across states; only aria-pressed flips.
// repos/aria-practices/content/patterns/button/examples/button.html
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/index.md
//
// Style analogue: registry/ui/button.tsx (variant/size maps, .htmx-request
// affordance, real <button>). We reuse Button's visual language.
//
// The target of the swap is the button itself (hx-target="this",
// hx-swap="outerHTML"), so on success the server returns a fresh <button> in
// the new state. The <template> lives OUTSIDE the swapped button (a sibling
// inside the data-slot wrapper) so it survives the swap and stays available
// for the next toggle. The optimistic source is pointed at by data-optimistic
// (a plain CSS selector the script reads — no extension involved).
// Shared optimistic-flip + rollback behaviour. Delegated on document.body so it
// covers buttons swapped in by htmx too; attached once via a global guard. It
// uses only real htmx v4 events and the platform <template> + aria-pressed, so
// it needs no extension. Copy this once into your app (e.g. site.js) — the
// component renders it inline for the docs/demo.
export const OPTIMISTIC_TOGGLE_JS = `(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
// Resolve the <button data-slot> for an event whose source is the toggle.
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
// Instant flip: stash the current markup, then paint the <template> in and
// toggle aria-pressed BEFORE the network round-trip.
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
// On a 4xx/5xx response, cancel the swap (htmx v4 swaps error bodies by
// default) and roll the optimistic flip back to exactly what it was.
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
// Network/abort failure (no response): also roll back.
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();`
export type OptimisticToggleVariant = "default" | "outline" | "ghost"
export type OptimisticToggleSize = "default" | "sm" | "lg" | "icon"
const base =
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:pointer-events-none disabled:opacity-50 " +
"aria-disabled:pointer-events-none aria-disabled:opacity-50 " +
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " +
// While the toggle request is in flight htmx adds .htmx-request to the
// trigger; dim slightly so the optimistic state still reads as "pending".
"[&.htmx-request]:opacity-80"
const variants: Record<OptimisticToggleVariant, string> = {
// The pressed look comes from aria-pressed (data-driven below), so each
// variant defines both the resting and the pressed treatment.
default:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " +
"aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " +
"aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15",
ghost:
"hover:bg-accent hover:text-accent-foreground " +
"aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80",
}
const sizes: Record<OptimisticToggleSize, string> = {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
}
export function optimisticToggleClasses(opts?: {
variant?: OptimisticToggleVariant
size?: OptimisticToggleSize
class?: ClassValue
}): string {
const variant = opts?.variant ?? "default"
const size = opts?.size ?? "default"
return cn(base, variants[variant], sizes[size], opts?.class)
}
type OptimisticToggleProps = {
// Unique id. Seeds the button id (`{id}`) and the optimistic template id
// (`{id}-optimistic`) that data-optimistic points the behaviour script at.
id: string
// Current persisted state (from your server / DB).
pressed?: boolean
variant?: OptimisticToggleVariant
size?: OptimisticToggleSize
class?: ClassValue
disabled?: boolean
// Stable accessible name. APG requires it not change between states —
// "Like" stays "Like" whether pressed or not; aria-pressed carries state.
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
// Visible content for the CURRENT (resting) state — icon and/or label.
children: Child
// Visible content for the OPTIMISTIC (just-toggled) state. Swapped in
// instantly on click via the <template>, before the server responds.
optimistic: Child
// htmx — where to POST the toggle. The server should reply with a fresh
// <button> in the new state (use OptimisticToggle again server-side).
"hx-post"?: string
"hx-put"?: string
"hx-patch"?: string
"hx-delete"?: string
// Defaults below target the button itself and swap its outerHTML so the
// server response replaces the whole control.
"hx-target"?: string
"hx-swap"?: string
"hx-trigger"?: string
"hx-vals"?: string
"hx-confirm"?: string
// Block double-submits while the toggle request is in flight (v4 name).
"hx-disable"?: string
}
export function OptimisticToggle(props: OptimisticToggleProps) {
const {
id,
pressed,
variant = "default",
size = "default",
class: className,
disabled,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
children,
optimistic,
...rest
} = props
const templateId = `${id}-optimistic`
const classes = optimisticToggleClasses({ variant, size, class: className })
// hx-target/hx-swap default to replacing the button with the server's
// authoritative response on success. data-optimistic points the behaviour
// script at the <template> holding the just-toggled markup.
const hxTarget = props["hx-target"] ?? "this"
const hxSwap = props["hx-swap"] ?? "outerHTML"
// Don't leak our defaults twice into ...rest.
const { "hx-target": _t, "hx-swap": _s, ...hxRest } = rest
return (
<span data-slot="optimistic-toggle" class="contents">
<button
type="button"
id={id}
class={classes}
disabled={disabled}
aria-pressed={pressed === undefined ? "false" : String(pressed)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
data-variant={variant}
data-size={size}
hx-target={hxTarget}
hx-swap={hxSwap}
data-optimistic={`#${templateId}`}
{...hxRest}
>
{children}
</button>
{/* Optimistic markup. <template> content is inert until the script clones
its innerHTML, so it never renders on its own. The inner state span is
tagged data-slot="optimistic-toggle-state" so the script can lift just
the icon/label out of it. */}
<template id={templateId}>
<span
data-slot="optimistic-toggle-state"
class={cn(classes, "pointer-events-none")}
aria-pressed="true"
>
{optimistic}
</span>
</template>
{/* Optimistic-flip + rollback behaviour (attaches once, page-wide). */}
<script dangerouslySetInnerHTML={{ __html: OPTIMISTIC_TOGGLE_JS }} />
</span>
)
}
1. Save the file
Copy optimistic-toggle.html into templates/components/.
2. Use it
{% from "components/optimistic-toggle.html" import optimistic_toggle %}
{% call(state) optimistic_toggle(id="like-42", pressed=liked,
hx_post="/posts/42/like", aria_label="Like") %}
{% if state == "current" %}{{ "Liked" if liked else "Like" }}
{% else %}Liked{% endif %}
{% endcall %}View source
{# Optimistic Toggle macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/optimistic-toggle.tsx for Python/Flask/FastAPI/Django.
A server-backed action toggle (like / star / follow / pin). Clicking flips
the appearance instantly via a native <template> of the toggled state, then
reconciles with the server's HTML response (rolling back on error).
No htmx extension needed. The behaviour <script> at the bottom of the macro
wires the optimistic flip + rollback with real htmx v4 events
(htmx:before:request to flip, htmx:before:swap to cancel + roll back on a
4xx/5xx). It self-guards with window.__shadcnOptimisticToggle so it only
attaches once even if the macro is called many times. (htmx v4's bundled
hx-optimistic extension is an unfinished stub that neither flips aria-pressed
nor cancels the error swap, so we don't depend on it.)
A real <button> + aria-pressed follows the APG Button (toggle) pattern: the
accessible name stays constant; only aria-pressed flips.
repos/aria-practices/content/patterns/button/examples/button.html
Usage:
{% from "components/optimistic-toggle.html" import optimistic_toggle %}
{% call(state) optimistic_toggle(id="like-42", pressed=is_liked,
hx_post="/posts/42/like", aria_label="Like") %}
{# state == "current" renders the resting label, "optimistic" the
just-toggled label — call() runs once per state. #}
{% if state == "current" %}{{ "Liked" if is_liked else "Like" }}
{% else %}Liked{% endif %}
{% endcall %}
All hx-* attributes pass through via **attrs (underscores become dashes). #}
{% macro optimistic_toggle(
id,
pressed=false,
variant="default",
size="default",
disabled=false,
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
hx_target="this",
hx_swap="outerHTML",
extra_class="",
**attrs
) %}
{%- set base -%}
inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80
{%- endset -%}
{%- set variants = {
"default": "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90",
"outline": "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15",
"ghost": "hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80"
} -%}
{%- set sizes = {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9"
} -%}
{%- set classes = base ~ " " ~ variants[variant] ~ " " ~ sizes[size] ~ (" " ~ extra_class if extra_class else "") -%}
{%- set template_id = id ~ "-optimistic" -%}
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="{{ id }}"
class="{{ classes }}"
{%- if disabled %} disabled{% endif %}
aria-pressed="{{ 'true' if pressed else 'false' }}"
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
data-variant="{{ variant }}" data-size="{{ size }}"
hx-target="{{ hx_target }}" hx-swap="{{ hx_swap }}"
data-optimistic="#{{ template_id }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ caller("current") }}</button>
<template id="{{ template_id }}">
<span data-slot="optimistic-toggle-state" class="{{ classes }} pointer-events-none" aria-pressed="true">{{ caller("optimistic") }}</span>
</template>
</span>
{# Optimistic flip + rollback. Self-guarded so it attaches once page-wide. #}
<script>
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
</script>
{% endmacro %}
1. Save the file
Add optimistic-toggle.tmpl alongside your templates.
2. Use it
tpl.ExecuteTemplate(w, "optimistic-toggle", map[string]any{
"ID": "like-42", "Pressed": liked, "AriaLabel": "Like",
"Current": template.HTML(currentLabel),
"Optimistic": template.HTML("Liked"),
"Attrs": map[string]string{"hx-post": "/posts/42/like"},
})View source
{{/*
Optimistic Toggle template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/optimistic-toggle.tsx for Go projects using html/template.
A server-backed action toggle (like / star / follow / pin). Clicking flips
the appearance instantly via a native <template> of the toggled state, then
reconciles with the server's HTML response (rolling back on error).
No htmx extension needed. The behaviour <script> at the end of this template
wires the optimistic flip + rollback with real htmx v4 events
(htmx:before:request to flip, htmx:before:swap to cancel + roll back on a
4xx/5xx). It self-guards with window.__shadcnOptimisticToggle so it attaches
once page-wide. (htmx v4's bundled hx-optimistic extension is an unfinished
stub that neither flips aria-pressed nor cancels the error swap.)
A real <button> + aria-pressed follows the APG Button (toggle) pattern: the
accessible name stays constant; only aria-pressed flips.
repos/aria-practices/content/patterns/button/examples/button.html
Usage in your code:
type OptimisticToggleArgs struct {
ID string
Pressed bool
Variant string // default | outline | ghost
Size string // default | sm | lg | icon
Disabled bool
AriaLabel string
Current template.HTML // resting-state inner markup
Optimistic template.HTML // just-toggled inner markup
HxTarget string // default "this"
HxSwap string // default "outerHTML"
Attrs map[string]string // hx-post, hx-confirm, …
}
tpl.ExecuteTemplate(w, "optimistic-toggle", OptimisticToggleArgs{
ID: "like-42", AriaLabel: "Like",
Current: template.HTML("Like"), Optimistic: template.HTML("Liked"),
Attrs: map[string]string{"hx-post": "/posts/42/like"},
})
Uses sprig's `dict`. Current/Optimistic are rendered with htmlSafe.
*/}}
{{define "optimistic-toggle"}}
{{- $variants := dict
"default" "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90"
"outline" "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15"
"ghost" "hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80" -}}
{{- $sizes := dict
"default" "h-9 px-4 py-2 has-[>svg]:px-3"
"sm" "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5"
"lg" "h-10 rounded-md px-6 has-[>svg]:px-4"
"icon" "size-9" -}}
{{- $variant := or .Variant "default" -}}
{{- $size := or .Size "default" -}}
{{- $base := "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80" -}}
{{- $classes := printf "%s %s %s" $base (index $variants $variant) (index $sizes $size) -}}
{{- $target := or .HxTarget "this" -}}
{{- $swap := or .HxSwap "outerHTML" -}}
{{- $templateId := printf "%s-optimistic" .ID -}}
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="{{.ID}}"
class="{{$classes}}"
{{- if .Disabled}} disabled{{end}}
aria-pressed="{{if .Pressed}}true{{else}}false{{end}}"
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
data-variant="{{$variant}}" data-size="{{$size}}"
hx-target="{{$target}}" hx-swap="{{$swap}}"
data-optimistic="#{{$templateId}}"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{htmlSafe .Current}}</button>
<template id="{{$templateId}}">
<span data-slot="optimistic-toggle-state" class="{{$classes}} pointer-events-none" aria-pressed="true">{{htmlSafe .Optimistic}}</span>
</template>
</span>
{{/* Optimistic flip + rollback. Self-guarded so it attaches once page-wide. */}}
<script>
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
</script>
{{end}}
1. Save the file
Drop optimistic_toggle.ex into lib/my_app_web/components/.
2. Use it
<.optimistic_toggle id="like-42" pressed={@liked}
hx-post="/posts/42/like" aria-label="Like">
<:current>{if @liked, do: "Liked", else: "Like"}</:current>
<:optimistic>Liked</:optimistic>
</.optimistic_toggle>View source
defmodule ShadcnHtmx.Components.OptimisticToggle do
@moduledoc """
Optimistic Toggle — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
A server-backed action toggle (like / star / follow / pin). Clicking flips
the appearance instantly via a native `<template>` of the toggled state, then
reconciles with the server's HTML response (rolling back on error).
Mirrors registry/ui/optimistic-toggle.tsx.
No htmx extension needed. The behaviour `<script>` rendered with the component
wires the optimistic flip + rollback with real htmx v4 events
(`htmx:before:request` to flip, `htmx:before:swap` to cancel + roll back on a
4xx/5xx). It self-guards with `window.__shadcnOptimisticToggle` so it attaches
once page-wide. (htmx v4's bundled `hx-optimistic` extension is an unfinished
stub that neither flips `aria-pressed` nor cancels the error swap.)
A real `<button>` + `aria-pressed` follows the APG Button (toggle) pattern:
the accessible name stays constant; only `aria-pressed` flips.
repos/aria-practices/content/patterns/button/examples/button.html
## Examples
<.optimistic_toggle id="like-42" pressed={@liked}
hx-post="/posts/42/like" aria-label="Like">
<:current>{if @liked, do: "Liked", else: "Like"}</:current>
<:optimistic>Liked</:optimistic>
</.optimistic_toggle>
"""
use Phoenix.Component
@variants %{
"default" =>
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " <>
"aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90",
"outline" =>
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground " <>
"aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15",
"ghost" =>
"hover:bg-accent hover:text-accent-foreground " <>
"aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80"
}
@sizes %{
"default" => "h-9 px-4 py-2 has-[>svg]:px-3",
"sm" => "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
"lg" => "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon" => "size-9"
}
@base "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium " <>
"whitespace-nowrap transition-all outline-none " <>
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:pointer-events-none disabled:opacity-50 " <>
"aria-disabled:pointer-events-none aria-disabled:opacity-50 " <>
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " <>
"[&.htmx-request]:opacity-80"
attr :id, :string, required: true
attr :pressed, :boolean, default: false
attr :variant, :string, default: "default", values: ~w(default outline ghost)
attr :size, :string, default: "default", values: ~w(default sm lg icon)
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global,
include:
~w(hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-vals hx-confirm hx-disable
aria-label aria-labelledby aria-describedby)
slot :current, required: true, doc: "Inner markup for the resting state."
slot :optimistic, required: true, doc: "Inner markup for the just-toggled state."
# Optimistic flip + rollback behaviour, using only real htmx v4 events plus
# the platform <template> + aria-pressed. Self-guarded so it attaches once
# page-wide no matter how many toggles render. Rendered raw inside <script>.
@behaviour_js """
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
"""
def optimistic_toggle(assigns) do
assigns =
assigns
|> assign(:classes, [@base, Map.fetch!(@variants, assigns.variant), Map.fetch!(@sizes, assigns.size), assigns.class])
|> assign(:template_id, "#{assigns.id}-optimistic")
|> assign(:behaviour_js, Phoenix.HTML.raw(@behaviour_js))
~H"""
<span data-slot="optimistic-toggle" class="contents">
<button
type="button"
id={@id}
class={@classes}
disabled={@disabled}
aria-pressed={to_string(@pressed)}
data-variant={@variant}
data-size={@size}
hx-target="this"
hx-swap="outerHTML"
data-optimistic={"##{@template_id}"}
{@rest}
>
{render_slot(@current)}
</button>
<template id={@template_id}>
<span data-slot="optimistic-toggle-state" class={[@classes, "pointer-events-none"]} aria-pressed="true">
{render_slot(@optimistic)}
</span>
</template>
<%!-- Optimistic flip + rollback. Self-guarded so it attaches once page-wide. --%>
<script>{@behaviour_js}</script>
</span>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens. The behaviour <script> is included — copy it once into your site.js.
2. Use it
<!-- No extension. The component's behaviour script (copy it once into
site.js) flips on htmx:before:request and rolls back on a 4xx/5xx. -->
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="like-42" aria-pressed="false" aria-label="Like"
hx-post="/posts/42/like" hx-target="this" hx-swap="outerHTML"
data-optimistic="#like-42-optimistic" class="…">Like</button>
<template id="like-42-optimistic">
<span data-slot="optimistic-toggle-state" aria-pressed="true" class="…">Liked</span>
</template>
</span>View source
<!--
shadcn-htmx — Optimistic Toggle (raw HTML snippet).
A server-backed action toggle (like / star / follow / pin). Clicking flips
the appearance INSTANTLY to the toggled state via a native <template>, then
reconciles with the server's HTML response — rolling back automatically if
the request fails.
Requirements:
1. Tailwind CSS v4 (the theme tokens --background, --primary, --secondary,
--border, --ring, etc.). Copy the :root / .dark blocks from
app/styles/input.css.
2. htmx v4. NO extension needed — the small behaviour <script> at the
bottom of this file wires the optimistic flip + rollback using real htmx
v4 events. (htmx v4 ships an unfinished hx-optimistic extension stub that
neither flips aria-pressed nor cancels the error swap, so we don't use
it.) Load htmx, then this snippet:
<script src="https://unpkg.com/[email protected]/dist/htmx.min.js" defer></script>
How it works:
- On htmx:before:request the script saves the button's current innerHTML +
aria-pressed, paints the <template>'s "Liked" markup in, and flips
aria-pressed — the instant pre-network flip.
- On success hx-swap="outerHTML" replaces the button with the server's
fresh <button> in the new state.
- On a 4xx/5xx the script calls preventDefault() in htmx:before:swap to
CANCEL the swap (htmx v4 swaps error bodies by default) and restores the
saved markup — automatic rollback.
A real <button> + aria-pressed = the APG Button (toggle) pattern. Keep the
accessible name constant across states (aria-pressed carries the state); the
<template> below holds the just-toggled appearance and never renders on its
own because <template> content is inert until cloned.
BASE (shared):
inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm
font-medium whitespace-nowrap transition-all outline-none
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
disabled:pointer-events-none disabled:opacity-50
aria-disabled:pointer-events-none aria-disabled:opacity-50
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4
[&.htmx-request]:opacity-80
-->
<!-- ─── Like toggle (default variant) ──────────────────────────────────── -->
<!-- Resting state shown; on click the template's "Liked" markup flashes in,
then the server's fresh button replaces it. -->
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="like-42"
data-variant="default" data-size="default"
aria-pressed="false" aria-label="Like"
hx-post="/posts/42/like" hx-target="this" hx-swap="outerHTML"
data-optimistic="#like-42-optimistic"
class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
</svg>
Like
</button>
<!-- Optimistic (just-toggled) markup — inert until the script clones it. The
inner state span is tagged data-slot="optimistic-toggle-state" so the
script can lift just the icon/label out of it. -->
<template id="like-42-optimistic">
<span data-slot="optimistic-toggle-state" aria-pressed="true"
class="pointer-events-none inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border bg-background shadow-xs aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground h-9 px-4 py-2 has-[>svg]:px-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
</svg>
Liked
</span>
</template>
</span>
<!-- The server should reply with a fresh <button> in the new state, e.g.: -->
<!--
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="like-42" aria-pressed="true" aria-label="Like"
hx-post="/posts/42/unlike" hx-target="this" hx-swap="outerHTML"
data-optimistic="#like-42-optimistic"
class="… aria-pressed:bg-primary aria-pressed:text-primary-foreground …">
<svg … fill="currentColor">…</svg> Liked
</button>
<template id="like-42-optimistic">…unlike preview…</template>
</span>
-->
<!-- ─── Behaviour: optimistic flip + rollback (copy once, e.g. into site.js) ─
Uses only real htmx v4 events + <template> + aria-pressed. No extension. -->
<script>
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
</script>
Examples
Like a post
Click the heart. The button flips to the liked state instantly, the server POST confirms it, and the authoritative <button> swaps in. The likes count below is part of the server response.
The button carries data-optimistic="#like-optimistic" pointing at the sibling <template>. On htmx:before:request the behaviour script paints the template's markup into the button and flips aria-pressed, so the liked look appears before any network round-trip. When the response lands, hx-swap="outerHTML" replaces the button with the server's version. The accessible name stays "Like" in both states — only aria-pressed flips, per the APG toggle-button pattern.
<OptimisticToggle id="like-42" pressed={liked} ariaLabel="Like"
hx-post="/posts/42/like"
optimistic={<><HeartIcon filled /> Liked</>}>
<HeartIcon filled={liked} /> {liked ? "Liked" : "Like"}
</OptimisticToggle>{% call(state) optimistic_toggle(id="like-42", pressed=liked,
hx_post="/posts/42/like", aria_label="Like") %}
{% if state == "current" %}{{ "Liked" if liked else "Like" }}
{% else %}Liked{% endif %}
{% endcall %}{{template "optimistic-toggle" (dict
"ID" "like-42" "Pressed" .Liked "AriaLabel" "Like"
"Current" (htmlSafe .CurrentLabel) "Optimistic" (htmlSafe "Liked")
"Attrs" (dict "hx-post" "/posts/42/like"))}}<.optimistic_toggle id="like-42" pressed={@liked}
hx-post="/posts/42/like" aria-label="Like">
<:current>{if @liked, do: "Liked", else: "Like"}</:current>
<:optimistic>Liked</:optimistic>
</.optimistic_toggle><div class="flex flex-col items-center gap-3">
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="ex-like" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3" aria-pressed="false" aria-label="Like" data-variant="default" data-size="default" hx-target="this" hx-swap="outerHTML" data-optimistic="#ex-like-optimistic" hx-post="/docs/optimistic-toggle/like">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z">
</path>
</svg>
Like
</button>
<template id="ex-like-optimistic">
<span data-slot="optimistic-toggle-state" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3 pointer-events-none" aria-pressed="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z">
</path>
</svg>
Liked
</span>
</template>
<script>
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
// Resolve the
<button data-slot>
for an event whose source is the toggle.
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
// Instant flip: stash the current markup, then paint the
<template>
in and
// toggle aria-pressed BEFORE the network round-trip.
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
// On a 4xx/5xx response, cancel the swap (htmx v4 swaps error bodies by
// default) and roll the optimistic flip back to exactly what it was.
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
// Network/abort failure (no response): also roll back.
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
</script>
</span>
</div>Follow toggle (outline variant)
A Follow / Following toggle. The outline variant tints when pressed instead of filling. Same instant-flip-then-reconcile flow.
Variants differ only in the pressed treatment: default fills with bg-primary, outline keeps the border and tints with bg-primary/10, and ghost uses the secondary surface. All three drive the pressed look from the aria-pressed: variant so it tracks the real state, not a separate class.
<OptimisticToggle id="follow-7" variant="outline"
pressed={following} ariaLabel="Follow"
hx-post="/users/7/follow"
optimistic={<><FollowIcon following /> Following</>}>
<FollowIcon following={following} /> {following ? "Following" : "Follow"}
</OptimisticToggle>{% call(state) optimistic_toggle(id="follow-7", variant="outline",
pressed=following, hx_post="/users/7/follow", aria_label="Follow") %}
{% if state == "current" %}{{ "Following" if following else "Follow" }}
{% else %}Following{% endif %}
{% endcall %}{{template "optimistic-toggle" (dict
"ID" "follow-7" "Variant" "outline" "Pressed" .Following "AriaLabel" "Follow"
"Current" (htmlSafe .CurrentLabel) "Optimistic" (htmlSafe "Following")
"Attrs" (dict "hx-post" "/users/7/follow"))}}<.optimistic_toggle id="follow-7" variant="outline"
pressed={@following} hx-post="/users/7/follow" aria-label="Follow">
<:current>{if @following, do: "Following", else: "Follow"}</:current>
<:optimistic>Following</:optimistic>
</.optimistic_toggle><div class="flex flex-col items-center gap-3">
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="ex-follow-toggle" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15 h-9 px-4 py-2 has-[>svg]:px-3" aria-pressed="false" aria-label="Follow" data-variant="outline" data-size="default" hx-target="this" hx-swap="outerHTML" data-optimistic="#ex-follow-toggle-optimistic" hx-post="/docs/optimistic-toggle/follow">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2">
</path>
<circle cx="9" cy="7" r="4">
</circle>
<line x1="19" y1="8" x2="19" y2="14">
</line>
<line x1="22" y1="11" x2="16" y2="11">
</line>
</svg>
Follow
</button>
<template id="ex-follow-toggle-optimistic">
<span data-slot="optimistic-toggle-state" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15 h-9 px-4 py-2 has-[>svg]:px-3 pointer-events-none" aria-pressed="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2">
</path>
<circle cx="9" cy="7" r="4">
</circle>
<polyline points="16 11 18 13 22 9">
</polyline>
</svg>
Following
</span>
</template>
<script>
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
// Resolve the
<button data-slot>
for an event whose source is the toggle.
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
// Instant flip: stash the current markup, then paint the
<template>
in and
// toggle aria-pressed BEFORE the network round-trip.
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
// On a 4xx/5xx response, cancel the swap (htmx v4 swaps error bodies by
// default) and roll the optimistic flip back to exactly what it was.
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
// Network/abort failure (no response): also roll back.
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
</script>
</span>
</div>Further reading
Rollback on error
This endpoint always replies 500. The button flips optimistically, then the behaviour script cancels the swap and restores the original pre-click button — no rollback code of your own.
Optimistic UI is a promise to the user that you keep — or walk back. The script saves the original markup before it paints the template in; on a 4xx/5xx it calls preventDefault() in htmx:before:swap (htmx v4 swaps error bodies by default) and restores the original, so a failed request leaves the toggle exactly as it was. Pair it with an aria-live region if you want to announce the failure.
The flip is reverted when the 500 comes back.
// No rollback code needed — the extension restores the original
// button on htmx:error.
<OptimisticToggle id="like-42" variant="ghost" ariaLabel="Like"
hx-post="/posts/42/like"
optimistic={<><HeartIcon filled /> Liked</>}>
<HeartIcon /> Like
</OptimisticToggle>{# Rollback is automatic on htmx:error — nothing extra to write. #}
{% call(state) optimistic_toggle(id="like-42", variant="ghost",
hx_post="/posts/42/like", aria_label="Like") %}
{% if state == "current" %}Like{% else %}Liked{% endif %}
{% endcall %}{{/* Rollback is automatic on htmx:error. */}}
{{template "optimistic-toggle" (dict
"ID" "like-42" "Variant" "ghost" "AriaLabel" "Like"
"Current" (htmlSafe "Like") "Optimistic" (htmlSafe "Liked")
"Attrs" (dict "hx-post" "/posts/42/like"))}}<%# Rollback is automatic on htmx:error. %>
<.optimistic_toggle id="like-42" variant="ghost"
hx-post="/posts/42/like" aria-label="Like">
<:current>Like</:current>
<:optimistic>Liked</:optimistic>
</.optimistic_toggle><div class="flex flex-col items-center gap-3">
<span data-slot="optimistic-toggle" class="contents">
<button type="button" id="ex-fail" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80 h-9 px-4 py-2 has-[>svg]:px-3" aria-pressed="false" aria-label="Like" data-variant="ghost" data-size="default" hx-target="this" hx-swap="outerHTML" data-optimistic="#ex-fail-optimistic" hx-post="/docs/optimistic-toggle/fail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z">
</path>
</svg>
Like
</button>
<template id="ex-fail-optimistic">
<span data-slot="optimistic-toggle-state" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80 h-9 px-4 py-2 has-[>svg]:px-3 pointer-events-none" aria-pressed="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z">
</path>
</svg>
Liked
</span>
</template>
<script>
(function(){
if (window.__shadcnOptimisticToggle) return;
window.__shadcnOptimisticToggle = true;
// Resolve the
<button data-slot>
for an event whose source is the toggle.
function toggleFor(detail){
var ctx = detail && detail.ctx;
var src = ctx && ctx.sourceElement;
if (!src || !src.closest) return null;
var btn = src.closest('[data-slot="optimistic-toggle"] > button[aria-pressed]');
return btn || (src.matches && src.matches('button[aria-pressed]') &&
src.closest('[data-slot="optimistic-toggle"]') ? src : null);
}
// Instant flip: stash the current markup, then paint the
<template>
in and
// toggle aria-pressed BEFORE the network round-trip.
document.body.addEventListener('htmx:before:request', function(e){
var btn = toggleFor(e.detail);
if (!btn) return;
btn.__optHTML = btn.innerHTML;
btn.__optPressed = btn.getAttribute('aria-pressed');
var sel = btn.getAttribute('data-optimistic');
var tmpl = sel && document.querySelector(sel);
var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot="optimistic-toggle-state"]') : null;
if (inner) btn.innerHTML = inner.innerHTML;
btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');
}, true);
// On a 4xx/5xx response, cancel the swap (htmx v4 swaps error bodies by
// default) and roll the optimistic flip back to exactly what it was.
document.body.addEventListener('htmx:before:swap', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;
if (status >= 400){
e.preventDefault();
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
}
btn.__optHTML = null;
}, true);
// Network/abort failure (no response): also roll back.
document.body.addEventListener('htmx:error', function(e){
var btn = toggleFor(e.detail);
if (!btn || btn.__optHTML == null) return;
btn.innerHTML = btn.__optHTML;
btn.setAttribute('aria-pressed', btn.__optPressed);
btn.__optHTML = null;
}, true);
})();
</script>
</span>
<p class="text-xs text-muted-foreground">The flip is reverted when the 500 comes back.</p>
</div>Further reading
API Reference
Optimistic Toggle
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Unique id. Seeds the button id and the optimistic <template> id (`{id}-optimistic`) that the data-optimistic behaviour script points at. |
pressed | boolean | false | Current persisted state from your server/DB. Sets aria-pressed and the pressed visual treatment.MDNaria-pressed |
children* | Child | — | Visible content for the current (resting) state — icon and/or label. |
optimistic* | Child | — | Visible content for the just-toggled state, rendered into a <template> and swapped in instantly on click before the server responds.MDN<template> element |
hx-post / hx-put / hx-patch / hx-delete | string | — | Where to send the toggle request. The server should reply with a fresh toggle button in the new state.htmxhx-post |
hx-target | string | "this" | Swap target. Defaults to the button itself so the server response replaces the whole control.htmxhx-target |
hx-swap | string | "outerHTML" | Swap strategy. Defaults to outerHTML so the authoritative server button replaces the optimistic one.htmxhx-swap |
variant | "default"|"outline"|"ghost" | "default" | Pressed-state visual treatment: default fills with primary, outline tints, ghost uses the secondary surface. |
size | "default"|"sm"|"lg"|"icon" | "default" | Size variant. icon is square with no horizontal padding. |
disabled | boolean | false | Disable the toggle — skipped from tab order, no click handling. |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required