Components
Table
Semantic <table> with <thead> / <tbody> and column headers carrying scope="col". Sortable columns advertise their state via aria-sort and route sort actions through htmx.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/table.json2. Use it
import { Table, TableHeader, TableBody, TableRow,
TableHead, TableCell } from "@/components/ui/table"
<Table>
<TableHeader>
<TableRow>
<TableHead sort="ascending">Name</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow><TableCell>Ada</TableCell><TableCell>Owner</TableCell></TableRow>
<TableRow><TableCell>Grace</TableCell><TableCell>Admin</TableCell></TableRow>
</TableBody>
</Table>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Table — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Pure semantic <table>/<thead>/<tbody>/<tr>/<th scope="col">/<td>. We
// don't reach for ARIA grid roles — the native table model is correct
// for tabular data and AT users get column/row navigation for free.
//
// Sortable columns:
// - <th> carries aria-sort="none" | "ascending" | "descending"
// - The sort affordance is a real <button> inside the <th> so it's
// keyboard-activatable (Enter / Space) — not the whole <th>.
// - The button can carry htmx attrs (hx-get) to re-fetch the body.
//
// Refs:
// repos/mdn/files/en-us/web/html/reference/elements/table/index.md
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-sort/
type Sort = "none" | "ascending" | "descending"
type TableProps = PropsWithChildren<{
class?: ClassValue
// Wrapper allows horizontal scroll on small screens.
wrapperClass?: ClassValue
}>
export function Table(props: TableProps) {
return (
<div class={cn("relative w-full overflow-auto", props.wrapperClass)}>
<table
data-slot="table"
class={cn("w-full caption-bottom text-sm", props.class)}
>
{props.children}
</table>
</div>
)
}
export function TableHeader(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<thead
data-slot="table-header"
class={cn("[&_tr]:border-b", props.class)}
>
{props.children}
</thead>
)
}
export function TableBody(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<tbody
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", props.class)}
>
{props.children}
</tbody>
)
}
export function TableFooter(props: PropsWithChildren<{ class?: ClassValue }>) {
return (
<tfoot
data-slot="table-footer"
class={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
props.class,
)}
>
{props.children}
</tfoot>
)
}
export function TableRow(
props: PropsWithChildren<
{ class?: ClassValue } & Record<`hx-${string}`, any>
>,
) {
const { children, class: className, ...rest } = props
return (
<tr
data-slot="table-row"
class={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...rest}
>
{children}
</tr>
)
}
type TableHeadProps = PropsWithChildren<{
class?: ClassValue
scope?: "col" | "row" | "colgroup" | "rowgroup"
// Sort state. Omit for non-sortable columns.
sort?: Sort
// Native <th> spanning + association attrs (MDN: web/html/reference/elements/th).
colspan?: number
rowspan?: number
// id / headers let complex tables explicitly associate cells with headers
// (MDN th: "Associate header cells with other header cells").
id?: string
headers?: string
// Short spoken label AT announces in place of verbose header text
// (MDN th: abbr — non-deprecated on <th>).
abbr?: string
// htmx attrs for the sort button (e.g. hx-get to re-fetch with new sort).
[key: `hx-${string}`]: any
// Click handler — used when sort button needs custom JS instead of htmx.
onclick?: string
}>
export function TableHead(props: TableHeadProps) {
const {
children,
class: className,
scope = "col",
sort,
onclick,
colspan,
rowspan,
id,
headers,
abbr,
...rest
} = props
const sortable = sort !== undefined
if (!sortable) {
return (
<th
scope={scope}
colspan={colspan}
rowspan={rowspan}
id={id}
headers={headers}
abbr={abbr}
data-slot="table-head"
class={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
className,
)}
>
{children}
</th>
)
}
return (
<th
scope={scope}
colspan={colspan}
rowspan={rowspan}
id={id}
headers={headers}
abbr={abbr}
data-slot="table-head"
data-sortable="true"
aria-sort={sort}
class={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
className,
)}
>
<button
type="button"
onclick={onclick}
class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
{...rest}
>
{children}
{sort === "ascending" && (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
<polyline points="18 15 12 9 6 15" />
</svg>
)}
{sort === "descending" && (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
)}
{sort === "none" && (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 opacity-30" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
)}
</button>
</th>
)
}
export function TableCell(
props: PropsWithChildren<{
class?: ClassValue
colspan?: number
// Native <td> spanning + association attrs (MDN: web/html/reference/elements/td).
rowspan?: number
scope?: "row"
id?: string
// headers: space-separated list of header cell ids for complex tables
// (MDN td: "Associate data cells with header cells").
headers?: string
}>,
) {
return (
<td
colspan={props.colspan}
rowspan={props.rowspan}
scope={props.scope}
id={props.id}
headers={props.headers}
data-slot="table-cell"
class={cn("p-2 align-middle", props.class)}
>
{props.children}
</td>
)
}
export function TableCaption(
props: PropsWithChildren<{ class?: ClassValue }>,
) {
return (
<caption
data-slot="table-caption"
class={cn("mt-4 text-sm text-muted-foreground", props.class)}
>
{props.children}
</caption>
)
}
1. Save the file
Copy table.html into templates/components/.
2. Use it
{% from "components/table.html" import table_open, table_close,
thead_open, thead_close, tbody_open, tbody_close, tr_open, tr_close, th, td %}
{{ table_open() }}
{{ thead_open() }}{{ tr_open() }}{{ th("Name", sort="ascending") }}{{ th("Role") }}{{ tr_close() }}{{ thead_close() }}
{{ tbody_open() }}{{ tr_open() }}{{ td("Ada") }}{{ td("Owner") }}{{ tr_close() }}{{ tbody_close() }}
{{ table_close() }}View source
{# Table macros — shadcn-htmx, htmx v4 + Tailwind v4.
Semantic <table> with sortable column buttons. #}
{% macro table_open(extra_class="") %}
<div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm {{ extra_class }}">
{% endmacro %}
{% macro table_close() %}</table></div>{% endmacro %}
{% macro thead_open() %}<thead data-slot="table-header" class="[&_tr]:border-b">{% endmacro %}
{% macro thead_close() %}</thead>{% endmacro %}
{% macro tbody_open() %}<tbody data-slot="table-body" class="[&_tr:last-child]:border-0">{% endmacro %}
{% macro tbody_close() %}</tbody>{% endmacro %}
{% macro tr_open(extra_class="") %}<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted {{ extra_class }}">{% endmacro %}
{% macro tr_close() %}</tr>{% endmacro %}
{# colspan/rowspan: native <th> spanning attrs (MDN web/html/reference/elements/th).
id/headers: explicit header association for complex tables. abbr: short spoken
label AT announces in place of verbose header text (non-deprecated on <th>). #}
{% macro th(label, scope="col", sort=none, extra_class="", colspan=none, rowspan=none, id=none, headers=none, abbr=none, **attrs) %}
{%- set base = "h-10 px-2 text-left align-middle font-medium text-muted-foreground" -%}
{% if sort is none %}
<th scope="{{ scope }}"
{%- if colspan %} colspan="{{ colspan }}"{% endif %}{% if rowspan %} rowspan="{{ rowspan }}"{% endif %}{% if id %} id="{{ id }}"{% endif %}{% if headers %} headers="{{ headers }}"{% endif %}{% if abbr %} abbr="{{ abbr }}"{% endif %} data-slot="table-head" class="{{ base }} {{ extra_class }}">{{ label|safe }}</th>
{% else %}
<th scope="{{ scope }}"
{%- if colspan %} colspan="{{ colspan }}"{% endif %}{% if rowspan %} rowspan="{{ rowspan }}"{% endif %}{% if id %} id="{{ id }}"{% endif %}{% if headers %} headers="{{ headers }}"{% endif %}{% if abbr %} abbr="{{ abbr }}"{% endif %} data-slot="table-head" data-sortable="true" aria-sort="{{ sort }}" class="{{ base }} {{ extra_class }}">
<button type="button" class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label|safe }}{% if sort == "ascending" %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true"><polyline points="18 15 12 9 6 15" /></svg>{% elif sort == "descending" %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true"><polyline points="6 9 12 15 18 9" /></svg>{% elif sort == "none" %}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 opacity-30" aria-hidden="true"><polyline points="6 9 12 15 18 9" /></svg>{% endif %}</button>
</th>
{% endif %}
{% endmacro %}
{# rowspan: native <td> spanning attr (MDN web/html/reference/elements/td).
id/headers: explicit header association for complex tables. #}
{% macro td(content, colspan=none, rowspan=none, id=none, headers=none, extra_class="") %}
<td {% if colspan %}colspan="{{ colspan }}"{% endif %}{% if rowspan %} rowspan="{{ rowspan }}"{% endif %}{% if id %} id="{{ id }}"{% endif %}{% if headers %} headers="{{ headers }}"{% endif %} data-slot="table-cell" class="p-2 align-middle {{ extra_class }}">{{ content|safe }}</td>
{% endmacro %}
1. Save the file
Add table.tmpl alongside button.tmpl.
2. Use it
{{template "table" (dict "Body" (htmlSafe `
…<thead>…<tbody>…`))}}View source
{{/* Table templates — shadcn-htmx, htmx v4 + Tailwind v4. */}}
{{define "table"}}
<div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm">{{.Body}}</table>
</div>
{{end}}
{{/* Native <th> attrs — colspan/rowspan (spanning), id/headers (explicit header
association for complex tables), abbr (short spoken label, non-deprecated on
<th>). MDN web/html/reference/elements/th. Emitted inline so html/template's
context-aware autoescaper treats each as a real HTML attribute. */}}
{{define "table_head"}}
{{- $base := "h-10 px-2 text-left align-middle font-medium text-muted-foreground" -}}
{{if .Sort}}
<th scope="col"{{if .Colspan}} colspan="{{.Colspan}}"{{end}}{{if .Rowspan}} rowspan="{{.Rowspan}}"{{end}}{{if .Id}} id="{{.Id}}"{{end}}{{if .Headers}} headers="{{.Headers}}"{{end}}{{if .Abbr}} abbr="{{.Abbr}}"{{end}} data-slot="table-head" data-sortable="true" aria-sort="{{.Sort}}" class="{{$base}}">
<button type="button" class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" hx-get="{{.HxGet}}" hx-target="{{.HxTarget}}" hx-swap="outerHTML">{{.Label}}{{if eq .Sort "ascending"}}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>{{else if eq .Sort "descending"}}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>{{else}}<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 opacity-30" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>{{end}}</button>
</th>
{{else}}
<th scope="col"{{if .Colspan}} colspan="{{.Colspan}}"{{end}}{{if .Rowspan}} rowspan="{{.Rowspan}}"{{end}}{{if .Id}} id="{{.Id}}"{{end}}{{if .Headers}} headers="{{.Headers}}"{{end}}{{if .Abbr}} abbr="{{.Abbr}}"{{end}} data-slot="table-head" class="{{$base}}">{{.Label}}</th>
{{end}}
{{end}}
1. Save the file
Drop table.ex into lib/my_app_web/components/.
2. Use it
<.table>
<.table_header>
<.table_row>
<.table_head sort="ascending">Name</.table_head>
<.table_head>Role</.table_head>
</.table_row>
</.table_header>
<.table_body>
<.table_row><.table_cell>Ada</.table_cell><.table_cell>Owner</.table_cell></.table_row>
</.table_body>
</.table>View source
defmodule ShadcnHtmx.Components.Table do
@moduledoc """
Table — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Semantic `<table>` / `<thead>` / `<tbody>` etc. Sortable columns
carry `aria-sort` and expose a clickable button inside the `<th>`.
"""
use Phoenix.Component
attr :class, :string, default: nil
attr :wrapper_class, :string, default: nil
slot :inner_block, required: true
def table(assigns) do
~H"""
<div class={["relative w-full overflow-auto", @wrapper_class]}>
<table data-slot="table" class={["w-full caption-bottom text-sm", @class]}>
{render_slot(@inner_block)}
</table>
</div>
"""
end
slot :inner_block, required: true
def table_header(assigns), do: ~H"<thead data-slot=\"table-header\" class=\"[&_tr]:border-b\">{render_slot(@inner_block)}</thead>"
slot :inner_block, required: true
def table_body(assigns), do: ~H"<tbody data-slot=\"table-body\" class=\"[&_tr:last-child]:border-0\">{render_slot(@inner_block)}</tbody>"
slot :inner_block, required: true
attr :class, :string, default: nil
def table_row(assigns) do
~H"""
<tr data-slot="table-row" class={["border-b transition-colors hover:bg-muted/50", @class]}>
{render_slot(@inner_block)}
</tr>
"""
end
attr :scope, :string, default: "col"
attr :sort, :string, default: nil, values: [nil, "none", "ascending", "descending"]
attr :class, :string, default: nil
# colspan/rowspan: native <th> spanning attrs (MDN web/html/reference/elements/th).
attr :colspan, :integer, default: nil
attr :rowspan, :integer, default: nil
# id/headers: explicit header association for complex tables.
attr :id, :string, default: nil
attr :headers, :string, default: nil
# abbr: short spoken label AT announces in place of verbose header text
# (non-deprecated on <th>).
attr :abbr, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def table_head(assigns) do
base = "h-10 px-2 text-left align-middle font-medium text-muted-foreground"
assigns = assign(assigns, base: base)
if assigns.sort do
~H"""
<th
scope={@scope}
colspan={@colspan}
rowspan={@rowspan}
id={@id}
headers={@headers}
abbr={@abbr}
data-slot="table-head"
data-sortable="true"
aria-sort={@sort}
class={[@base, @class]}
>
<button
type="button"
class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
{@rest}
>
{render_slot(@inner_block)}
<svg :if={@sort == "ascending"} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
<polyline points="18 15 12 9 6 15" />
</svg>
<svg :if={@sort == "descending"} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
<svg :if={@sort == "none"} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 opacity-30" aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</th>
"""
else
~H"""
<th
scope={@scope}
colspan={@colspan}
rowspan={@rowspan}
id={@id}
headers={@headers}
abbr={@abbr}
data-slot="table-head"
class={[@base, @class]}
>
{render_slot(@inner_block)}
</th>
"""
end
end
attr :colspan, :integer, default: nil
# rowspan: native <td> spanning attr (MDN web/html/reference/elements/td).
attr :rowspan, :integer, default: nil
# id/headers: explicit header association for complex tables.
attr :id, :string, default: nil
attr :headers, :string, default: nil
attr :class, :string, default: nil
slot :inner_block, required: true
def table_cell(assigns) do
~H"""
<td
colspan={@colspan}
rowspan={@rowspan}
id={@id}
headers={@headers}
data-slot="table-cell"
class={["p-2 align-middle", @class]}
>
{render_slot(@inner_block)}
</td>
"""
end
end
1. Save the file
Tailwind utilities only; sort button uses htmx.
2. Use it
<table data-slot="table" class="w-full text-sm">
<thead><tr>
<th scope="col" data-sortable="true" aria-sort="ascending"><button>Name ↑</button></th>
<th scope="col">Role</th>
</tr></thead>
<tbody>
<tr><td>Ada</td><td>Owner</td></tr>
</tbody>
</table>View source
<!--
shadcn-htmx — raw HTML table snippet.
Semantic <table> with <thead>/<tbody>; sortable column has aria-sort
and a clickable button inside the <th>.
Native <th>/<td> attributes you can add as needed (no extra markup required):
- colspan / rowspan : span cells across columns/rows for grouped or merged
headers and merged data cells (MDN web/html/reference/elements/th & td).
- id + headers : for complex tables where scope alone is insufficient,
give each header an id and list them (space-separated) in each cell's
headers attribute to explicitly associate data cells with their headers
(MDN: "Associate data cells with header cells").
- abbr (on <th>) : a short label assistive tech announces in place of a
verbose column title (non-deprecated on <th>; do not use on <td>).
-->
<div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm">
<thead data-slot="table-header" class="[&_tr]:border-b">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th scope="col" data-slot="table-head" data-sortable="true" aria-sort="ascending"
class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">
<button type="button" class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground"
hx-get="/articles?sort=name&dir=desc" hx-target="closest table" hx-swap="outerHTML">
Name
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="size-3.5" aria-hidden="true">
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Role</th>
</tr>
</thead>
<tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Ada Lovelace</td>
<td data-slot="table-cell" class="p-2 align-middle">Owner</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Grace Hopper</td>
<td data-slot="table-cell" class="p-2 align-middle">Admin</td>
</tr>
</tbody>
</table>
</div>
Examples
Basic — semantic table
<thead>, <tbody>, <th scope="col">. AT users get native column/row navigation.
Don't reach for ARIA role="grid" unless you actually need spreadsheet-style cell focus. For read-only tabular data, the native model is correct and cheaper. scope="col" on each <th> is enough.
| Name | Role | Joined |
|---|---|---|
| Ada Lovelace | Owner | 2024-01-15 |
| Grace Hopper | Admin | 2024-03-02 |
| Hedy Lamarr | Editor | 2024-08-21 |
import { Table, TableHeader, TableBody, TableRow,
TableHead, TableCell } from "@/components/ui/table"
<Table>
<TableHeader>
<TableRow>
<TableHead sort="ascending">Name</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow><TableCell>Ada</TableCell><TableCell>Owner</TableCell></TableRow>
<TableRow><TableCell>Grace</TableCell><TableCell>Admin</TableCell></TableRow>
</TableBody>
</Table>{% from "components/table.html" import table_open, table_close,
thead_open, thead_close, tbody_open, tbody_close, tr_open, tr_close, th, td %}
{{ table_open() }}
{{ thead_open() }}{{ tr_open() }}{{ th("Name", sort="ascending") }}{{ th("Role") }}{{ tr_close() }}{{ thead_close() }}
{{ tbody_open() }}{{ tr_open() }}{{ td("Ada") }}{{ td("Owner") }}{{ tr_close() }}{{ tbody_close() }}
{{ table_close() }}{{template "table" (dict "Body" (htmlSafe `
…<thead>…<tbody>…`))}}<.table>
<.table_header>
<.table_row>
<.table_head sort="ascending">Name</.table_head>
<.table_head>Role</.table_head>
</.table_row>
</.table_header>
<.table_body>
<.table_row><.table_cell>Ada</.table_cell><.table_cell>Owner</.table_cell></.table_row>
</.table_body>
</.table><div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm">
<thead data-slot="table-header" class="[&_tr]:border-b">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Name</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Role</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Joined</th>
</tr>
</thead>
<tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Ada Lovelace</td>
<td data-slot="table-cell" class="p-2 align-middle">Owner</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-01-15</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Grace Hopper</td>
<td data-slot="table-cell" class="p-2 align-middle">Admin</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-03-02</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Hedy Lamarr</td>
<td data-slot="table-cell" class="p-2 align-middle">Editor</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-08-21</td>
</tr>
</tbody>
</table>
</div>Further reading
Sortable — htmx round-trip
Click a header to sort. Each click POSTs the next sort state; server returns the re-sorted table; htmx swaps outerHTML.
No client state machine — the server is the source of truth. Each header's hx-get carries the next sort direction in the query string. After swap, aria-sort reflects the new state automatically because the server renders it. AT users hear the announcement on the next focus.
| Ada Lovelace | Owner | 2024-01-15 |
| Grace Hopper | Admin | 2024-03-02 |
| Hedy Lamarr | Editor | 2024-08-21 |
| Katherine Johnson | Viewer | 2025-02-19 |
| Margaret Hamilton | Editor | 2024-11-08 |
<TableHead
sort={field === activeSort ? dir : "none"}
hx-get={`/api/users?sort=${field}&dir=${nextDir}`}
hx-target="closest table"
hx-swap="outerHTML"
>
Name
</TableHead>{{ th("Name", sort="ascending",
hx_get="/api/users?sort=name&dir=descending",
hx_target="closest table", hx_swap="outerHTML") }}{{template "table_head" (dict "Label" "Name" "Sort" "ascending"
"HxGet" "/api/users?sort=name&dir=descending" "HxTarget" "closest table")}}<.table_head sort="ascending"
hx-get={~p"/api/users?sort=name&dir=descending"}
hx-target="closest table" hx-swap="outerHTML">
Name
</.table_head><div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm">
<thead data-slot="table-header" class="[&_tr]:border-b">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th scope="col" data-slot="table-head" data-sortable="true" aria-sort="ascending" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">
<button type="button" class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" hx-get="/table/sort?field=name&dir=descending" hx-target="closest table" hx-swap="outerHTML">
Name
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5" aria-hidden="true">
<polyline points="18 15 12 9 6 15">
</polyline>
</svg>
</button>
</th>
<th scope="col" data-slot="table-head" data-sortable="true" aria-sort="none" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">
<button type="button" class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" hx-get="/table/sort?field=role&dir=ascending" hx-target="closest table" hx-swap="outerHTML">
Role
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 opacity-30" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</button>
</th>
<th scope="col" data-slot="table-head" data-sortable="true" aria-sort="none" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">
<button type="button" class="inline-flex h-7 items-center gap-1.5 rounded-md px-2 -ml-2 hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" hx-get="/table/sort?field=joined&dir=ascending" hx-target="closest table" hx-swap="outerHTML">
Joined
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 opacity-30" aria-hidden="true">
<polyline points="6 9 12 15 18 9">
</polyline>
</svg>
</button>
</th>
</tr>
</thead>
<tbody data-slot="table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Ada Lovelace</td>
<td data-slot="table-cell" class="p-2 align-middle">Owner</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-01-15</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Grace Hopper</td>
<td data-slot="table-cell" class="p-2 align-middle">Admin</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-03-02</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Hedy Lamarr</td>
<td data-slot="table-cell" class="p-2 align-middle">Editor</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-08-21</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Katherine Johnson</td>
<td data-slot="table-cell" class="p-2 align-middle">Viewer</td>
<td data-slot="table-cell" class="p-2 align-middle">2025-02-19</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle">Margaret Hamilton</td>
<td data-slot="table-cell" class="p-2 align-middle">Editor</td>
<td data-slot="table-cell" class="p-2 align-middle">2024-11-08</td>
</tr>
</tbody>
</table>
</div>Further reading
API Reference
<Table>
| Prop | Type | Default | Description |
|---|---|---|---|
TableHead · colspan | number | — | Number of columns the header cell spans. Maps to the native <th> colspan attribute. Use for grouped column headers (e.g. a header spanning two sub-columns). |
TableHead · rowspan | number | — | Number of rows the header cell spans. Maps to the native <th> rowspan attribute. 0 spans to the end of the row group. |
TableHead · id | string | — | id for the header cell. Reference it from a cell's headers attribute to explicitly associate data cells with their headers in complex tables. |
TableHead · headers | string | — | Space-separated list of header cell ids this header is associated with. Used for multi-level headers where scope alone is insufficient. |
TableHead · abbr | string | — | Short alternative label assistive technologies may announce in place of a verbose column title. Native <th> abbr attribute (not for data cells). |
TableCell · rowspan | number | — | Number of rows the data cell spans. Maps to the native <td> rowspan attribute. 0 spans to the end of the row group. |
TableCell · id | string | — | id for the data cell, so other cells can reference it via their headers attribute. |
TableCell · headers | string | — | Space-separated list of header cell ids that head this data cell. Use for complex tables where scope alone cannot convey the associations. |
wrapperClass | string | — | Tailwind classes appended to the overflow-x wrapper. |
class | string | — | Extra Tailwind classes appended to the root element. |