shshadcn-htmx

Components

Delete Row

A row delete affordance that confirms, sends DELETE, then fades out in place. One :inherited declaration on the list host covers every row — no per-row wiring and no client-side list state. The server replies with an empty body and the row simply disappears.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/delete-row.json

2. Use it

components/ui/delete-row.tsx
import { DeleteRowList, DeleteRowItem, DeleteRow } from "@/components/ui/delete-row"

// The <tbody> host hoists confirm / target / swap to every Delete button.
<table class="w-full text-sm">
  <DeleteRowList>
    {contacts.map((c) => (
      <DeleteRowItem>
        <td class="p-2">{c.name}</td>
        <td class="p-2 text-right">
          <DeleteRow href={`/contacts/${c.id}`} />
        </td>
      </DeleteRowItem>
    ))}
  </DeleteRowList>
</table>
Or copy the source manually
components/ui/delete-row.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
import { buttonClasses, type ButtonVariant, type ButtonSize } from "@/registry/ui/button"

// Delete Row — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A row/item delete affordance that confirms, sends DELETE, then fades out
// in place. One inherited declaration on the list host covers every row —
// no per-row wiring, no client-side list state. The server answers the
// DELETE with a 200 and an empty body, so the row is swapped with nothing
// and simply disappears.
//
// Built on:
//   repos/htmx/www/src/content/patterns/03-records/02-delete-in-place.md
//     The canonical pattern: the <tbody> hoists hx-confirm / hx-target /
//     hx-swap with the :inherited modifier; each Delete button only needs
//     hx-delete. During the swap delay htmx adds the htmx-swapping class to
//     the target row, which we use to drive a CSS opacity fade.
//   repos/htmx/www/src/content/docs/03-features/08-attribute-inheritance.md:10,29-32
//     htmx v4 inheritance is explicit — hoist an attribute to an ancestor
//     with the `:inherited` modifier (e.g. hx-confirm:inherited).
//   repos/htmx/www/src/content/reference/01-attributes/05-hx-delete.md:25-29
//     Respond to DELETE with a 200 + empty body to remove the element (a
//     204 performs no swap).
//   repos/htmx/www/src/content/reference/01-attributes/08-hx-target.md
//     hx-target="closest tr" targets the row containing the button.
//   repos/htmx/www/src/content/reference/01-attributes/07-hx-swap.md:211
//     hx-swap="outerHTML swap:Nms" delays the removal by N ms, giving the
//     fade transition time to play before the node is detached.
//   repos/htmx/www/src/content/reference/01-attributes/22-hx-confirm.md
//     hx-confirm prompts with window.confirm before issuing the request.
//   repos/htmx/src/htmx.js:1304,1394
//     htmx adds `htmx-swapping` to the target before the swap delay and
//     removes it after the swap — the hook our fade keys off.
//
// Native semantics:
//   - The list host is a real <tbody> (default) so the rows live in a valid
//     table model and AT users get row/column navigation for free.
//     repos/mdn/files/en-us/web/html/reference/elements/tbody/index.md
//   - The delete affordance is a real <button>, so Enter / Space activation
//     and focus come from the platform; no role/tabindex needed.
//     repos/aria-practices/content/patterns/button/button-pattern.html
//
// Style analogues: registry/ui/table.tsx (the row/cell model + transition
// idiom on <tr>), registry/ui/button.tsx (the affordance classes + the
// `[&.htmx-request]:…` arbitrary-variant idiom this fade mirrors).

// ── List host ─────────────────────────────────────────────────────────────
// The inheritance host. It hoists the shared delete behaviour onto every
// descendant Delete button via htmx's `:inherited` modifier, so a single
// declaration covers the whole list. Defaults to <tbody>; pass `as="ul"`
// (etc.) for non-table lists, and set the matching `target` (e.g. "closest
// li").

type ListTag = "tbody" | "ul" | "ol" | "div"

type DeleteRowListProps = PropsWithChildren<{
  // Confirmation question shown by the browser before each DELETE fires.
  // Pass null to skip confirmation entirely.
  confirm?: string | null
  // Selector for the element each Delete request removes. Default "closest
  // tr" — change to match `as` (e.g. "closest li" for a <ul>).
  target?: string
  // Fade duration in ms. Must match the row's CSS transition; both default
  // to 300ms. This is the htmx swap delay (hx-swap="… swap:Nms").
  swapMs?: number
  // Element the host renders as. Default "tbody".
  as?: ListTag
  class?: ClassValue
}> &
  Record<string, any>

