shshadcn-htmx

Components

grid

An interactive data grid: <table role="grid"> that is a single tab stop with 2-D arrow-key cell navigation (roving tabindex). Use it instead of Table when you want spreadsheet-style cell focus and a shorter tab sequence.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/grid.json

2. Use it

components/ui/grid.tsx
import { Grid, GridHeader, GridBody, GridRow,
  GridColumnHeader, GridRowHeader, GridCell } from "@/components/ui/grid"

<Grid ariaLabel="Transactions">
  <GridHeader>
    <GridRow>
      <GridColumnHeader sort="ascending">Name</GridColumnHeader>
      <GridColumnHeader>Amount</GridColumnHeader>
    </GridRow>
  </GridHeader>
  <GridBody>
    <GridRow>
      <GridRowHeader>Ada Lovelace</GridRowHeader>
      <GridCell>$120.00</GridCell>
    </GridRow>
  </GridBody>
</Grid>
Or copy the source manually
components/ui/grid.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Grid — shadcn-htmx, htmx v4 + Tailwind v4.
//
// An INTERACTIVE data grid: a single tab stop with 2-D arrow-key cell
// navigation (roving tabindex). This is deliberately distinct from the
// static <Table> component — Table is the right choice for read-only
// tabular data (every focusable link/button stays in the tab sequence and
// AT gets native row/column navigation). Reach for Grid only when you want
// spreadsheet-style cell focus and a SHORTER tab sequence (the whole grid
// is one tab stop). See the APG comparison of the two patterns.
//
// shadcn/ui has no Grid component — this maps to the WAI-ARIA APG Grid
// pattern, built on a real <table> so we inherit the semantic table model
// and only layer the grid roles + roving tabindex on top.
//
// Refs (read before editing):
//   repos/aria-practices/content/patterns/grid/grid-pattern.html
//     — keyboard contract + roles/states. Arrow keys move one cell; Home/End
//       jump to row ends; Ctrl+Home/End jump to the grid's first/last cell.
//   repos/aria-practices/content/patterns/grid/examples/js/dataGrid.js
//     — the reference roving-tabindex implementation we model (one cell at
//       tabindex="0", the rest at -1; setFocusPointer rolls the 0).
//   repos/mdn/files/en-us/web/html/reference/elements/table/index.md
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/grid_role/index.md
//
// The ARIA contract:
//   - The container is a <table role="grid"> with an accessible name
//     (aria-label or aria-labelledby). role="grid" switches screen readers
//     into application mode so the arrow-key contract is exposed.
//   - Native <tr> carries the implicit role="row"; <th scope="col"> the
//     implicit role="columnheader"; <td> the implicit role="gridcell". We
//     keep the native elements and do NOT add aria-rowspan/colspan — per the
//     APG note, a grid built from a <table> must use HTML rowspan/colspan.
//   - Every focusable cell is marked [data-grid-cell] so the keyboard layer
//     can build its 2-D map. Exactly one carries tabindex="0"; the rest -1.
//   - If a cell contains a single interactive widget (link/button), grid
//     navigation focuses that widget directly (APG "focus an element inside
//     the cell"); otherwise it focuses the cell itself. We expose that via
//     <GridCell as="a"> / interactive children — the cell stays the
//     [data-grid-cell] hook and is the thing that gets tabindex.
//   - aria-sort lives on a header cell when the column is sortable (the sort
//     control routes through htmx, exactly like <Table>).
//
// A tiny inline boot <script> sets the initial roving tabindex before paint
// (no flash of all-tabbable cells); public/site.js (keyed on
// data-slot="grid") owns the live arrow/Home/End/Ctrl+Home/End contract.

export type GridSort = "none" | "ascending" | "descending"

const gridBase = "w-full caption-bottom border-separate border-spacing-0 text-sm"

// Cells get a focus ring on the cell itself (roving tabindex lands here).
const cellBase =
  "border-b border-r px-3 py-2 align-middle outline-none " +
  "focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 " +
  // The roving-tabindex owner reads as the active cell even before focus.
  "data-[grid-active=true]:bg-muted/50"

const headBase =
  cellBase +
  " border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md"

const dataCellBase = cellBase + " bg-background text-foreground"

