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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/*
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
<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
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
<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
<!--
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.
| Name | Actions | |
|---|---|---|
| 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="[&_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="[&_tr:last-child]:border-0">
<tr data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&.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 [&_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="transition-opacity ease-out [&.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 [&_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="transition-opacity ease-out [&.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 [&_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="transition-opacity ease-out [&.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 [&_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>
</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 [&.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 [&_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>
</li>
<li data-slot="delete-row-item" style="transition-duration:300ms" class="transition-opacity ease-out [&.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 [&_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>
</li>
</ul>Further reading
API Reference
Delete Row
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
confirm | string|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 |
target | string | "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 |
swapMs | number | 300 | 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. |
disabled | boolean | false | Disable the Delete button — skipped from tab order, no request fires. |
ariaLabel | string | — | 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 |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required