export function DeleteRowList(props: DeleteRowListProps) {
  const {
    children,
    confirm = "Are you sure you want to delete this?",
    target = "closest tr",
    swapMs = 300,
    as = "tbody",
    class: className,
    ...rest
  } = props
  const Tag: any = as

  return (
    <Tag
      data-slot="delete-row"
      // htmx v4 explicit inheritance: every descendant Delete button picks
      // up these three attributes, so the per-row markup only needs
      // hx-delete. One declaration, every row.
      hx-confirm:inherited={confirm === null ? undefined : confirm}
      hx-target:inherited={target}
      hx-swap:inherited={`outerHTML swap:${swapMs}ms`}
      class={cn(className)}
      {...rest}
    >
      {children}
    </Tag>
  )
}

// ── Row ─────────────────────────────────────────────────────────────────
// One deletable row. Carries the opacity transition so that when htmx adds
// `htmx-swapping` during the swap delay, the row fades out before it's
// detached. Defaults to <tr>; pass `as` to match the list host.

type RowTag = "tr" | "li" | "div"

type DeleteRowItemProps = PropsWithChildren<{
  // Duration of the fade in ms; must equal the host's swapMs. Default 300.
  swapMs?: number
  as?: RowTag
  class?: ClassValue
}> &
  Record<string, any>

export function DeleteRowItem(props: DeleteRowItemProps) {
  const { children, swapMs = 300, as = "tr", class: className, ...rest } = props
  const Tag: any = as

  return (
    <Tag
      data-slot="delete-row-item"
      // The fade: opacity transitions over the swap delay, and htmx's
      // `htmx-swapping` class (added to this row for the swap:Nms window)
      // drives it to 0 before the node is removed. Same arbitrary-variant
      // idiom as button.tsx's [&.htmx-request]:… hook.
      style={`transition-duration:${swapMs}ms`}
      class={cn(
        "transition-opacity ease-out [&.htmx-swapping]:opacity-0",
        className,
      )}
      {...rest}
    >
      {children}
    </Tag>
  )
}

// ── Delete affordance ─────────────────────────────────────────────────────
// The per-row button. It only carries hx-delete — confirm / target / swap
// are inherited from DeleteRowList. Styled as a ghost button by default so
// it sits quietly in a cell; pass variant="destructive" for a louder one.

type DeleteRowProps = PropsWithChildren<{
  // DELETE endpoint for this row's resource. Respond 200 + empty body.
  href: string
  // Button label. Default "Delete". Use `ariaLabel` when the visible label
  // is an icon only.
  variant?: ButtonVariant
  size?: ButtonSize
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  disabled?: boolean
  class?: ClassValue
}> &
  Record<string, any>

export function DeleteRow(props: DeleteRowProps) {
  const {
    children,
    href,
    variant = "ghost",
    size = "sm",
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    disabled,
    class: className,
    ...rest
  } = props

  return (
    <button
      type="button"
      data-slot="delete-row-trigger"
      hx-delete={href}
      disabled={disabled}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabelledby}
      aria-describedby={ariaDescribedby}
      class={buttonClasses({
        variant,
        size,
        class: cn("text-muted-foreground hover:text-destructive", className),
      })}
      {...rest}
    >
      {children ?? "Delete"}
    </button>
  )
}

1. Save the file

Copy delete-row.html into templates/components/.

2. Use it

templates/components/delete-row.html
{% from "components/delete-row.html" import delete_row_list, delete_row_item, delete_row %}

<table class="w-full text-sm">
  {% call delete_row_list() %}
    {% for c in contacts %}
      {% call delete_row_item() %}
        <td class="p-2">{{ c.name }}</td>
        <td class="p-2 text-right">{{ delete_row(href="/contacts/" ~ c.id) }}</td>
      {% endcall %}
    {% endfor %}
  {% endcall %}
