shshadcn-htmx

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.json

2. Use it

components/ui/optimistic-toggle.tsx
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
components/ui/optimistic-toggle.tsx
/** @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

templates/components/optimistic-toggle.html
{% 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
templates/components/optimistic-toggle.html
{# 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

components/optimistic-toggle.tmpl
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
components/optimistic-toggle.tmpl
{{/*
  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

lib/my_app_web/components/optimistic_toggle.ex
<.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
lib/my_app_web/components/optimistic_toggle.ex
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

snippets/optimistic-toggle.html
<!-- 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
snippets/optimistic-toggle.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.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-[&gt;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>

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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.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-[&gt;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>

API Reference

Optimistic Toggle

PropTypeDefaultDescription
id*string
Unique id. Seeds the button id and the optimistic <template> id (`{id}-optimistic`) that the data-optimistic behaviour script points at.
pressedbooleanfalse
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-deletestring
Where to send the toggle request. The server should reply with a fresh toggle button in the new state.htmxhx-post
hx-targetstring"this"
Swap target. Defaults to the button itself so the server response replaces the whole control.htmxhx-target
hx-swapstring"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.
disabledbooleanfalse
Disable the toggle — skipped from tab order, no click handling.
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required