shshadcn-htmx

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

2. Use it

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

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

templates/components/table.tmpl
{{template "table" (dict "Body" (htmlSafe `
  …<thead>…<tbody>…`))}}
View source
templates/components/table.tmpl
{{/* 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

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

index.html
<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
index.html
<!--
  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.

NameRoleJoined
Ada LovelaceOwner2024-01-15
Grace HopperAdmin2024-03-02
Hedy LamarrEditor2024-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="[&amp;_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="[&amp;_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>

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 LovelaceOwner2024-01-15
Grace HopperAdmin2024-03-02
Hedy LamarrEditor2024-08-21
Katherine JohnsonViewer2025-02-19
Margaret HamiltonEditor2024-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="[&amp;_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&amp;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&amp;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&amp;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="[&amp;_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>

API Reference

<Table>

PropTypeDefaultDescription
TableHead · colspannumber
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 · rowspannumber
Number of rows the header cell spans. Maps to the native <th> rowspan attribute. 0 spans to the end of the row group.
TableHead · idstring
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 · headersstring
Space-separated list of header cell ids this header is associated with. Used for multi-level headers where scope alone is insufficient.
TableHead · abbrstring
Short alternative label assistive technologies may announce in place of a verbose column title. Native <th> abbr attribute (not for data cells).
TableCell · rowspannumber
Number of rows the data cell spans. Maps to the native <td> rowspan attribute. 0 spans to the end of the row group.
TableCell · idstring
id for the data cell, so other cells can reference it via their headers attribute.
TableCell · headersstring
Space-separated list of header cell ids that head this data cell. Use for complex tables where scope alone cannot convey the associations.
wrapperClassstring
Tailwind classes appended to the overflow-x wrapper.
classstring
Extra Tailwind classes appended to the root element.