Components
Selectable Table
A data table with row checkboxes, a header select-all, a live selection count, and a contextual bulk-action bar. The bar is revealed purely in CSS via :has(:checked) — no JavaScript decides visibility. Each row checkbox is a real name="selected" field, so bulk-action buttons hx-post the checked values and re-render the table.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/selectable-table.json2. Use it
import {
SelectableTable, SelectableTableActions, BulkAction,
SelectableTableContent, SelectableTableHeader, SelectableTableBody,
SelectableTableRow, SelectableTableHead, SelectableTableCell,
SelectAllCheckbox, SelectRowCheckbox, SelectableTableCount,
} from "@/components/ui/selectable-table"
<SelectableTable ariaLabel="Users">
<SelectableTableActions label="With selected:">
<BulkAction hx-post="/users/activate">Activate</BulkAction>
<BulkAction hx-post="/users/delete" variant="destructive" confirm="Delete selected users?">Delete</BulkAction>
</SelectableTableActions>
<SelectableTableContent>
<SelectableTableHeader>
<SelectableTableRow>
<SelectableTableHead class="w-10"><SelectAllCheckbox /></SelectableTableHead>
<SelectableTableHead>Name</SelectableTableHead>
<SelectableTableHead>Email</SelectableTableHead>
</SelectableTableRow>
</SelectableTableHeader>
<SelectableTableBody>
<SelectableTableRow>
<SelectableTableCell><SelectRowCheckbox value="[email protected]" ariaLabel="Select Ada" /></SelectableTableCell>
<SelectableTableCell>Ada Lovelace</SelectableTableCell>
<SelectableTableCell>[email protected]</SelectableTableCell>
</SelectableTableRow>
</SelectableTableBody>
</SelectableTableContent>
<SelectableTableCount />
</SelectableTable>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Selectable Table — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A data table with row checkboxes, a header select-all, a live selection
// count, and a contextual bulk-action bar. Built entirely on web standards:
//
// - A <form> wraps the <table>. Every row checkbox is a real
// <input type="checkbox" name="selected" value="…">, so the checked
// values are submitted as a repeated form field with no JS plumbing.
// - Bulk-action <button>s carry hx-post; because they sit inside the form,
// htmx serialises the enclosing form and submits every checked `selected`
// value. hx-target / hx-swap on the form replace it with the server's
// re-render (selections cleared, rows updated, result message).
// - The action bar is revealed PURELY in CSS via :has() — no JS decides
// visibility. `form:has(input[name=selected]:checked)` shows the bar and
// highlights checked rows. This is the platform feature, not an emulation.
// - The header "select-all" + the live count + the row indeterminate state
// are the only behaviour that CSS can't express, so they live in a tiny
// delegated site.js handler keyed on data-slot="selectable-table". The
// table still works without it: every checkbox toggles and submits
// natively; you just lose the convenience toggle and the running count.
//
// The <output> element is an implicit aria-live region, so the running count
// and the post-action result message are announced to AT without moving focus.
//
// Sources (read, never copied):
// repos/htmx/www/src/content/patterns/03-records/01-bulk-actions.md
// repos/htmx/www/src/content/reference/01-attributes/{02-hx-post,07-hx-swap,08-hx-target,22-hx-confirm}.md
// repos/htmx/www/src/content/docs/02-core-concepts/02-hypermedia-controls.md (enclosing-form serialisation)
// repos/mdn/files/en-us/web/css/reference/selectors/_colon_has/index.md
// repos/mdn/files/en-us/web/html/reference/elements/output/index.md (implicit aria-live)
// repos/mdn/files/en-us/web/html/reference/elements/table/index.md
// style analogues: registry/ui/table.tsx, registry/ui/checkbox.tsx
// Shared checkbox styling — mirrors registry/ui/checkbox.tsx so the row /
// select-all boxes look identical to the standalone <Checkbox>.
const checkboxInput =
"peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:cursor-not-allowed disabled:opacity-50 " +
"checked:border-primary checked:bg-primary " +
"indeterminate:border-primary indeterminate:bg-primary " +
"dark:bg-input/30"
function CheckIcon() {
return (
<>
<svg
class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</>
)
}
// ---- Root form -------------------------------------------------------
type SelectableTableProps = {
// Endpoint-independent: each BulkAction carries its own hx-post. The form
// owns the swap target/strategy so every action re-renders the whole table.
"hx-target"?: string
"hx-swap"?: string
// Accessible name for the form region (announced as a group).
ariaLabel?: string
ariaLabelledby?: string
id?: string
class?: ClassValue
children?: Child
[key: `data-${string}`]: any
[key: `hx-${string}`]: any
}
export function SelectableTable(props: SelectableTableProps) {
const {
class: className,
ariaLabel,
ariaLabelledby,
children,
...rest
} = props
return (
<form
data-slot="selectable-table"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
// Named group so the action bar can react to :has(checked) via the
// group-has-[…]/selectable-table arbitrary variant — pure CSS reveal.
// NB: htmx v4 inheritance is EXPLICIT (repos/htmx/.../docs/03-features/
// 08-attribute-inheritance.md), so we do NOT set hx-target/hx-swap here
// and rely on children inheriting them — each BulkAction targets the
// form explicitly instead (see below).
class={cn("group/selectable-table w-full space-y-3", className)}
{...rest}
>
{children}
</form>
)
}
// ---- Contextual action bar -------------------------------------------
// Hidden by default; revealed only while the form has a checked row box.
// Pure CSS :has() — the group-[…] arbitrary variant targets the bar based on
// the ancestor form's :has() state, so no JS toggles display.
type ActionsProps = {
label?: Child
class?: ClassValue
children?: Child
}
export function SelectableTableActions(props: ActionsProps) {
return (
<div
data-slot="selectable-table-actions"
// hidden until any row checkbox is checked (CSS :has on the form root).
class={cn(
"hidden items-center gap-2 rounded-md border bg-muted px-3 py-2",
"group-has-[input[name=selected]:checked]/selectable-table:flex",
props.class,
)}
>
{props.label !== undefined && (
<span class="mr-1 text-xs font-medium text-muted-foreground">
{props.label}
</span>
)}
{props.children}
</div>
)
}
// A bulk-action button. Sits inside the form, so htmx serialises the enclosing
// form (all checked `selected` values) onto its hx-post request. It targets the
// closest [data-slot=selectable-table] form and swaps outerHTML, so the server
// re-render replaces the whole table. We set this EXPLICITLY (not via parent
// inheritance) because htmx v4 inheritance is opt-in, and because hx-target
// "this" on the form would resolve to the button, not the form. Override either
// with an explicit hx-target / hx-swap prop.
type BulkActionProps = {
"hx-post"?: string
"hx-target"?: string
"hx-swap"?: string
// Native browser confirm before firing (htmx hx-confirm).
confirm?: string
variant?: "default" | "destructive"
type?: "submit" | "button"
disabled?: boolean
class?: ClassValue
children?: Child
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
}
const bulkActionBase =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:pointer-events-none disabled:opacity-50 " +
"[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"
const bulkActionVariants: Record<NonNullable<BulkActionProps["variant"]>, string> = {
default: "bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
destructive: "border-destructive/40 text-destructive hover:bg-destructive/10",
}
export function BulkAction(props: BulkActionProps) {
const {
class: className,
confirm,
variant = "default",
type = "button",
children,
"hx-target": hxTarget,
"hx-swap": hxSwap,
...rest
} = props
return (
<button
type={type}
data-slot="selectable-table-action"
hx-confirm={confirm}
hx-target={hxTarget ?? "closest [data-slot='selectable-table']"}
hx-swap={hxSwap ?? "outerHTML"}
class={cn(bulkActionBase, bulkActionVariants[variant], className)}
{...rest}
>
{children}
</button>
)
}
// ---- Live selection count --------------------------------------------
// <output> is an implicit aria-live region. site.js writes the running count
// here; the server can also render a result message into it after a POST.
export function SelectableTableCount(
props: { children?: Child; class?: ClassValue },
) {
return (
<output
data-slot="selectable-table-count"
class={cn("block text-sm text-muted-foreground", props.class)}
>
{props.children}
</output>
)
}
// ---- Table primitives (thin wrappers mirroring registry/ui/table.tsx) -
export function SelectableTableContent(
props: { children?: Child; class?: ClassValue; wrapperClass?: ClassValue },
) {
return (
<div class={cn("relative w-full overflow-auto rounded-md border", props.wrapperClass)}>
<table
data-slot="selectable-table-content"
class={cn("w-full caption-bottom text-sm", props.class)}
>
{props.children}
</table>
</div>
)
}
export function SelectableTableHeader(props: { children?: Child; class?: ClassValue }) {
return (
<thead data-slot="selectable-table-header" class={cn("[&_tr]:border-b", props.class)}>
{props.children}
</thead>
)
}
export function SelectableTableBody(props: { children?: Child; class?: ClassValue }) {
return (
<tbody data-slot="selectable-table-body" class={cn("[&_tr:last-child]:border-0", props.class)}>
{props.children}
</tbody>
)
}
type RowProps = {
// value submitted for this row when its checkbox is checked.
value?: string
children?: Child
class?: ClassValue
}
export function SelectableTableRow(props: RowProps) {
return (
<tr
data-slot="selectable-table-row"
// Pure-CSS selected-row highlight, per the bulk-actions pattern.
class={cn(
"border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted",
props.class,
)}
>
{props.children}
</tr>
)
}
export function SelectableTableHead(
props: { children?: Child; scope?: "col" | "row"; class?: ClassValue },
) {
return (
<th
scope={props.scope ?? "col"}
data-slot="selectable-table-head"
class={cn("h-10 px-3 text-left align-middle font-medium text-muted-foreground", props.class)}
>
{props.children}
</th>
)
}
export function SelectableTableCell(
props: { children?: Child; class?: ClassValue; colspan?: number; scope?: "row" },
) {
return (
<td
colspan={props.colspan}
scope={props.scope}
data-slot="selectable-table-cell"
class={cn("px-3 py-2 align-middle", props.class)}
>
{props.children}
</td>
)
}
// ---- Select-all (header) and row checkboxes --------------------------
// Header checkbox. site.js toggles every row box from it and keeps it
// in sync (checked / unchecked / indeterminate). Native + form-safe; it is
// not submitted (no name), it only drives the row boxes.
export function SelectAllCheckbox(
props: { ariaLabel?: string; class?: ClassValue; id?: string },
) {
return (
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input
type="checkbox"
id={props.id}
data-slot="selectable-table-select-all"
aria-label={props.ariaLabel ?? "Select all rows"}
class={cn(checkboxInput, props.class)}
/>
<CheckIcon />
</span>
)
}
// Per-row checkbox. name="selected" makes the checked rows a repeated form
// field; value identifies the record.
export function SelectRowCheckbox(
props: {
value: string
checked?: boolean
ariaLabel?: string
name?: string
class?: ClassValue
id?: string
},
) {
return (
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input
type="checkbox"
id={props.id}
name={props.name ?? "selected"}
value={props.value}
checked={props.checked || undefined}
data-slot="selectable-table-select-row"
aria-label={props.ariaLabel ?? `Select ${props.value}`}
class={cn(checkboxInput, props.class)}
/>
<CheckIcon />
</span>
)
}
1. Save the file
Copy selectable-table.html into templates/components/.
2. Use it
{% from "components/selectable-table.html" import
selectable_table_open, selectable_table_close, st_actions_open,
st_actions_close, bulk_action, st_content_open, st_content_close,
st_select_all, st_select_row, st_count with context %}
{% call selectable_table_open(aria_label="Users") %}
{% call st_actions_open(label="With selected:") %}
{{ bulk_action(label="Activate", hx_post="/users/activate") }}
{{ bulk_action(label="Delete", hx_post="/users/delete", variant="destructive", confirm="Delete selected users?") }}
{% endcall %}
{# …table with st_select_all / st_select_row … #}
{{ st_count() }}
{% endcall %}View source
{# Selectable Table macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/selectable-table.tsx EXACTLY (same elements, roles,
data-slots, classes — only the templating syntax differs).
A <form>-wrapped <table> with row checkboxes (name="selected"), a header
select-all, a live <output> count, and a contextual bulk-action bar that
is revealed PURELY in CSS via :has(:checked) — no JS decides visibility.
Bulk-action <button>s sit inside the form, so htmx serialises every checked
value on hx-post; hx-target/hx-swap on the form replace it with the server
re-render. The select-all toggle + running count are handled by the shared
site.js keyed on data-slot="selectable-table" (graceful: every box still
toggles + submits natively without it).
Sources cited in selectable-table.tsx:
repos/htmx/.../patterns/03-records/01-bulk-actions.md
repos/htmx/.../reference/01-attributes/{02-hx-post,07-hx-swap,08-hx-target,22-hx-confirm}.md
repos/mdn/.../web/css/reference/selectors/_colon_has/index.md
repos/mdn/.../web/html/reference/elements/output/index.md
Usage:
{% from "components/selectable-table.html" import
selectable_table_open, selectable_table_close, st_actions_open,
st_actions_close, bulk_action, st_content_open, st_content_close,
st_select_all, st_select_row, st_count with context %} #}
{# htmx v4 inheritance is explicit, so the form carries no hx-target/hx-swap —
each bulk_action targets the closest form itself (see bulk_action). #}
{% macro selectable_table_open(aria_label=none, aria_labelledby=none, extra_class="", **attrs) %}
<form data-slot="selectable-table"
{% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if aria_labelledby %}aria-labelledby="{{ aria_labelledby }}"{% endif %}
class="group/selectable-table w-full space-y-3 {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ caller() }}</form>
{% endmacro %}
{% macro selectable_table_close() %}{% endmacro %}
{% macro st_actions_open(label=none, extra_class="") %}
<div data-slot="selectable-table-actions"
class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex {{ extra_class }}">
{% if label %}<span class="mr-1 text-xs font-medium text-muted-foreground">{{ label }}</span>{% endif %}
{{ caller() }}
</div>
{% endmacro %}
{% macro st_actions_close() %}{% endmacro %}
{% macro bulk_action(label="", hx_post=none, hx_target="closest [data-slot='selectable-table']", hx_swap="outerHTML", confirm=none, variant="default", type="button", disabled=false, extra_class="", **attrs) %}
{%- set base = "inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70" -%}
{%- set variants = {"default": "bg-background text-foreground hover:bg-accent hover:text-accent-foreground", "destructive": "border-destructive/40 text-destructive hover:bg-destructive/10"} -%}
<button type="{{ type }}" data-slot="selectable-table-action"
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
hx-target="{{ hx_target }}" hx-swap="{{ hx_swap }}"
{% if confirm %}hx-confirm="{{ confirm }}"{% endif %}
{% if disabled %}disabled{% endif %}
class="{{ base }} {{ variants[variant] }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{% if caller %}{{ caller() }}{% else %}{{ label }}{% endif %}</button>
{% endmacro %}
{% macro st_content_open(extra_class="", wrapper_class="") %}
<div class="relative w-full overflow-auto rounded-md border {{ wrapper_class }}">
<table data-slot="selectable-table-content" class="w-full caption-bottom text-sm {{ extra_class }}">
{% endmacro %}
{% macro st_content_close() %}</table></div>{% endmacro %}
{% macro st_select_all(aria_label="Select all rows", id=none, extra_class="") %}
{%- set box = "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30" -%}
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" {% if id %}id="{{ id }}"{% endif %} data-slot="selectable-table-select-all" aria-label="{{ aria_label }}" class="{{ box }} {{ extra_class }}">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</span>
{% endmacro %}
{% macro st_select_row(value, name="selected", checked=false, aria_label=none, id=none, extra_class="") %}
{%- set box = "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30" -%}
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" {% if id %}id="{{ id }}"{% endif %} name="{{ name }}" value="{{ value }}" {% if checked %}checked{% endif %} data-slot="selectable-table-select-row" aria-label="{{ aria_label or ('Select ' ~ value) }}" class="{{ box }} {{ extra_class }}">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</span>
{% endmacro %}
{% macro st_count(message="", extra_class="") %}
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground {{ extra_class }}">{{ message }}</output>
{% endmacro %}
1. Save the file
Add selectable-table.tmpl alongside your templates.
2. Use it
{{template "selectable_table" (dict
"AriaLabel" "Users"
"Body" (htmlSafe "<!-- actions + table + count -->"))}}
{{template "bulk_action" (dict "Label" "Activate" "HxPost" "/users/activate")}}
{{template "st_select_row" (dict "Value" "[email protected]" "AriaLabel" "Select Ada")}}View source
{{/* Selectable Table templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/selectable-table.tsx EXACTLY (same elements, roles,
data-slots, classes — only the templating syntax differs).
A <form>-wrapped <table> with row checkboxes (name="selected"), a header
select-all, a live <output> count, and a contextual bulk-action bar that
is revealed PURELY in CSS via :has(:checked) — no JS decides visibility.
Bulk-action <button>s sit inside the form, so htmx serialises every
checked value on hx-post; hx-target/hx-swap on the form replace it with
the re-render. The select-all toggle + running count come from the shared
site.js keyed on data-slot="selectable-table" (graceful without it).
Sources cited in selectable-table.tsx:
repos/htmx/.../patterns/03-records/01-bulk-actions.md
repos/htmx/.../reference/01-attributes/{02-hx-post,07-hx-swap,08-hx-target,22-hx-confirm}.md
repos/mdn/.../web/css/reference/selectors/_colon_has/index.md
repos/mdn/.../web/html/reference/elements/output/index.md
Usage:
{{template "selectable_table" (dict "AriaLabel" "Users" "Body" (htmlSafe "..."))}}
{{template "bulk_action" (dict "Label" "Activate" "HxPost" "/users/activate")}}
{{template "st_select_row" (dict "Value" "[email protected]" "AriaLabel" "Select Ada")}} */}}
{{/* htmx v4 inheritance is explicit, so the form carries no hx-target/hx-swap —
each bulk_action targets the closest form itself (see bulk_action). */}}
{{define "selectable_table"}}
<form data-slot="selectable-table"
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
class="group/selectable-table w-full space-y-3 {{.Class}}">{{.Body}}</form>
{{end}}
{{define "st_actions"}}
<div data-slot="selectable-table-actions"
class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex {{.Class}}">
{{if .Label}}<span class="mr-1 text-xs font-medium text-muted-foreground">{{.Label}}</span>{{end}}
{{.Body}}
</div>
{{end}}
{{define "bulk_action"}}
{{- $base := "inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70" -}}
{{- $variant := or .Variant "default" -}}
{{- $variantClass := "bg-background text-foreground hover:bg-accent hover:text-accent-foreground" -}}
{{- if eq $variant "destructive" }}{{ $variantClass = "border-destructive/40 text-destructive hover:bg-destructive/10" }}{{ end -}}
<button type="{{or .Type "button"}}" data-slot="selectable-table-action"
{{if .HxPost}}hx-post="{{.HxPost}}"{{end}}
hx-target="{{or .HxTarget "closest [data-slot='selectable-table']"}}" hx-swap="{{or .HxSwap "outerHTML"}}"
{{if .Confirm}}hx-confirm="{{.Confirm}}"{{end}}
{{if .Disabled}}disabled{{end}}
class="{{$base}} {{$variantClass}} {{.Class}}">{{if .Body}}{{.Body}}{{else}}{{.Label}}{{end}}</button>
{{end}}
{{define "st_content"}}
<div class="relative w-full overflow-auto rounded-md border {{.WrapperClass}}">
<table data-slot="selectable-table-content" class="w-full caption-bottom text-sm {{.Class}}">{{.Body}}</table>
</div>
{{end}}
{{define "st_select_all"}}
{{- $box := "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30" -}}
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" {{if .ID}}id="{{.ID}}"{{end}} data-slot="selectable-table-select-all" aria-label="{{or .AriaLabel "Select all rows"}}" class="{{$box}} {{.Class}}">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</span>
{{end}}
{{define "st_select_row"}}
{{- $box := "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30" -}}
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" {{if .ID}}id="{{.ID}}"{{end}} name="{{or .Name "selected"}}" value="{{.Value}}" {{if .Checked}}checked{{end}} data-slot="selectable-table-select-row" aria-label="{{if .AriaLabel}}{{.AriaLabel}}{{else}}Select {{.Value}}{{end}}" class="{{$box}} {{.Class}}">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</span>
{{end}}
{{define "st_count"}}
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground {{.Class}}">{{.Message}}</output>
{{end}}
1. Save the file
Drop selectable_table.ex into lib/my_app_web/components/.
2. Use it
<.selectable_table aria_label="Users">
<:actions label="With selected:">
<.bulk_action hx-post="/users/activate">Activate</.bulk_action>
<.bulk_action hx-post="/users/delete" variant="destructive" confirm="Delete selected users?">Delete</.bulk_action>
</:actions>
<:row :for={u <- @users} value={u.email}>
<:cell>{u.name}</:cell>
<:cell>{u.email}</:cell>
</:row>
</.selectable_table>View source
defmodule ShadcnHtmx.Components.SelectableTable do
@moduledoc """
Selectable Table — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/selectable-table.tsx EXACTLY (same elements, roles,
data-slots, classes — only the templating syntax differs).
A `<form>`-wrapped `<table>` with row checkboxes (`name="selected"`), a
header select-all, a live `<output>` count, and a contextual bulk-action
bar revealed PURELY in CSS via `:has(:checked)` — no JS decides visibility.
Bulk-action `<button>`s sit inside the form, so htmx serialises every
checked value on `hx-post`; `hx-target`/`hx-swap` on the form replace it
with the re-render. The select-all toggle + running count come from the
shared site.js keyed on `data-slot="selectable-table"` (graceful: every box
still toggles + submits natively without it).
Sources cited in selectable-table.tsx:
repos/htmx/.../patterns/03-records/01-bulk-actions.md
repos/htmx/.../reference/01-attributes/{02-hx-post,07-hx-swap,08-hx-target,22-hx-confirm}.md
repos/mdn/.../web/css/reference/selectors/_colon_has/index.md
repos/mdn/.../web/html/reference/elements/output/index.md
## Examples
<.selectable_table aria_label="Users" message={@message}>
<:actions label="With selected:">
<.bulk_action hx-post="/users/activate">Activate</.bulk_action>
<.bulk_action hx-post="/users/delete" variant="destructive" confirm="Delete selected users?">
Delete
</.bulk_action>
</:actions>
<:column label="Name" />
<:column label="Email" />
<:row :for={u <- @users} value={u.email} aria_label={"Select #{u.name}"}>
<:cell>{u.name}</:cell>
<:cell>{u.email}</:cell>
</:row>
</.selectable_table>
"""
use Phoenix.Component
@box "peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs " <>
"transition-shadow outline-none " <>
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:cursor-not-allowed disabled:opacity-50 " <>
"checked:border-primary checked:bg-primary " <>
"indeterminate:border-primary indeterminate:bg-primary " <>
"dark:bg-input/30"
attr :aria_label, :string, default: nil
attr :aria_labelledby, :string, default: nil
attr :message, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global, include: ~w(hx-target hx-swap id)
slot :actions do
attr :label, :string
end
slot :column, required: true do
attr :label, :string
end
slot :row, required: true do
attr :value, :string, required: true
attr :aria_label, :string
attr :checked, :boolean
end
slot :cell
def selectable_table(assigns) do
assigns = assign(assigns, box: @box)
~H"""
<%!-- htmx v4 inheritance is explicit, so the form carries no
hx-target/hx-swap — each .bulk_action targets the closest form. --%>
<form
data-slot="selectable-table"
aria-label={@aria_label}
aria-labelledby={@aria_labelledby}
class={["group/selectable-table w-full space-y-3", @class]}
{@rest}
>
<div
:for={actions <- @actions}
data-slot="selectable-table-actions"
class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex"
>
<span :if={actions[:label]} class="mr-1 text-xs font-medium text-muted-foreground">
{actions.label}
</span>
{render_slot(actions)}
</div>
<div class="relative w-full overflow-auto rounded-md border">
<table data-slot="selectable-table-content" class="w-full caption-bottom text-sm">
<thead data-slot="selectable-table-header" class="[&_tr]:border-b">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50">
<th
scope="col"
data-slot="selectable-table-head"
class="h-10 w-10 px-3 text-left align-middle font-medium text-muted-foreground"
>
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input
type="checkbox"
data-slot="selectable-table-select-all"
aria-label="Select all rows"
class={@box}
/>
<.check_icon />
</span>
</th>
<th
:for={col <- @column}
scope="col"
data-slot="selectable-table-head"
class="h-10 px-3 text-left align-middle font-medium text-muted-foreground"
>
{col[:label]}
</th>
</tr>
</thead>
<tbody data-slot="selectable-table-body" class="[&_tr:last-child]:border-0">
<tr
:for={r <- @row}
data-slot="selectable-table-row"
class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted"
>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input
type="checkbox"
name="selected"
value={r.value}
checked={r[:checked]}
data-slot="selectable-table-select-row"
aria-label={r[:aria_label] || "Select #{r.value}"}
class={@box}
/>
<.check_icon />
</span>
</td>
{render_slot(r)}
</tr>
</tbody>
</table>
</div>
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground">
{@message}
</output>
</form>
"""
end
@doc "A table cell — use inside a `<:row>` slot's render block."
attr :class, :string, default: nil
slot :inner_block, required: true
def st_cell(assigns) do
~H"""
<td data-slot="selectable-table-cell" class={["px-3 py-2 align-middle", @class]}>
{render_slot(@inner_block)}
</td>
"""
end
@bulk_base "inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors " <>
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:pointer-events-none disabled:opacity-50 " <>
"[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"
attr :variant, :string, default: "default", values: ~w(default destructive)
attr :type, :string, default: "button"
attr :confirm, :string, default: nil
attr :hx_target, :string, default: "closest [data-slot='selectable-table']"
attr :hx_swap, :string, default: "outerHTML"
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global, include: ~w(hx-post hx-get)
slot :inner_block, required: true
def bulk_action(assigns) do
variant_class =
case assigns.variant do
"destructive" -> "border-destructive/40 text-destructive hover:bg-destructive/10"
_ -> "bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
end
assigns = assign(assigns, bulk_base: @bulk_base, variant_class: variant_class)
~H"""
<button
type={@type}
data-slot="selectable-table-action"
hx-confirm={@confirm}
hx-target={@hx_target}
hx-swap={@hx_swap}
disabled={@disabled}
class={[@bulk_base, @variant_class, @class]}
{@rest}
>
{render_slot(@inner_block)}
</button>
"""
end
# Check + indeterminate icons layered over the native input (peer-checked /
# peer-indeterminate reveal). aria-hidden — the input carries the semantics.
defp check_icon(assigns) do
~H"""
<svg
class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="20 6 9 17 4 12" />
</svg>
<svg
class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<form data-slot="selectable-table" aria-label="Users"
class="group/selectable-table w-full space-y-3">
<div data-slot="selectable-table-actions"
class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex">
<span class="mr-1 text-xs font-medium text-muted-foreground">With selected:</span>
<button type="button" data-slot="selectable-table-action" hx-post="/users/activate"
hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="…">Activate</button>
</div>
<!-- <table> with name="selected" row checkboxes -->
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground"></output>
</form>View source
<!--
shadcn-htmx — raw HTML selectable-table snippet.
Mirrors registry/ui/selectable-table.tsx (same elements, roles, data-slots,
classes — only the templating wrapper differs).
A <form>-wrapped <table> with row checkboxes (name="selected"), a header
select-all, a live <output> count, and a contextual bulk-action bar revealed
PURELY in CSS via :has(:checked) — no JS decides visibility. Bulk-action
<button>s sit inside the form, so htmx serialises every checked value on
hx-post; hx-target/hx-swap on the form replace it with the server re-render.
Two pieces still need JS: the header select-all toggle and the running
count. Those are handled by the shared site.js keyed on
data-slot="selectable-table" (it toggles every row box from the header box,
keeps the header in sync incl. indeterminate, and writes the count into the
<output>). The table degrades gracefully without it — every checkbox still
toggles and submits natively; you only lose the convenience toggle + count.
Sources (read, never copied):
repos/htmx/.../patterns/03-records/01-bulk-actions.md
repos/htmx/.../reference/01-attributes/{02-hx-post,07-hx-swap,08-hx-target,22-hx-confirm}.md
repos/mdn/.../web/css/reference/selectors/_colon_has/index.md
repos/mdn/.../web/html/reference/elements/output/index.md
Relies only on the theme tokens in styles.css.
-->
<!-- htmx v4 inheritance is explicit, so the form carries no hx-target/hx-swap;
each bulk-action button targets the closest form itself (see buttons). -->
<form data-slot="selectable-table" aria-label="Users"
class="group/selectable-table w-full space-y-3">
<!-- Bulk-action bar. Hidden until any row checkbox is checked (pure CSS). -->
<div data-slot="selectable-table-actions"
class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex">
<span class="mr-1 text-xs font-medium text-muted-foreground">With selected:</span>
<button type="button" data-slot="selectable-table-action" hx-post="/users/activate" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML"
class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors bg-background text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">Activate</button>
<button type="button" data-slot="selectable-table-action" hx-post="/users/delete" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" hx-confirm="Delete the selected users?"
class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors border-destructive/40 text-destructive hover:bg-destructive/10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">Delete</button>
</div>
<div class="relative w-full overflow-auto rounded-md border">
<table data-slot="selectable-table-content" class="w-full caption-bottom text-sm">
<thead data-slot="selectable-table-header" class="[&_tr]:border-b">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50">
<th scope="col" data-slot="selectable-table-head" class="h-10 w-10 px-3 text-left align-middle font-medium text-muted-foreground">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" data-slot="selectable-table-select-all" aria-label="Select all rows"
class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</span>
</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Name</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Email</th>
</tr>
</thead>
<tbody data-slot="selectable-table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Ada Lovelace"
class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Ada Lovelace</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Grace Hopper"
class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30">
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12" /></svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Grace Hopper</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
</tbody>
</table>
</div>
<!-- Live region: site.js writes the running count here; the server can also
render a result message into it after a bulk POST. -->
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground"></output>
</form>
Examples
Bulk actions
Check rows (or the header box to select all). The action bar slides in via CSS :has(:checked) — no JS toggles it. Each action button hx-posts the checked name="selected" values (htmx serialises the enclosing form) and the server replaces the whole form with the re-rendered table and a result message in the live <output>.
The action bar visibility is entirely CSS: form:has(input[name=selected]:checked) .actions { display: flex }. The bulk buttons live inside the <form>, so htmx submits every checked value with no hx-include plumbing. The select-all toggle and the running count are the only behaviour JavaScript handles, and they degrade gracefully — every checkbox still toggles and submits without it.
<SelectableTable ariaLabel="Users">
<SelectableTableActions label="With selected:">
<BulkAction hx-post="/users/activate">Activate</BulkAction>
<BulkAction hx-post="/users/deactivate">Deactivate</BulkAction>
</SelectableTableActions>
<SelectableTableContent>
<SelectableTableHeader>
<SelectableTableRow>
<SelectableTableHead class="w-10"><SelectAllCheckbox /></SelectableTableHead>
<SelectableTableHead>Name</SelectableTableHead>
<SelectableTableHead>Email</SelectableTableHead>
</SelectableTableRow>
</SelectableTableHeader>
<SelectableTableBody>
{users.map((u) => (
<SelectableTableRow value={u.email}>
<SelectableTableCell><SelectRowCheckbox value={u.email} ariaLabel={`Select ${u.name}`} /></SelectableTableCell>
<SelectableTableCell>{u.name}</SelectableTableCell>
<SelectableTableCell>{u.email}</SelectableTableCell>
</SelectableTableRow>
))}
</SelectableTableBody>
</SelectableTableContent>
<SelectableTableCount />
</SelectableTable>
// Server POST /users/activate reads the repeated "selected" field,
// applies the action, and returns the whole <SelectableTable> again
// with a result message in <SelectableTableCount>.{% call selectable_table_open(aria_label="Users") %}
{% call st_actions_open(label="With selected:") %}
{{ bulk_action(label="Activate", hx_post="/users/activate") }}
{{ bulk_action(label="Deactivate", hx_post="/users/deactivate") }}
{% endcall %}
{% call st_content_open() %}
<thead data-slot="selectable-table-header" class="[&_tr]:border-b">
<tr data-slot="selectable-table-row" class="border-b">
<th class="w-10 …">{{ st_select_all() }}</th>
<th class="…">Name</th><th class="…">Email</th>
</tr>
</thead>
<tbody data-slot="selectable-table-body">
{% for u in users %}
<tr data-slot="selectable-table-row" class="border-b … has-[input[name=selected]:checked]:bg-muted">
<td class="…">{{ st_select_row(value=u.email, aria_label="Select " ~ u.name) }}</td>
<td class="…">{{ u.name }}</td><td class="…">{{ u.email }}</td>
</tr>
{% endfor %}
</tbody>
{% endcall %}
{{ st_count(message) }}
{% endcall %}{{define "user_table"}}
{{template "selectable_table" (dict "AriaLabel" "Users" "Body" (htmlSafe (printf "%s%s%s"
(renderActions) (renderTable .Users) (renderCount .Message))))}}
{{end}}
{{/* Each row checkbox: */}}
{{template "st_select_row" (dict "Value" .Email "AriaLabel" (printf "Select %s" .Name))}}<.selectable_table aria_label="Users" message={@message}>
<:actions label="With selected:">
<.bulk_action hx-post="/users/activate">Activate</.bulk_action>
<.bulk_action hx-post="/users/deactivate">Deactivate</.bulk_action>
</:actions>
<:column label="Name" /><:column label="Email" />
<:row :for={u <- @users} value={u.email} aria_label={"Select #{u.name}"}>
<:cell>{u.name}</:cell>
<:cell>{u.email}</:cell>
</:row>
</.selectable_table><div class="w-full max-w-xl">
<form data-slot="selectable-table" aria-label="Users" class="group/selectable-table w-full space-y-3" data-test="table">
<div data-slot="selectable-table-actions" class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex">
<span class="mr-1 text-xs font-medium text-muted-foreground">With selected:</span>
<button type="button" data-slot="selectable-table-action" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-background text-foreground hover:bg-accent hover:text-accent-foreground" hx-post="/selectable-table/activate" data-test="activate">Activate</button>
<button type="button" data-slot="selectable-table-action" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-background text-foreground hover:bg-accent hover:text-accent-foreground" hx-post="/selectable-table/deactivate" data-test="deactivate">Deactivate</button>
<button type="button" data-slot="selectable-table-action" hx-confirm="Delete the selected users?" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border-destructive/40 text-destructive hover:bg-destructive/10" hx-post="/selectable-table/delete" data-test="delete">Delete</button>
</div>
<div class="relative w-full overflow-auto rounded-md border">
<table data-slot="selectable-table-content" class="w-full caption-bottom text-sm">
<thead data-slot="selectable-table-header" class="[&_tr]:border-b">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-10">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" data-slot="selectable-table-select-all" aria-label="Select all rows" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Name</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Email</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody data-slot="selectable-table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Ada Lovelace" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Ada Lovelace</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-secondary text-secondary-foreground">Active</span>
</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Grace Hopper" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Grace Hopper</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-secondary text-secondary-foreground">Active</span>
</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Alan Turing" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Alan Turing</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground">Inactive</span>
</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Katherine Johnson" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Katherine Johnson</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-secondary text-secondary-foreground">Active</span>
</td>
</tr>
</tbody>
</table>
</div>
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground">
</output>
</form>
</div>Destructive action
Mark a bulk action variant="destructive" for the muted-danger styling, and add confirm="…" to gate it behind the browser's native window.confirm (htmx hx-confirm) so an accidental click can't wipe rows.
A destructive bulk button uses variant="destructive" for the danger styling and confirm="…", which sets hx-confirm. htmx pops the browser's native window.confirm before issuing the POST — zero custom JS.
<SelectableTableActions label="With selected:">
<BulkAction hx-post="/users/activate">Activate</BulkAction>
<BulkAction
hx-post="/users/delete"
variant="destructive"
confirm="Delete the selected users?"
>
Delete
</BulkAction>
</SelectableTableActions>{% call st_actions_open(label="With selected:") %}
{{ bulk_action(label="Activate", hx_post="/users/activate") }}
{{ bulk_action(
label="Delete", hx_post="/users/delete",
variant="destructive", confirm="Delete the selected users?") }}
{% endcall %}{{template "bulk_action" (dict "Label" "Activate" "HxPost" "/users/activate")}}
{{template "bulk_action" (dict
"Label" "Delete" "HxPost" "/users/delete"
"Variant" "destructive" "Confirm" "Delete the selected users?")}}<:actions label="With selected:">
<.bulk_action hx-post="/users/activate">Activate</.bulk_action>
<.bulk_action hx-post="/users/delete" variant="destructive" confirm="Delete the selected users?">
Delete
</.bulk_action>
</:actions><div class="w-full max-w-xl">
<form data-slot="selectable-table" aria-label="Users" class="group/selectable-table w-full space-y-3" data-test="table">
<div data-slot="selectable-table-actions" class="hidden items-center gap-2 rounded-md border bg-muted px-3 py-2 group-has-[input[name=selected]:checked]/selectable-table:flex">
<span class="mr-1 text-xs font-medium text-muted-foreground">With selected:</span>
<button type="button" data-slot="selectable-table-action" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-background text-foreground hover:bg-accent hover:text-accent-foreground" hx-post="/selectable-table/activate" data-test="activate">Activate</button>
<button type="button" data-slot="selectable-table-action" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-background text-foreground hover:bg-accent hover:text-accent-foreground" hx-post="/selectable-table/deactivate" data-test="deactivate">Deactivate</button>
<button type="button" data-slot="selectable-table-action" hx-confirm="Delete the selected users?" hx-target="closest [data-slot='selectable-table']" hx-swap="outerHTML" class="inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-3 text-xs font-medium whitespace-nowrap outline-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border-destructive/40 text-destructive hover:bg-destructive/10" hx-post="/selectable-table/delete" data-test="delete">Delete</button>
</div>
<div class="relative w-full overflow-auto rounded-md border">
<table data-slot="selectable-table-content" class="w-full caption-bottom text-sm">
<thead data-slot="selectable-table-header" class="[&_tr]:border-b">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground w-10">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" data-slot="selectable-table-select-all" aria-label="Select all rows" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Name</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Email</th>
<th scope="col" data-slot="selectable-table-head" class="h-10 px-3 text-left align-middle font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody data-slot="selectable-table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Ada Lovelace" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Ada Lovelace</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-secondary text-secondary-foreground">Active</span>
</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Grace Hopper" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Grace Hopper</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-secondary text-secondary-foreground">Active</span>
</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Alan Turing" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Alan Turing</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-muted text-muted-foreground">Inactive</span>
</td>
</tr>
<tr data-slot="selectable-table-row" class="border-b transition-colors hover:bg-muted/50 has-[input[name=selected]:checked]:bg-muted">
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span class="relative inline-flex size-4 shrink-0 align-middle">
<input type="checkbox" name="selected" value="[email protected]" data-slot="selectable-table-select-row" aria-label="Select Katherine Johnson" class="peer size-4 shrink-0 appearance-none rounded-[4px] border border-input bg-background shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary dark:bg-input/30"/>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-checked:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12">
</polyline>
</svg>
<svg class="pointer-events-none absolute inset-0 m-auto hidden size-3 text-primary-foreground peer-indeterminate:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12">
</line>
</svg>
</span>
</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle font-medium text-foreground">Katherine Johnson</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle text-muted-foreground">[email protected]</td>
<td data-slot="selectable-table-cell" class="px-3 py-2 align-middle">
<span data-test="status" class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-secondary text-secondary-foreground">Active</span>
</td>
</tr>
</tbody>
</table>
</div>
<output data-slot="selectable-table-count" class="block text-sm text-muted-foreground">
</output>
</form>
</div>Further reading
API Reference
<SelectableTable>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaLabel | string | — | Accessible name for the <form> region wrapping the table. Use when there is no visible heading naming the table.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element (e.g. a heading) that names the table. Alternative to ariaLabel.MDNaria-labelledby |
BulkAction hx-post | string | — | Endpoint for a bulk action. The button sits inside the form, so htmx serialises every checked name="selected" value onto the POST. The button defaults hx-target to the closest [data-slot=selectable-table] form and hx-swap to outerHTML, so the response replaces the whole table.htmxhx-post |
BulkAction confirm | string | — | Sets hx-confirm on the button. htmx pops the browser's native window.confirm before issuing the request — used to gate destructive actions.htmxhx-confirm |
BulkAction variant | "default"|"destructive" | "default" | Visual style for the bulk-action button. Use destructive for delete/remove actions. |
SelectRowCheckbox value* | string | — | Identifier submitted for this row when its checkbox is checked. Rendered as <input type="checkbox" name="selected" value>.MDN<input type=checkbox> |
SelectRowCheckbox checked | boolean | false | Pre-check this row on render (e.g. to restore a server-side selection). |
SelectRowCheckbox name | string | "selected" | Form field name for the row checkboxes. Keep the same name across all rows so the checked values submit as one repeated field. |
SelectableTableCount children | Child | — | Result message rendered into the <output> live region after a bulk POST. <output> is an implicit aria-live region, so the message is announced without moving focus. The running selection count ("N selected") is written here by site.js when no server message is present.MDN<output> |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required