type GridProps = PropsWithChildren<{
  // APG: a grid MUST have an accessible name. Provide one of these.
  ariaLabel?: string
  ariaLabelledby?: string
  // Optional caption/description element id (announced after the name).
  ariaDescribedby?: string
  // Total counts when rows/cols are virtualised or not all in the DOM.
  ariaRowcount?: number
  ariaColcount?: number
  // Editing is disabled across the whole grid (read-only data grid).
  ariaReadonly?: boolean
  // The grid supports selecting more than one cell/row. Pair with the
  // `selected` props on GridRow/GridCell; selectable-but-unselected nodes
  // should then carry aria-selected="false" so AT advertises selectability.
  // aria-multiselectable: w3c.github.io/aria/#aria-multiselectable
  ariaMultiselectable?: boolean
  class?: ClassValue
  wrapperClass?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function Grid(props: GridProps) {
  const {
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaRowcount,
    ariaColcount,
    ariaReadonly,
    ariaMultiselectable,
    class: className,
    wrapperClass,
    children,
    ...rest
  } = props as any
  // Boot script: roll the roving tabindex to the FIRST focusable cell before
  // paint, so the grid is a single tab stop immediately (no flash of every
  // cell being tabbable). Models dataGrid.js setFocusPointer(0,0).
  const boot = `(function(el){
    var cells = el.querySelectorAll('[data-grid-cell]');
    cells.forEach(function(c,i){ c.setAttribute('tabindex', i===0 ? '0' : '-1'); });
    if (cells.length) cells[0].setAttribute('data-grid-active','true');
    el.setAttribute('data-grid-ready','true');
  })(document.currentScript.previousElementSibling);`
  return (
    <div class={cn("relative w-full overflow-auto rounded-md border", wrapperClass)}>
      <table
        role="grid"
        data-slot="grid"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        aria-rowcount={ariaRowcount}
        aria-colcount={ariaColcount}
        aria-readonly={ariaReadonly ? "true" : undefined}
        aria-multiselectable={ariaMultiselectable ? "true" : undefined}
        class={cn(gridBase, className)}
        {...rest}
      >
        {children}
      </table>
      <script
        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
        dangerouslySetInnerHTML={{ __html: boot }}
      />
    </div>
  )
}

export function GridHeader(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <thead data-slot="grid-header" class={cn(props.class)}>
      {props.children}
    </thead>
  )
}

export function GridBody(props: PropsWithChildren<{ class?: ClassValue }>) {
  return (
    <tbody data-slot="grid-body" class={cn(props.class)}>
      {props.children}
    </tbody>
  )
}

type GridRowProps = PropsWithChildren<{
  class?: ClassValue
  // 1-based row position when not all rows are in the DOM (virtualised).
  ariaRowindex?: number
  // 1-based column index of the row's first cell when the visible columns are
  // contiguous: set aria-colindex ONCE on the row and browsers compute each
  // cell's column number (preferred over per-cell when columns are contiguous).
  // aria-colindex: w3c.github.io/aria/#aria-colindex
  ariaColindex?: number
  selected?: boolean
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
}>

export function GridRow(props: GridRowProps) {
  const { children, class: className, ariaRowindex, ariaColindex, selected, ...rest } = props as any
  return (
    <tr
      data-slot="grid-row"
      aria-rowindex={ariaRowindex}
      aria-colindex={ariaColindex}
      aria-selected={selected ? "true" : undefined}
      class={cn("transition-colors", className)}
      {...rest}
    >
      {children}
    </tr>
  )
}

type GridColumnHeaderProps = PropsWithChildren<{
  class?: ClassValue
  // Sort state. Omit for non-sortable columns.
  sort?: GridSort
  // 1-based column position when columns are virtualised.
  ariaColindex?: number
  // htmx attrs ride onto the header cell (e.g. hx-get to re-fetch sorted).
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
}>

// A column header. It is focusable (a [data-grid-cell]) so screen-reader
// users in application mode can reach it with the arrow keys — APG: header
// cells should be focusable when they provide functions like sort.
export function GridColumnHeader(props: GridColumnHeaderProps) {
  const { children, class: className, sort, ariaColindex, ...rest } = props as any
  const sortable = sort !== undefined
  return (
    <th
      scope="col"
      data-slot="grid-columnheader"
      data-grid-cell=""
      data-sortable={sortable ? "true" : undefined}
      aria-sort={sortable ? sort : undefined}
      aria-colindex={ariaColindex}
      class={cn(headBase, className)}
      {...rest}
    >
      <span class="inline-flex items-center gap-1.5">
        {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>
        )}
      </span>
    </th>
  )
}

type GridRowHeaderProps = PropsWithChildren<{
  class?: ClassValue
  ariaColindex?: number
  [key: `data-${string}`]: any
}>

// A row header (scope="row") — title information for the row, focusable.
export function GridRowHeader(props: GridRowHeaderProps) {
  const { children, class: className, ariaColindex, ...rest } = props as any
  return (
    <th
      scope="row"
      data-slot="grid-rowheader"
      data-grid-cell=""
      aria-colindex={ariaColindex}
      class={cn(headBase, "font-medium text-foreground", className)}
      {...rest}
    >
      {children}
    </th>
  )
}

type GridCellProps = PropsWithChildren<{
  class?: ClassValue
  ariaColindex?: number
  selected?: boolean
  // Editing disabled for this specific cell.
  ariaReadonly?: boolean
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
}>

export function GridCell(props: GridCellProps) {
  const { children, class: className, ariaColindex, selected, ariaReadonly, ...rest } =
    props as any
  return (
    <td
      data-slot="grid-cell"
      data-grid-cell=""
      aria-colindex={ariaColindex}
      aria-selected={selected ? "true" : undefined}
      aria-readonly={ariaReadonly ? "true" : undefined}
      class={cn(dataCellBase, className)}
      {...rest}
    >
      {children}
    </td>
  )
}

1. Save the file

Copy grid.html into templates/components/.

2. Use it

templates/components/grid.html
{% from "components/grid.html" import grid_open, grid_close,
   ghead_open, ghead_close, gbody_open, gbody_close, grow_open, grow_close,
   gcolheader, growheader, gcell %}