</table>
View source
templates/components/delete-row.html
{# Delete Row macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/delete-row.tsx for Python/Flask/FastAPI/Django/Jinja2.

   A row/item delete affordance that confirms, sends DELETE, then fades out
   in place. One inherited declaration on the list host covers every row —
   no per-row wiring, no client-side list state. The server answers the
   DELETE with a 200 and an empty body, so the row is swapped with nothing
   and simply disappears.
   See repos/htmx/www/src/content/patterns/03-records/02-delete-in-place.md.

   htmx v4 inheritance is explicit (the `:inherited` modifier), so the host
   hoists hx-confirm / hx-target / hx-swap to every descendant Delete button:
   see repos/htmx/www/src/content/docs/03-features/08-attribute-inheritance.md.

   Usage:
       {% from "components/delete-row.html" import delete_row_list, delete_row_item, delete_row %}

       <table class="w-full caption-bottom text-sm">
         <tbody is not used directly wrap rows in delete_row_list #}
         {% call delete_row_list() %}
           {% call delete_row_item() %}
             <td>Joe Smith</td>
             <td class="text-right">{{ delete_row(href="/contacts/1") }}</td>
           {% endcall %}
         {% endcall %}
       </table>
#}

{# List host — hoists the shared delete behaviour onto every Delete button. #}
{% macro delete_row_list(confirm="Are you sure you want to delete this?", target="closest tr", swap_ms=300, as="tbody", extra_class="", caller=none) %}
<{{ as }} data-slot="delete-row"
   {%- if confirm is not none %} hx-confirm:inherited="{{ confirm }}"{% endif %}
   hx-target:inherited="{{ target }}" hx-swap:inherited="outerHTML swap:{{ swap_ms }}ms"
   class="{{ extra_class }}">
  {%- if caller %}{{ caller() }}{% endif %}
</{{ as }}>
{% endmacro %}

{# Row — carries the opacity fade keyed off htmx's `htmx-swapping` class. #}
{% macro delete_row_item(swap_ms=300, as="tr", extra_class="", caller=none) %}
<{{ as }} data-slot="delete-row-item" style="transition-duration:{{ swap_ms }}ms"
   class="transition-opacity ease-out [&.htmx-swapping]:opacity-0 {{ extra_class }}">
  {%- if caller %}{{ caller() }}{% endif %}
</{{ as }}>
{% endmacro %}

{# Delete affordance — only carries hx-delete; the rest is inherited. #}
{% macro delete_row(href, label="Delete", aria_label=none, disabled=false, extra_class="", attrs={}) %}
{%- set _btn = "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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 text-muted-foreground hover:text-destructive" -%}
<button type="button" data-slot="delete-row-trigger" hx-delete="{{ href }}"
        {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
        {%- if disabled %} disabled{% endif %}
        {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
        class="{{ _btn }} {{ extra_class }}">{{ label }}</button>
{% endmacro %}

1. Save the file

Add delete-row.tmpl alongside your templates.

2. Use it

components/delete-row.tmpl
{{define "rows"}}
  {{range .Contacts}}
    {{template "delete_row_item" (dict "Body" (htmlSafe (printf
      "<td class=\"p-2\">%s</td><td class=\"p-2 text-right\">%s</td>"
      .Name (delete_row_btn .ID)))}}
  {{end}}
{{end}}

<table class="w-full text-sm">
  {{template "delete_row_list" (dict "Body" (htmlSafe (renderRows .Contacts)))}}
</table>
View source
components/delete-row.tmpl
{{/*
  Delete Row templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/delete-row.tsx for Go projects using html/template.

  A row/item delete affordance that confirms, sends DELETE, then fades out
  in place. One inherited declaration on the list host covers every row —
  no per-row wiring, no client-side list state. The server answers the
  DELETE with a 200 and an empty body, so the row is swapped with nothing
  and simply disappears.
  See repos/htmx/www/src/content/patterns/03-records/02-delete-in-place.md.

  htmx v4 inheritance is explicit (the `:inherited` modifier), so the host
  hoists hx-confirm / hx-target / hx-swap to every descendant Delete button:
  see repos/htmx/www/src/content/docs/03-features/08-attribute-inheritance.md.

  Three templates:
    - "delete_row_list" — the inheritance host (default <tbody>).
    - "delete_row_item" — one deletable row (default <tr>) with the fade.
    - "delete_row"      — the Delete button; only carries hx-delete.

  Args:

      type DeleteRowListArgs struct {
          Confirm string       // "" disables the confirm prompt
          Target  string       // default "closest tr"
          SwapMs  int          // default 300
          As      string       // default "tbody"
          Body    template.HTML
      }
      type DeleteRowItemArgs struct {
          SwapMs int           // default 300
          As     string        // default "tr"
          Body   template.HTML
      }
      type DeleteRowArgs struct {
          Href      string     // DELETE endpoint; respond 200 + empty body
          Label     string     // default "Delete"
          AriaLabel string
          Disabled  bool
      }

  Pass these via a (dict ...) helper and (htmlSafe ...) for the body, e.g.

      {{template "delete_row_list" (dict "Body" (htmlSafe $rows))}}
*/}}

{{define "delete_row_list"}}
{{- $confirm := or .Confirm "Are you sure you want to delete this?" -}}
{{- $target := or .Target "closest tr" -}}
{{- $swapMs := or .SwapMs 300 -}}
{{- $as := or .As "tbody" -}}
<{{$as}} data-slot="delete-row"
   {{- if .NoConfirm}}{{else}} hx-confirm:inherited="{{$confirm}}"{{end}}
   hx-target:inherited="{{$target}}" hx-swap:inherited="outerHTML swap:{{$swapMs}}ms">
  {{- .Body}}
</{{$as}}>
{{end}}

{{define "delete_row_item"}}
{{- $swapMs := or .SwapMs 300 -}}
{{- $as := or .As "tr" -}}
<{{$as}} data-slot="delete-row-item" style="transition-duration:{{$swapMs}}ms"
   class="transition-opacity ease-out [&.htmx-swapping]:opacity-0">
  {{- .Body}}
</{{$as}}>
{{end}}

{{define "delete_row"}}
{{- $btn := "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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 text-muted-foreground hover:text-destructive" -}}
{{- $label := or .Label "Delete" -}}
<button type="button" data-slot="delete-row-trigger" hx-delete="{{.Href}}"
        {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
        {{- if .Disabled}} disabled{{end}}
        class="{{$btn}}">{{$label}}</button>
{{end}}

1. Save the file

Drop delete_row.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/delete_row.ex
<table class="w-full text-sm">
  <.delete_row_list>
    <.delete_row_item :for={c <- @contacts}>
      <td class="p-2">{c.name}</td>
      <td class="p-2 text-right"><.delete_row href={~p"/contacts/#{c.id}"} /></td>
    </.delete_row_item>
  </.delete_row_list>
</table>
View source
lib/my_app_web/components/delete_row.ex
defmodule ShadcnHtmx.Components.DeleteRow do
  @moduledoc """
  Delete Row — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/delete-row.tsx. A row/item delete affordance that
  confirms, sends DELETE, then fades out in place. One inherited declaration
  on the list host covers every row — no per-row wiring, no client-side list
  state. The server answers the DELETE with a 200 and an empty body, so the
  row is swapped with nothing and simply disappears.
  See repos/htmx/www/src/content/patterns/03-records/02-delete-in-place.md.

  htmx v4 inheritance is explicit (the `:inherited` modifier), so the host
  hoists hx-confirm / hx-target / hx-swap to every descendant Delete button:
  see repos/htmx/www/src/content/docs/03-features/08-attribute-inheritance.md.

  Three function components:
    - `delete_row_list/1` — the inheritance host (default <tbody>).
    - `delete_row_item/1` — one deletable row (default <tr>) with the fade.
    - `delete_row/1`      — the Delete button; only carries hx-delete.

  ## Examples

      <table class="w-full caption-bottom text-sm">
        <.delete_row_list>
          <.delete_row_item>
            <td>Joe Smith</td>
            <td class="text-right"><.delete_row href={~p"/contacts/1"} /></td>
          </.delete_row_item>
        </.delete_row_list>
      </table>
  """

  use Phoenix.Component

  @btn "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 " <>
         "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " <>
         "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " <>
         "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 " <>
         "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 " <>
         "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 " <>
         "text-muted-foreground hover:text-destructive"

  attr :confirm, :string,
    default: "Are you sure you want to delete this?",
    doc: "Confirm prompt; pass nil to skip confirmation."

  attr :target, :string, default: "closest tr"
  attr :swap_ms, :integer, default: 300
  attr :as, :string, default: "tbody"
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def delete_row_list(assigns) do
    ~H"""
    <.dynamic_tag
      tag_name={@as}
      data-slot="delete-row"
      hx-confirm:inherited={@confirm}
      hx-target:inherited={@target}
      hx-swap:inherited={"outerHTML swap:#{@swap_ms}ms"}
      class={@class}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.dynamic_tag>
    """
  end

  attr :swap_ms, :integer, default: 300
  attr :as, :string, default: "tr"
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def delete_row_item(assigns) do
    ~H"""
    <.dynamic_tag
      tag_name={@as}
      data-slot="delete-row-item"
      style={"transition-duration:#{@swap_ms}ms"}
      class={["transition-opacity ease-out [&.htmx-swapping]:opacity-0", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </.dynamic_tag>
    """
  end

  attr :href, :string, required: true
  attr :label, :string, default: "Delete"
  attr :aria_label, :string, default: nil
  attr :disabled, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(hx-target hx-swap hx-confirm hx-trigger hx-indicator)

  def delete_row(assigns) do
    assigns = assign(assigns, :btn, @btn)

    ~H"""
    <button
      type="button"
      data-slot="delete-row-trigger"
      hx-delete={@href}
      aria-label={@aria_label}
      disabled={@disabled}
      class={[@btn, @class]}
      {@rest}
    >
      {@label}
    </button>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/delete-row.html
<table class="w-full text-sm">
  <tbody data-slot="delete-row"
         hx-confirm:inherited="Are you sure you want to delete this?"
         hx-target:inherited="closest tr"
         hx-swap:inherited="outerHTML swap:300ms">
    <tr data-slot="delete-row-item" style="transition-duration:300ms"
        class="transition-opacity ease-out [&.htmx-swapping]:opacity-0">
      <td class="p-2">Joe Smith</td>
      <td class="p-2 text-right">
        <button type="button" data-slot="delete-row-trigger" hx-delete="/contacts/1"
                class="…">Delete</button>
      </td>
    </tr>
  </tbody>
</table>
View source
snippets/delete-row.html
<!--
  shadcn-htmx — raw HTML Delete Row snippet.

  Mirrors registry/ui/delete-row.tsx. Drop onto any page that loads htmx v4
  + Tailwind CSS v4 and the shadcn theme variables (muted-foreground,
  destructive, accent, border, ring). Relies only on theme tokens.

  A row/item delete affordance that confirms, sends DELETE, then fades out
  in place. One inherited declaration on the <tbody> covers every row — no
  per-row wiring, no client-side list state.
  See repos/htmx/www/src/content/patterns/03-records/02-delete-in-place.md.

  How it works (all native + htmx, zero custom JS):
    - The <tbody> hoists three attributes to every descendant Delete button
      using htmx v4's explicit `:inherited` modifier:
        hx-confirm:inherited  → window.confirm before each request
        hx-target:inherited   → "closest tr" targets the row to remove
        hx-swap:inherited     → "outerHTML swap:300ms" delays removal 300ms
    - Each Delete button only carries hx-delete="/…". On click it confirms,
      sends DELETE, and during the 300ms swap delay htmx adds the
      `htmx-swapping` class to the row — which drives the CSS opacity fade.
    - The server responds 200 with an EMPTY body, so the row is replaced
      with nothing and disappears. (A 204 would skip the swap entirely.)
-->

<table class="w-full caption-bottom text-sm">
  <thead class="[&_tr]:border-b">
    <tr>
      <th scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Name</th>
      <th scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Email</th>
      <th scope="col" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground"><span class="sr-only">Actions</span></th>
    </tr>
  </thead>

  <!-- List host: one inherited declaration, every row. -->
  <tbody data-slot="delete-row"
         hx-confirm:inherited="Are you sure you want to delete this?"
         hx-target:inherited="closest tr"
         hx-swap:inherited="outerHTML swap:300ms"
         class="[&_tr:last-child]:border-0">

    <!-- Row: fades out via opacity while htmx-swapping is on it. -->
    <tr data-slot="delete-row-item" style="transition-duration:300ms"
        class="border-b transition-opacity ease-out [&.htmx-swapping]:opacity-0 hover:bg-muted/50">
      <td class="p-2 align-middle">Joe Smith</td>
      <td class="p-2 align-middle text-muted-foreground">[email protected]</td>
      <td class="p-2 align-middle text-right">
        <button type="button" data-slot="delete-row-trigger" hx-delete="/contacts/1"
                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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
      </td>
    </tr>

    <tr data-slot="delete-row-item" style="transition-duration:300ms"
        class="border-b transition-opacity ease-out [&.htmx-swapping]:opacity-0 hover:bg-muted/50">
      <td class="p-2 align-middle">Angie MacDowell</td>
      <td class="p-2 align-middle text-muted-foreground">[email protected]</td>
      <td class="p-2 align-middle text-right">
        <button type="button" data-slot="delete-row-trigger" hx-delete="/contacts/2"
                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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
      </td>
    </tr>
  </tbody>
</table>

Examples

Delete in place

Each Delete confirms, sends DELETE to the row's resource, then fades the row out before htmx detaches it. Reload the page to restore the list.

The <tbody> hoists hx-confirm:inherited, hx-target:inherited="closest tr" and hx-swap:inherited="outerHTML swap:300ms" with htmx v4's explicit :inherited modifier, so each Delete button only needs hx-delete. During the 300ms swap delay htmx adds htmx-swapping to the row, which drives the opacity fade — no JavaScript of our own.

NameEmailActions
Joe Smith[email protected]
Angie MacDowell[email protected]
Fuqua Tarkenton[email protected]
Kim Yee[email protected]
<table class="w-full text-sm">
  <DeleteRowList>
    {contacts.map((c) => (
      <DeleteRowItem>
        <td class="p-2">{c.name}</td>
        <td class="p-2 text-right">
          <DeleteRow href={`/contacts/${c.id}`} ariaLabel={`Delete ${c.name}`} />
        </td>
      </DeleteRowItem>
    ))}
  </DeleteRowList>
</table>
{% call delete_row_list() %}
  {% for c in contacts %}
    {% call delete_row_item() %}
      <td class="p-2">{{ c.name }}</td>
      <td class="p-2 text-right">{{ delete_row(href="/contacts/" ~ c.id) }}</td>
    {% endcall %}
  {% endfor %}
{% endcall %}
{{template "delete_row_list" (dict "Body" (htmlSafe $rows))}}
<.delete_row_list>
  <.delete_row_item :for={c <- @contacts}>
    <td class="p-2">{c.name}</td>
    <td class="p-2 text-right"><.delete_row href={~p"/contacts/#{c.id}"} /></td>
  </.delete_row_item>
</.delete_row_list>
<div id="ex-dr-host" class="w-full">
  <div class="relative w-full overflow-auto">
    <table data-slot="table" class="w-full caption-bottom text-sm text-sm">
      <thead data-slot="table-header" class="[&amp;_tr]:border-b">
        <tr>
          <th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Name</th>
          <th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Email</th>
          <th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">
            <span class="sr-only">Actions</span>
          </th>
        </tr>
      </thead>
      <tbody data-slot="delete-row" hx-confirm:inherited="Are you sure you want to delete this?" hx-target:inherited="closest tr" hx-swap:inherited="outerHTML swap:300ms" class="[&amp;_tr:last-child]:border-0">
        <tr data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&amp;.htmx-swapping]:opacity-0 border-b hover:bg-muted/50" data-test="row-1">
          <td data-slot="table-cell" class="p-2 align-middle">Joe Smith</td>
          <td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
          <td data-slot="table-cell" class="p-2 align-middle text-right">
            <button type="button" data-slot="delete-row-trigger" hx-delete="/delete-row/contacts/1" aria-label="Delete Joe Smith" 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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
          </td>
        </tr>
        <tr data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&amp;.htmx-swapping]:opacity-0 border-b hover:bg-muted/50" data-test="row-2">
          <td data-slot="table-cell" class="p-2 align-middle">Angie MacDowell</td>
          <td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
          <td data-slot="table-cell" class="p-2 align-middle text-right">
            <button type="button" data-slot="delete-row-trigger" hx-delete="/delete-row/contacts/2" aria-label="Delete Angie MacDowell" 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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
          </td>
        </tr>
        <tr data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&amp;.htmx-swapping]:opacity-0 border-b hover:bg-muted/50" data-test="row-3">
          <td data-slot="table-cell" class="p-2 align-middle">Fuqua Tarkenton</td>
          <td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
          <td data-slot="table-cell" class="p-2 align-middle text-right">
            <button type="button" data-slot="delete-row-trigger" hx-delete="/delete-row/contacts/3" aria-label="Delete Fuqua Tarkenton" 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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
          </td>
        </tr>
        <tr data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&amp;.htmx-swapping]:opacity-0 border-b hover:bg-muted/50" data-test="row-4">
          <td data-slot="table-cell" class="p-2 align-middle">Kim Yee</td>
          <td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
          <td data-slot="table-cell" class="p-2 align-middle text-right">
            <button type="button" data-slot="delete-row-trigger" hx-delete="/delete-row/contacts/4" aria-label="Delete Kim Yee" 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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Non-table list

The host isn't tied to tables. Render it as a <ul> and set the matching target so the same one-declaration behaviour removes <li> items.

Pass as="ul" on the host and target="closest li" so the inherited hx-target resolves to the list item. Each DeleteRowItem renders as an <li> carrying the same fade. Everything else is identical — the affordance, the confirm, the empty-body DELETE.

  • design-spec.pdf
  • budget-q3.xlsx
<DeleteRowList as="ul" target="closest li">
  <DeleteRowItem as="li">
    <span>design-spec.pdf</span>
    <DeleteRow href="/files/1" ariaLabel="Delete design-spec.pdf" />
  </DeleteRowItem>
</DeleteRowList>
{% call delete_row_list(as="ul", target="closest li") %}
  {% call delete_row_item(as="li") %}
    <span>design-spec.pdf</span>
    {{ delete_row(href="/files/1", aria_label="Delete design-spec.pdf") }}
  {% endcall %}
{% endcall %}
{{template "delete_row_list" (dict "As" "ul" "Target" "closest li" "Body" (htmlSafe $items))}}
<.delete_row_list as="ul" target="closest li">
  <.delete_row_item as="li">
    <span>design-spec.pdf</span>
    <.delete_row href={~p"/files/1"} aria_label="Delete design-spec.pdf" />
  </.delete_row_item>
</.delete_row_list>
<ul data-slot="delete-row" hx-confirm:inherited="Are you sure you want to delete this?" hx-target:inherited="closest li" hx-swap:inherited="outerHTML swap:300ms" class="w-full max-w-sm divide-y rounded-md border">
  <li data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&amp;.htmx-swapping]:opacity-0 flex items-center justify-between gap-2 px-3 py-2">
    <span class="text-sm">design-spec.pdf</span>
    <button type="button" data-slot="delete-row-trigger" hx-delete="/delete-row/files/1" aria-label="Delete design-spec.pdf" 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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
  </li>
  <li data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&amp;.htmx-swapping]:opacity-0 flex items-center justify-between gap-2 px-3 py-2">
    <span class="text-sm">budget-q3.xlsx</span>
    <button type="button" data-slot="delete-row-trigger" hx-delete="/delete-row/files/2" aria-label="Delete budget-q3.xlsx" 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 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5 text-muted-foreground hover:text-destructive">Delete</button>
  </li>
</ul>

API Reference

Delete Row

PropTypeDefaultDescription
href*string
DELETE endpoint for this row's resource (on DeleteRow). The server must answer 200 with an empty body so htmx swaps the row with nothing; a 204 performs no swap and the row stays.htmxhx-delete
confirmstring|null"Are you sure you want to delete this?"
Confirmation question shown by window.confirm before each DELETE (on DeleteRowList; inherited by every Delete button via hx-confirm:inherited). Pass null to skip confirmation.htmxhx-confirm
targetstring"closest tr"
Selector for the element each Delete request removes (on DeleteRowList; inherited as hx-target). Change to match the host element, e.g. "closest li" for a <ul>.htmxhx-target
swapMsnumber300
Fade duration in milliseconds. Set on both DeleteRowList (the htmx swap delay, hx-swap="outerHTML swap:Nms") and DeleteRowItem (the CSS transition); the two must match so the row finishes fading before htmx detaches it.htmxhx-swap (swap: modifier)
as"tbody"|"ul"|"ol"|"div"|"tr"|"li""tbody" (list) / "tr" (item)
Element the host (DeleteRowList) or row (DeleteRowItem) renders as. Defaults suit a table; switch to ul/li (etc.) for non-table lists and set the matching target.MDN<tbody>
variant"default"|"secondary"|"destructive"|"outline"|"ghost"|"link""ghost"
Visual style of the Delete button (on DeleteRow). Ghost sits quietly in a cell; destructive makes the affordance louder.
size"xs"|"sm"|"default"|"lg"|"icon"|"icon-xs"|"icon-sm"|"icon-lg""sm"
Size of the Delete button (on DeleteRow). Use an icon-* size with ariaLabel for an icon-only affordance.
disabledbooleanfalse
Disable the Delete button — skipped from tab order, no request fires.
ariaLabelstring
Accessible name for the Delete button (on DeleteRow). Set this when the visible label is an icon or when several Delete buttons need to be distinguished (e.g. "Delete Joe Smith").MDNaria-label
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required