shshadcn-htmx

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

2. Use it

components/ui/selectable-table.tsx
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
components/ui/selectable-table.tsx
/** @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

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

components/selectable-table.tmpl
{{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
components/selectable-table.tmpl
{{/* 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

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

snippets/selectable-table.html
<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
snippets/selectable-table.html
<!--
  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.

NameEmailStatus
Ada Lovelace[email protected]Active
Grace Hopper[email protected]Active
Alan Turing[email protected]Inactive
Katherine Johnson[email protected]Active
<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=&#39;selectable-table&#39;]" 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 [&amp;.htmx-request]:pointer-events-none [&amp;.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=&#39;selectable-table&#39;]" 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 [&amp;.htmx-request]:pointer-events-none [&amp;.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=&#39;selectable-table&#39;]" 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 [&amp;.htmx-request]:pointer-events-none [&amp;.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="[&amp;_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="[&amp;_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.

NameEmailStatus
Ada Lovelace[email protected]Active
Grace Hopper[email protected]Active
Alan Turing[email protected]Inactive
Katherine Johnson[email protected]Active
<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=&#39;selectable-table&#39;]" 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 [&amp;.htmx-request]:pointer-events-none [&amp;.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=&#39;selectable-table&#39;]" 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 [&amp;.htmx-request]:pointer-events-none [&amp;.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=&#39;selectable-table&#39;]" 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 [&amp;.htmx-request]:pointer-events-none [&amp;.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="[&amp;_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="[&amp;_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>

PropTypeDefaultDescription
ariaLabelstring
Accessible name for the <form> region wrapping the table. Use when there is no visible heading naming the table.MDNaria-label
ariaLabelledbystring
Id of a visible element (e.g. a heading) that names the table. Alternative to ariaLabel.MDNaria-labelledby
BulkAction hx-poststring
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 confirmstring
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 checkedbooleanfalse
Pre-check this row on render (e.g. to restore a server-side selection).
SelectRowCheckbox namestring"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 childrenChild
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>
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required