{{ grid_open(aria_label="Transactions") }}
  {{ ghead_open() }}{{ grow_open() }}{{ gcolheader("Name", sort="ascending") }}{{ gcolheader("Amount") }}{{ grow_close() }}{{ ghead_close() }}
  {{ gbody_open() }}{{ grow_open() }}{{ growheader("Ada Lovelace") }}{{ gcell("$120.00") }}{{ grow_close() }}{{ gbody_close() }}
{{ grid_close() }}
View source
templates/components/grid.html
{# Grid macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/grid.tsx. An INTERACTIVE data grid: <table role="grid">
   that is a SINGLE tab stop with 2-D arrow-key cell navigation (roving
   tabindex). Distinct from the static Table component.

   The boot <script> emitted by grid_close() sets the roving tabindex on
   first paint (the first focusable [data-grid-cell] gets tabindex="0", the
   rest -1); public/site.js (keyed on data-slot="grid") owns the live
   arrow / Home / End / Ctrl+Home / Ctrl+End contract.

   Accessibility contract:
     repos/aria-practices/content/patterns/grid/grid-pattern.html
     repos/aria-practices/content/patterns/grid/examples/js/dataGrid.js

   Usage:
     {% from "components/grid.html" import grid_open, grid_close,
          ghead_open, ghead_close, gbody_open, gbody_close,
          grow_open, grow_close, gcolheader, growheader, gcell %}

     {{ grid_open(aria_label="Transactions") }}
       {{ ghead_open() }}{{ grow_open() }}
         {{ gcolheader("Name", sort="ascending") }}{{ gcolheader("Amount") }}
       {{ grow_close() }}{{ ghead_close() }}
       {{ gbody_open() }}{{ grow_open() }}
         {{ gcell("Ada") }}{{ gcell("$120") }}
       {{ grow_close() }}{{ gbody_close() }}
     {{ grid_close() }} #}

{% macro grid_open(aria_label=none, aria_labelledby=none, aria_describedby=none, aria_rowcount=none, aria_colcount=none, aria_readonly=false, aria_multiselectable=false, extra_class="", wrapper_class="", attrs={}) -%}
<div class="relative w-full overflow-auto rounded-md border {{ wrapper_class }}">
  <table role="grid"
         data-slot="grid"
         {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
         {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
         {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
         {%- if aria_rowcount is not none %} aria-rowcount="{{ aria_rowcount }}"{% endif %}
         {%- if aria_colcount is not none %} aria-colcount="{{ aria_colcount }}"{% endif %}
         {%- if aria_readonly %} aria-readonly="true"{% endif %}
         {#- grid supports multi-selection: w3c.github.io/aria/#aria-multiselectable -#}
         {%- if aria_multiselectable %} aria-multiselectable="true"{% endif %}
         {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
         class="w-full caption-bottom border-separate border-spacing-0 text-sm {{ extra_class }}">
{%- endmacro %}

{% macro grid_close() -%}
  </table>
  <script>(function(el){
    var cells = el.querySelectorAll('[data-grid-cell]');
    cells.forEach(function(c,i){ c.setAttribute('tabindex', i===0 ? '0' : '-1'); });
    if (cells.length) cells[0].setAttribute('data-grid-active','true');
    el.setAttribute('data-grid-ready','true');
  })(document.currentScript.previousElementSibling);</script>
</div>
{%- endmacro %}

{% macro ghead_open() %}<thead data-slot="grid-header">{% endmacro %}
{% macro ghead_close() %}</thead>{% endmacro %}

{% macro gbody_open() %}<tbody data-slot="grid-body">{% endmacro %}
{% macro gbody_close() %}</tbody>{% endmacro %}

{% macro grow_open(aria_rowindex=none, aria_colindex=none, selected=false, extra_class="", attrs={}) -%}
<tr data-slot="grid-row"
    {%- if aria_rowindex is not none %} aria-rowindex="{{ aria_rowindex }}"{% endif %}
    {#- contiguous-columns form: aria-colindex once on the row. w3c.github.io/aria/#aria-colindex -#}
    {%- if aria_colindex is not none %} aria-colindex="{{ aria_colindex }}"{% endif %}
    {%- if selected %} aria-selected="true"{% endif %}
    {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
    class="transition-colors {{ extra_class }}">
{%- endmacro %}
{% macro grow_close() %}</tr>{% endmacro %}

{%- set CELL = "border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50" -%}
{%- set HEAD = CELL ~ " border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md" -%}
{%- set DATA = CELL ~ " bg-background text-foreground" -%}

{% macro gcolheader(label, sort=none, aria_colindex=none, extra_class="", attrs={}) -%}
<th scope="col"
    data-slot="grid-columnheader"
    data-grid-cell=""
    {%- if sort is not none %} data-sortable="true" aria-sort="{{ sort }}"{% endif %}
    {%- if aria_colindex is not none %} aria-colindex="{{ aria_colindex }}"{% endif %}
    {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
    class="{{ HEAD }} {{ extra_class }}"><span class="inline-flex items-center gap-1.5">{{ 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 %}</span></th>
{%- endmacro %}

{% macro growheader(label, aria_colindex=none, extra_class="", attrs={}) -%}
<th scope="row"
    data-slot="grid-rowheader"
    data-grid-cell=""
    {%- if aria_colindex is not none %} aria-colindex="{{ aria_colindex }}"{% endif %}
    {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
    class="{{ HEAD }} font-medium text-foreground {{ extra_class }}">{{ label|safe }}</th>
{%- endmacro %}

{% macro gcell(content, aria_colindex=none, selected=false, aria_readonly=false, extra_class="", attrs={}) -%}
<td data-slot="grid-cell"
    data-grid-cell=""
    {%- if aria_colindex is not none %} aria-colindex="{{ aria_colindex }}"{% endif %}
    {%- if selected %} aria-selected="true"{% endif %}
    {%- if aria_readonly %} aria-readonly="true"{% endif %}
    {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
    class="{{ DATA }} {{ extra_class }}">{{ content|safe }}</td>
{%- endmacro %}

1. Save the file

Add grid.tmpl alongside your other templates.

2. Use it

components/grid.tmpl
{{template "grid" (dict "AriaLabel" "Transactions" "Body" (htmlSafe `
  …<thead>…<tbody>…`))}}
View source
components/grid.tmpl
{{/*
  Grid templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/grid.tsx. An INTERACTIVE data grid: <table role="grid">
  that is a SINGLE tab stop with 2-D arrow-key cell navigation (roving
  tabindex). Distinct from the static "table" component.

  Named templates:
    - "grid"          — wrapper + <table role="grid"> + boot script (pass .Body)
    - "grid_row"      — one <tr> (pass .Body HTML)
    - "grid_colheader" — a focusable <th scope="col"> (optional .Sort)
    - "grid_rowheader" — a focusable <th scope="row">
    - "grid_cell"     — a focusable <td role="gridcell"> (pass .Body)

  The boot script sets the roving tabindex (single tab stop) on first paint;
  public/site.js (keyed on data-slot="grid") owns the arrow / Home / End /
  Ctrl+Home / Ctrl+End contract.

  Accessibility contract:
    repos/aria-practices/content/patterns/grid/grid-pattern.html
    repos/aria-practices/content/patterns/grid/examples/js/dataGrid.js

  Hand-compose the inner HTML (rows of headers/cells), then pass it as .Body
  (template.HTML via htmlSafe).
*/}}

{{define "grid"}}
<div class="relative w-full overflow-auto rounded-md border">
  <table role="grid"
         data-slot="grid"
         {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
         {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
         {{- if .AriaDescribedby}} aria-describedby="{{.AriaDescribedby}}"{{end}}
         {{- if .AriaRowcount}} aria-rowcount="{{.AriaRowcount}}"{{end}}
         {{- if .AriaColcount}} aria-colcount="{{.AriaColcount}}"{{end}}
         {{- if .AriaReadonly}} aria-readonly="true"{{end}}
         {{/* grid supports multi-selection: w3c.github.io/aria/#aria-multiselectable */}}
         {{- if .AriaMultiselectable}} aria-multiselectable="true"{{end}}
         class="w-full caption-bottom border-separate border-spacing-0 text-sm">
    {{.Body}}
  </table>
  <script>(function(el){
    var cells = el.querySelectorAll('[data-grid-cell]');
    cells.forEach(function(c,i){ c.setAttribute('tabindex', i===0 ? '0' : '-1'); });
    if (cells.length) cells[0].setAttribute('data-grid-active','true');
    el.setAttribute('data-grid-ready','true');
  })(document.currentScript.previousElementSibling);</script>
</div>
{{end}}

{{define "grid_row"}}
<tr data-slot="grid-row"
    {{- if .AriaRowindex}} aria-rowindex="{{.AriaRowindex}}"{{end}}
    {{/* contiguous-columns form: aria-colindex once on the row. w3c.github.io/aria/#aria-colindex */}}
    {{- if .AriaColindex}} aria-colindex="{{.AriaColindex}}"{{end}}
    {{- if .Selected}} aria-selected="true"{{end}}
    class="transition-colors">{{.Body}}</tr>
{{end}}

{{define "grid_colheader"}}
{{- $cell := "border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50" -}}
{{- $head := printf "%s border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md" $cell -}}
<th scope="col"
    data-slot="grid-columnheader"
    data-grid-cell=""
    {{- if .Sort}} data-sortable="true" aria-sort="{{.Sort}}"{{end}}
    {{- if .AriaColindex}} aria-colindex="{{.AriaColindex}}"{{end}}
    class="{{$head}}"><span class="inline-flex items-center gap-1.5">{{.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 if eq .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>{{end}}</span></th>
{{end}}

{{define "grid_rowheader"}}
{{- $cell := "border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50" -}}
{{- $head := printf "%s border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md font-medium text-foreground" $cell -}}
<th scope="row"
    data-slot="grid-rowheader"
    data-grid-cell=""
    {{- if .AriaColindex}} aria-colindex="{{.AriaColindex}}"{{end}}
    class="{{$head}}">{{.Label}}</th>
{{end}}

{{define "grid_cell"}}
{{- $cell := "border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50" -}}
{{- $data := printf "%s bg-background text-foreground" $cell -}}
<td data-slot="grid-cell"
    data-grid-cell=""
    {{- if .AriaColindex}} aria-colindex="{{.AriaColindex}}"{{end}}
    {{- if .Selected}} aria-selected="true"{{end}}
    {{- if .AriaReadonly}} aria-readonly="true"{{end}}
    class="{{$data}}">{{.Body}}</td>
{{end}}

1. Save the file

Drop grid.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/grid.ex
<.grid aria-label="Transactions">
  <.grid_header>
    <.grid_row>
      <.grid_columnheader sort="ascending">Name</.grid_columnheader>
      <.grid_columnheader>Amount</.grid_columnheader>
    </.grid_row>
  </.grid_header>
  <.grid_body>
    <.grid_row>
      <.grid_rowheader>Ada Lovelace</.grid_rowheader>
      <.grid_cell>$120.00</.grid_cell>
    </.grid_row>
  </.grid_body>
</.grid>
View source
lib/my_app_web/components/grid.ex
defmodule ShadcnHtmx.Components.Grid do
  @moduledoc """
  Grid — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  An INTERACTIVE data grid: a `<table role="grid">` that is a SINGLE tab stop
  with 2-D arrow-key cell navigation (roving tabindex). Distinct from the
  static `table` component — reach for `grid` only when you want
  spreadsheet-style cell focus and a shorter tab sequence.

  Mirrors registry/ui/grid.tsx. Function components: `grid`, `grid_header`,
  `grid_body`, `grid_row`, `grid_columnheader`, `grid_rowheader`, `grid_cell`.

  A boot `<script>` sets the roving tabindex on first paint, and
  public/site.js (keyed on data-slot="grid") owns the arrow / Home / End /
  Ctrl+Home / Ctrl+End contract. Accessibility contract:
  repos/aria-practices/content/patterns/grid/grid-pattern.html
  repos/aria-practices/content/patterns/grid/examples/js/dataGrid.js

  ## Examples

      <.grid aria-label="Transactions">
        <.grid_header>
          <.grid_row>
            <.grid_columnheader sort="ascending">Name</.grid_columnheader>
            <.grid_columnheader>Amount</.grid_columnheader>
          </.grid_row>
        </.grid_header>
        <.grid_body>
          <.grid_row>
            <.grid_cell>Ada</.grid_cell>
            <.grid_cell>$120</.grid_cell>
          </.grid_row>
        </.grid_body>
      </.grid>
  """

  use Phoenix.Component

  @cell "border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50"
  @head @cell <>
          " border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md"
  @data @cell <> " bg-background text-foreground"

  attr :"aria-label", :string, default: nil
  attr :"aria-labelledby", :string, default: nil
  attr :"aria-describedby", :string, default: nil
  attr :"aria-rowcount", :integer, default: nil
  attr :"aria-colcount", :integer, default: nil
  attr :"aria-readonly", :boolean, default: false
  # grid supports selecting more than one cell/row: w3c.github.io/aria/#aria-multiselectable
  attr :"aria-multiselectable", :boolean, default: false
  attr :class, :string, default: nil
  attr :wrapper_class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def grid(assigns) do
    ~H"""
    <div class={["relative w-full overflow-auto rounded-md border", @wrapper_class]}>
      <table
        role="grid"
        data-slot="grid"
        aria-label={assigns[:"aria-label"]}
        aria-labelledby={assigns[:"aria-labelledby"]}
        aria-describedby={assigns[:"aria-describedby"]}
        aria-rowcount={assigns[:"aria-rowcount"]}
        aria-colcount={assigns[:"aria-colcount"]}
        aria-readonly={assigns[:"aria-readonly"] && "true"}
        aria-multiselectable={assigns[:"aria-multiselectable"] && "true"}
        class={["w-full caption-bottom border-separate border-spacing-0 text-sm", @class]}
        {@rest}
      >
        {render_slot(@inner_block)}
      </table>
      <script>{Phoenix.HTML.raw(~s"""
        (function(el){
          var cells = el.querySelectorAll('[data-grid-cell]');
          cells.forEach(function(c,i){ c.setAttribute('tabindex', i===0 ? '0' : '-1'); });
          if (cells.length) cells[0].setAttribute('data-grid-active','true');
          el.setAttribute('data-grid-ready','true');
        })(document.currentScript.previousElementSibling);
      """)}</script>
    </div>
    """
  end

  slot :inner_block, required: true
  def grid_header(assigns), do: ~H"<thead data-slot=\"grid-header\">{render_slot(@inner_block)}</thead>"

  slot :inner_block, required: true
  def grid_body(assigns), do: ~H"<tbody data-slot=\"grid-body\">{render_slot(@inner_block)}</tbody>"

  attr :"aria-rowindex", :integer, default: nil
  # contiguous-columns form: aria-colindex once on the row. w3c.github.io/aria/#aria-colindex
  attr :"aria-colindex", :integer, default: nil
  attr :selected, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def grid_row(assigns) do
    ~H"""
    <tr
      data-slot="grid-row"
      aria-rowindex={assigns[:"aria-rowindex"]}
      aria-colindex={assigns[:"aria-colindex"]}
      aria-selected={@selected && "true"}
      class={["transition-colors", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </tr>
    """
  end

  attr :sort, :string, default: nil, values: [nil, "none", "ascending", "descending"]
  attr :"aria-colindex", :integer, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def grid_columnheader(assigns) do
    assigns = assign(assigns, head: @head)

    ~H"""
    <th
      scope="col"
      data-slot="grid-columnheader"
      data-grid-cell=""
      data-sortable={@sort && "true"}
      aria-sort={@sort}
      aria-colindex={assigns[:"aria-colindex"]}
      class={[@head, @class]}
      {@rest}
    >
      <span class="inline-flex items-center gap-1.5">
        {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>
      </span>
    </th>
    """
  end

  attr :"aria-colindex", :integer, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def grid_rowheader(assigns) do
    assigns = assign(assigns, head: @head)

    ~H"""
    <th
      scope="row"
      data-slot="grid-rowheader"
      data-grid-cell=""
      aria-colindex={assigns[:"aria-colindex"]}
      class={[@head, "font-medium text-foreground", @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </th>
    """
  end

  attr :"aria-colindex", :integer, default: nil
  attr :selected, :boolean, default: false
  attr :"aria-readonly", :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def grid_cell(assigns) do
    assigns = assign(assigns, data: @data)

    ~H"""
    <td
      data-slot="grid-cell"
      data-grid-cell=""
      aria-colindex={assigns[:"aria-colindex"]}
      aria-selected={@selected && "true"}
      aria-readonly={assigns[:"aria-readonly"] && "true"}
      class={[@data, @class]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </td>
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css.

2. Use it

snippets/grid.html
<table role="grid" data-slot="grid" aria-label="Transactions"
  class="w-full border-separate border-spacing-0 text-sm">
  <thead><tr>
    <th scope="col" data-slot="grid-columnheader" data-grid-cell="" aria-sort="ascending">Name</th>
    <th scope="col" data-slot="grid-columnheader" data-grid-cell="">Amount</th>
  </tr></thead>
  <tbody>
    <tr>
      <th scope="row" data-slot="grid-rowheader" data-grid-cell="">Ada</th>
      <td data-slot="grid-cell" data-grid-cell="">$120.00</td>
    </tr>
  </tbody>
</table>
<!-- inline boot <script> sets the roving tabindex; site.js owns the keys -->
View source
snippets/grid.html
<!--
  shadcn-htmx — raw HTML grid snippet.

  Mirrors registry/ui/grid.tsx. An INTERACTIVE data grid: <table role="grid">
  that is a SINGLE tab stop with 2-D arrow-key cell navigation (roving
  tabindex). Distinct from the static <table> snippet — use this only when you
  want spreadsheet-style cell focus.

  Every focusable cell carries [data-grid-cell]; exactly one has tabindex="0",
  the rest tabindex="-1". The inline <script> right after the grid sets that
  on first paint. The full keyboard contract (Arrow keys move one cell,
  Home/End jump to row ends, Ctrl+Home/End jump to the grid corners) needs the
  wiring in public/site.js.

  Accessibility contract:
    repos/aria-practices/content/patterns/grid/grid-pattern.html

  Optional ARIA attributes (additive, omit unless needed):
    - On <table role="grid">: add aria-multiselectable="true" when the grid
      supports selecting more than one cell/row (pair with aria-selected on
      cells/rows; selectable-but-unselected nodes carry aria-selected="false").
      w3c.github.io/aria/#aria-multiselectable
    - On <tr data-slot="grid-row">: when the visible columns are contiguous you
      may set aria-colindex once on the row (the row's first column index)
      instead of per cell. w3c.github.io/aria/#aria-colindex

  Required CSS theme variables: --background, --foreground, --muted,
  --muted-foreground, --border, --ring. See app/styles/input.css.

  Minimal inline JS for keyboard navigation (if you are not loading site.js):

    <script>
      document.addEventListener('keydown', function (e) {
        var cell = e.target.closest('[data-grid-cell]')
        if (!cell) return
        var grid = cell.closest('[data-slot="grid"]')
        if (!grid) return
        var keys = ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End']
        if (keys.indexOf(e.key) === -1) return
        e.preventDefault()
        var rows = [].slice.call(grid.querySelectorAll('tr'))
        var grid2d = rows.map(function (r) {
          return [].slice.call(r.querySelectorAll('[data-grid-cell]'))
        }).filter(function (r) { return r.length })
        var R = grid2d.length, C = grid2d[0].length
        var r = -1, c = -1
        grid2d.forEach(function (row, ri) {
          var ci = row.indexOf(cell)
          if (ci !== -1) { r = ri; c = ci }
        })
        if (r === -1) return
        if (e.key === 'ArrowUp') r = Math.max(0, r - 1)
        else if (e.key === 'ArrowDown') r = Math.min(R - 1, r + 1)
        else if (e.key === 'ArrowLeft') c = Math.max(0, c - 1)
        else if (e.key === 'ArrowRight') c = Math.min(C - 1, c + 1)
        else if (e.key === 'Home') { c = 0; if (e.ctrlKey) r = 0 }
        else if (e.key === 'End') { c = grid2d[r].length - 1; if (e.ctrlKey) r = R - 1 }
        var next = grid2d[r][c]
        if (!next) return
        cell.setAttribute('tabindex', '-1'); cell.removeAttribute('data-grid-active')
        next.setAttribute('tabindex', '0'); next.setAttribute('data-grid-active', 'true')
        next.focus()
      })
    </script>
-->

<div class="relative w-full overflow-auto rounded-md border">
  <table role="grid" data-slot="grid" aria-label="Transactions"
         class="w-full caption-bottom border-separate border-spacing-0 text-sm">
    <thead data-slot="grid-header">
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="col" data-slot="grid-columnheader" data-grid-cell="" data-sortable="true" aria-sort="ascending"
            class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md">
          <span class="inline-flex items-center gap-1.5">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" /></svg>
          </span>
        </th>
        <th scope="col" data-slot="grid-columnheader" data-grid-cell=""
            class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md">
          <span class="inline-flex items-center gap-1.5">Amount</span>
        </th>
      </tr>
    </thead>
    <tbody data-slot="grid-body">
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="row" data-slot="grid-rowheader" data-grid-cell=""
            class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md font-medium text-foreground">Ada Lovelace</th>
        <td data-slot="grid-cell" data-grid-cell=""
            class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">$120.00</td>
      </tr>
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="row" data-slot="grid-rowheader" data-grid-cell=""
            class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md font-medium text-foreground">Grace Hopper</th>
        <td data-slot="grid-cell" data-grid-cell=""
            class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">$87.50</td>
      </tr>
    </tbody>
  </table>
  <script>
    (function (el) {
      var cells = el.querySelectorAll('[data-grid-cell]')
      cells.forEach(function (c, i) { c.setAttribute('tabindex', i === 0 ? '0' : '-1') })
      if (cells.length) cells[0].setAttribute('data-grid-active', 'true')
      el.setAttribute('data-grid-ready', 'true')
    })(document.currentScript.previousElementSibling)
  </script>
</div>

Examples

Basic — focusable cells

Tab once to enter the grid, then arrow between cells. Home/End jump to the row ends; Ctrl+Home/End jump to the grid corners.

The whole grid is a single tab stop. Exactly one cell carries tabindex="0" (a roving tabindex); the arrow keys roll it across the 2-D cell map. A screen reader switches into application mode on role="grid" and announces each cell as you move. Built on a real <table> so the row/cell semantics come from the platform.

NameAmountDate
Ada Lovelace$120.002025-01-15
Grace Hopper$87.502025-03-02
Hedy Lamarr$240.102025-08-21
import { Grid, GridHeader, GridBody, GridRow,
  GridColumnHeader, GridRowHeader, GridCell } from "@/components/ui/grid"

<Grid ariaLabel="Transactions">
  <GridHeader>
    <GridRow>
      <GridColumnHeader sort="ascending">Name</GridColumnHeader>
      <GridColumnHeader>Amount</GridColumnHeader>
    </GridRow>
  </GridHeader>
  <GridBody>
    <GridRow>
      <GridRowHeader>Ada Lovelace</GridRowHeader>
      <GridCell>$120.00</GridCell>
    </GridRow>
  </GridBody>
</Grid>
{% from "components/grid.html" import grid_open, grid_close,
   ghead_open, ghead_close, gbody_open, gbody_close, grow_open, grow_close,
   gcolheader, growheader, gcell %}

{{ grid_open(aria_label="Transactions") }}
  {{ ghead_open() }}{{ grow_open() }}{{ gcolheader("Name", sort="ascending") }}{{ gcolheader("Amount") }}{{ grow_close() }}{{ ghead_close() }}
  {{ gbody_open() }}{{ grow_open() }}{{ growheader("Ada Lovelace") }}{{ gcell("$120.00") }}{{ grow_close() }}{{ gbody_close() }}
{{ grid_close() }}
{{template "grid" (dict "AriaLabel" "Transactions" "Body" (htmlSafe `
  …<thead>…<tbody>…`))}}
<.grid aria-label="Transactions">
  <.grid_header>
    <.grid_row>
      <.grid_columnheader sort="ascending">Name</.grid_columnheader>
      <.grid_columnheader>Amount</.grid_columnheader>
    </.grid_row>
  </.grid_header>
  <.grid_body>
    <.grid_row>
      <.grid_rowheader>Ada Lovelace</.grid_rowheader>
      <.grid_cell>$120.00</.grid_cell>
    </.grid_row>
  </.grid_body>
</.grid>
<div class="relative w-full overflow-auto rounded-md border">
  <table role="grid" data-slot="grid" aria-label="Recent transactions" class="w-full caption-bottom border-separate border-spacing-0 text-sm">
    <thead data-slot="grid-header" class="">
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="col" data-slot="grid-columnheader" data-grid-cell="" data-sortable="true" aria-sort="ascending" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md">
          <span class="inline-flex items-center gap-1.5">
            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>
          </span>
        </th>
        <th scope="col" data-slot="grid-columnheader" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md">
          <span class="inline-flex items-center gap-1.5">Amount</span>
        </th>
        <th scope="col" data-slot="grid-columnheader" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md">
          <span class="inline-flex items-center gap-1.5">Date</span>
        </th>
      </tr>
    </thead>
    <tbody data-slot="grid-body" class="">
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="row" data-slot="grid-rowheader" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md font-medium text-foreground">Ada Lovelace</th>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">$120.00</td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">2025-01-15</td>
      </tr>
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="row" data-slot="grid-rowheader" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md font-medium text-foreground">Grace Hopper</th>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">$87.50</td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">2025-03-02</td>
      </tr>
      <tr data-slot="grid-row" class="transition-colors">
        <th scope="row" data-slot="grid-rowheader" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 border-t bg-muted/50 text-left font-medium text-muted-foreground first:rounded-tl-md last:rounded-tr-md font-medium text-foreground">Hedy Lamarr</th>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">$240.10</td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">2025-08-21</td>
      </tr>
    </tbody>
  </table>
  <script>
    (function(el){
    var cells = el.querySelectorAll('[data-grid-cell]');
    cells.forEach(function(c,i){ c.setAttribute('tabindex', i===0 ? '0' : '-1'); });
    if (cells.length) cells[0].setAttribute('data-grid-active','true');
    el.setAttribute('data-grid-ready','true');
  })(document.currentScript.previousElementSibling);
  </script>
</div>

When a cell holds a single interactive widget (a link), grid navigation focuses that widget directly — so the whole list is one tab stop instead of one per link.

This is the layout-grid use case from the APG: grouping a long list of links so keyboard users aren't trapped tabbing through every one. Each cell wraps an <a>; the roving tabindex lands on the cell, and Enter follows the link inside it. The arrow keys never get trapped because the cell — not the page — owns them.

Q1 revenueQ2 revenueQ3 revenue
HeadcountChurnPipeline
<Grid ariaLabel="Saved reports">
  <GridBody>
    <GridRow>
      <GridCell><a href="/q1">Q1 revenue</a></GridCell>
      <GridCell><a href="/q2">Q2 revenue</a></GridCell>
    </GridRow>
  </GridBody>
</Grid>
{{ grid_open(aria_label="Saved reports") }}
  {{ gbody_open() }}{{ grow_open() }}
    {{ gcell('<a href="/q1">Q1 revenue</a>') }}
    {{ gcell('<a href="/q2">Q2 revenue</a>') }}
  {{ grow_close() }}{{ gbody_close() }}
{{ grid_close() }}
{{template "grid" (dict "AriaLabel" "Saved reports" "Body" (htmlSafe `
  <tbody><tr>
    <td data-slot="grid-cell" data-grid-cell=""><a href="/q1">Q1 revenue</a></td>
  </tr></tbody>`))}}
<.grid aria-label="Saved reports">
  <.grid_body>
    <.grid_row>
      <.grid_cell><a href="/q1">Q1 revenue</a></.grid_cell>
      <.grid_cell><a href="/q2">Q2 revenue</a></.grid_cell>
    </.grid_row>
  </.grid_body>
</.grid>
<div class="relative w-full overflow-auto rounded-md border">
  <table role="grid" data-slot="grid" aria-label="Saved reports" class="w-full caption-bottom border-separate border-spacing-0 text-sm">
    <tbody data-slot="grid-body" class="">
      <tr data-slot="grid-row" class="transition-colors">
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">
          <a href="#ex-links" class="text-primary underline-offset-4 hover:underline">Q1 revenue</a>
        </td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">
          <a href="#ex-links" class="text-primary underline-offset-4 hover:underline">Q2 revenue</a>
        </td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">
          <a href="#ex-links" class="text-primary underline-offset-4 hover:underline">Q3 revenue</a>
        </td>
      </tr>
      <tr data-slot="grid-row" class="transition-colors">
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">
          <a href="#ex-links" class="text-primary underline-offset-4 hover:underline">Headcount</a>
        </td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">
          <a href="#ex-links" class="text-primary underline-offset-4 hover:underline">Churn</a>
        </td>
        <td data-slot="grid-cell" data-grid-cell="" class="border-b border-r px-3 py-2 align-middle outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-[2px] focus-visible:ring-ring/50 data-[grid-active=true]:bg-muted/50 bg-background text-foreground">
          <a href="#ex-links" class="text-primary underline-offset-4 hover:underline">Pipeline</a>
        </td>
      </tr>
    </tbody>
  </table>
  <script>
    (function(el){
    var cells = el.querySelectorAll('[data-grid-cell]');
    cells.forEach(function(c,i){ c.setAttribute('tabindex', i===0 ? '0' : '-1'); });
    if (cells.length) cells[0].setAttribute('data-grid-active','true');
    el.setAttribute('data-grid-ready','true');
  })(document.currentScript.previousElementSibling);
  </script>
</div>

API Reference

<Grid>

PropTypeDefaultDescription
ariaMultiselectableboolean
Set on the grid when more than one cell/row can be selected. Pair with the selected prop on GridRow/GridCell; selectable-but-unselected nodes should then carry aria-selected="false" so AT advertises that multiple selection is allowed.MDNaria-multiselectable
ariaColindexnumber
GridRow only. 1-based column index of the row's first cell. When the visible columns are contiguous, set this once on the row (instead of per cell) and browsers compute each cell's column number; use the per-cell ariaColindex form only when columns are non-contiguous.MDNaria-colindex
ariaLabelstring
Accessible name for the grid. Required when there is no visible labelling element (APG: a grid must be named).APGGrid pattern
ariaLabelledbystring
Id of a visible element that names the grid (use instead of ariaLabel).MDNaria-labelledby
ariaDescribedbystring
Id of an element describing the grid (announced after the name).MDNaria-describedby
ariaRowcountnumber
Total number of rows when not all are present in the DOM (virtualised/paginated grids).MDNaria-rowcount
ariaColcountnumber
Total number of columns when not all are present in the DOM.MDNaria-colcount
ariaReadonlyboolean
Set on the grid when editing is disabled for all cells (read-only data grid).MDNaria-readonly
wrapperClassstring
Tailwind classes appended to the overflow-x scroll wrapper.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference