Skip to content
Home Theme Gallery

Data grid

The custom element is <hui-data-grid>.

Overview

A data grid over a JSON child the server writes. In client mode it sorts, filters, pages and selects in the browser; in server mode it sends the same query - one JSON form field - and draws the rows the host swaps back. Cells are formatted by column type or filled from the host’s own templates.

Example

Sorting, filtering, paging and selection in the browser
Source
<hui-data-grid label="Files" selection="multiple" page-size="5" quick-filter column-menu export="files.csv">
<script type="application/json">{"columns":[{"key":"name","label":"Name","filter":"text"},{"key":"size","label":"Size","type":"number","filter":"range"},{"key":"modified","label":"Modified","type":"date","filter":"range","format":{"year":"numeric","month":"short","day":"numeric"}},{"key":"status","label":"Status","filter":"values"},{"key":"owner","label":"Owner","hidden":true}],"rows":[{"id":"f1","name":"report10.pdf","size":2048,"modified":"2026-09-01","status":"ready","owner":"Ada","url":"#f1"},{"id":"f2","name":"report2.pdf","size":512,"modified":"2026-08-15","status":"failed","owner":"Grace","url":"#f2"},{"id":"f3","name":"Budget.xlsx","size":1024,"modified":"2026-07-30","status":"ready","owner":"Ada","url":"#f3"},{"id":"f4","name":"notes.txt","size":12,"modified":"2026-09-20","status":"queued","owner":"Linus","url":"#f4"},{"id":"f5","name":"photo.jpg","size":4096,"modified":"2026-06-02","status":"ready","owner":"Grace","url":"#f5"},{"id":"f6","name":"archive.zip","size":8192,"modified":"2026-05-11","status":"failed","owner":"Ada","url":"#f6"},{"id":"f7","name":"draft.docx","size":256,"modified":"2026-09-10","status":"queued","owner":"Linus","url":"#f7"}],"selected":["f3"]}</script>
<template data-column="name"><a data-attr-href="url" data-text="name"></a></template>
<template data-column="status"><span class="hui-badge" data-text="status"></span></template>
</hui-data-grid>

The Go template that renders it:

<hui-data-grid label="Files" name="query" selection="multiple" selection-name="selected"
page-size="5" quick-filter column-menu export="files.csv">
<script type="application/json">{{ .Grid }}</script>
<template data-column="name"><a data-attr-href="url" data-text="name"></a></template>
<template data-column="status"><span class="hui-badge" data-text="status"></span></template>
</hui-data-grid>

On a phone

The grid scrolls sideways in its own box, never the page; the toolbar and footer wrap. Moving the focused cell with the arrow keys scrolls it into view.

On a phone 375 px wide
Source
<hui-data-grid label="Files" page-size="4" quick-filter filter-row="hidden">
<script type="application/json">{"columns":[{"key":"name","label":"Name","filter":"text"},{"key":"size","label":"Size","type":"number","filter":"range"},{"key":"modified","label":"Modified","type":"date","filter":"range","format":{"year":"numeric","month":"short","day":"numeric"}},{"key":"status","label":"Status","filter":"values"},{"key":"owner","label":"Owner","hidden":true}],"rows":[{"id":"f1","name":"report10.pdf","size":2048,"modified":"2026-09-01","status":"ready","owner":"Ada","url":"#f1"},{"id":"f2","name":"report2.pdf","size":512,"modified":"2026-08-15","status":"failed","owner":"Grace","url":"#f2"},{"id":"f3","name":"Budget.xlsx","size":1024,"modified":"2026-07-30","status":"ready","owner":"Ada","url":"#f3"},{"id":"f4","name":"notes.txt","size":12,"modified":"2026-09-20","status":"queued","owner":"Linus","url":"#f4"},{"id":"f5","name":"photo.jpg","size":4096,"modified":"2026-06-02","status":"ready","owner":"Grace","url":"#f5"},{"id":"f6","name":"archive.zip","size":8192,"modified":"2026-05-11","status":"failed","owner":"Ada","url":"#f6"},{"id":"f7","name":"draft.docx","size":256,"modified":"2026-09-10","status":"queued","owner":"Linus","url":"#f7"}],"selected":["f3"]}</script>
</hui-data-grid>

API

Attributes16
Attributes
NameTypeDefaultDescription
mode"client" | "server""client"Who answers the query: the grid, from the rows it holds, or the host, which swaps in the rows it answers with.
labelstring-The grid’s accessible name. Required in practice: a grid with no name is a defect.
namestring-The form field the query submits under, as one JSON value. HTMX reads it from value when the grid carries hx-get itself.
selection"none" | "single" | "multiple""none"Row selection. multiple adds a selection column with a select-all box.
selection-namestring-The form field the selected row keys submit under, one entry each.
row-keystring"id"The row field that identifies a row, for selection and for keeping it across a sort or a swap.
page-sizenumber0Rows per page. 0 shows every row with no pager.
queryJSON-The query: { sort, q, filters, page, pageSize, columns }. Viewer-owned: reflected as it changes, and accepted back from the server on the next render.
widthsJSON-Column widths the reader set, in pixels, by key. Viewer-owned.
quick-filterboolean-Draws the quick filter above the grid.
filter-row"auto" | "hidden""auto"auto draws a filter row under the header when a column declares a filter.
column-menuboolean-Draws the Columns menu, which shows and hides columns. The last visible column cannot be hidden.
exportstring-Draws an Export button; the value is the file name, export.csv when empty.
sticky-columnboolean-Keeps the first data column (and the selection column) in view while the grid scrolls sideways.
empty-textstring"No results."Shown when no row matches and the host wrote no empty template.
data-loadingboolean-Reflected while a server query is out. Never written by the host.
Properties2
Properties
NameTypeDefaultDescription
valuestring-The query as JSON. Setting a string applies a query; setting an array sets the selection, which is how Alpine’s x-model writes back. No event fires.
valuesstring[]-Read-only. The selected row keys, in the order the rows arrived.
Methods9
Methods
NameTypeDescription
sortBy(key, additive?)voidCycles a column’s sort, as a header press does; additive is Shift.
filterBy(key, filter)voidSets a column’s filter: text, an array of values, or { min, max }.
search(q)voidSets the quick filter.
goToPage(page)voidMoves to a page, within range.
setColumnVisible(key, visible)voidShows or hides a column.
toCSV()stringRFC 4180 CSV of every row matching the query (client) or held (server), over the visible columns, with raw values.
download(filename?)voidDownloads toCSV().
settle()voidClears a pending query’s busy state, for a host whose request failed.
read()voidRe-reads the JSON child. Called for you when it changes.
Events5
Events
NameDescription
hui-grid-queryThe query changed, after query is reflected. Detail: { reason, query, value }, where reason is sort, filter, page or columns and value is the JSON. In server mode this is the host’s cue to answer.
inputThe selection changed. A CustomEvent whose detail is the selected keys, which x-model binds.
changeThe selection changed, after input.
hui-grid-activateEnter on a cell with no control of its own, or a double-click on a row. Cancellable. Detail: { key, row }.
hui-grid-columnsColumns were shown, hidden or resized. Detail: { visible, widths }.
Slots3
Slots
NameDescription
script[type="application/json"]{ columns, rows, total?, query?, selected?, error? }. Each column is { key, label?, type?, sortable?, filter?, options?, hidden?, width?, align?, format? }: type is text, number, date or boolean; filter is text, values, range or none; format is Intl options.
template[data-column]A cell template for the named column. Elements with data-text="field" get that field’s text; data-attr-href="field" sets href from it, and any other data-attr-* likewise. Data is never parsed as HTML, and a script URL becomes #.
template[data-empty]What to show when no row matches.
CSS custom properties2
CSS custom properties
NameDefaultDescription
--hui-grid-heightnoneThe scrolling box’s maximum height. Set it for rows to scroll under the sticky header.
--hui-grid-select-width2.5remThe selection column’s width, and the sticky column’s offset after it.
Classes25
Classes
NameDescription
.hui-gridThe view hui-data-grid renders into its own light DOM, after the host’s JSON child and templates: the toolbar, the scrolling table, the footer and the live region.
.hui-grid__toolbarThe row above the table: the quick filter, then the Columns menu and the Export button, drawn when quick-filter, column-menu or export is set.
.hui-grid__footerThe row under the table: the count of rows shown, the number selected, and the pager.
.hui-grid__spacerTakes the free space in the toolbar and footer, pushing the tools to the end.
.hui-grid__searchThe quick filter: a search box that matches every visible column.
.hui-grid__filterA filter control in the filter row: a search box, a hui-select selection="multiple" of values, or one end of a range.
.hui-grid__rangeThe pair of from and to inputs a range filter draws for a number or date column.
.hui-grid__toolA toolbar or pager button, at the button group’s 28px.
.hui-grid__pagerThe pager: first, previous, "Page n of m", next and last, as a named nav.
.hui-grid__pageThe pager’s "Page n of m".
.hui-grid__scrollThe box the table scrolls in, both ways. Set --hui-grid-height on the element to scroll rows under the sticky header.
.hui-grid__tableThe role="grid" table, which also takes .hui-table for nova’s table geometry. Cells keep to one line.
.hui-grid__headA column header: sortable ones show a pointer and announce aria-sort.
.hui-grid__labelA column header’s label.
.hui-grid__sortThe sort mark after a sortable header’s label: Remix’s chevron up or down, faint on hover when unsorted.
.hui-grid__sort-orderThe key’s position when more than one column sorts.
.hui-grid__resizeThe drag handle on a header’s end edge that resizes the column. Decorative: Alt with the arrow keys resizes from the keyboard.
.hui-grid__filtersThe filter row under the header, drawn when a column declares a filter and filter-row is not hidden.
.hui-grid__align-endA column aligned to the end: the default for numbers.
.hui-grid__align-centerA column aligned to the centre.
.hui-grid__selectThe selection column in selection="multiple", with the header’s select-all box.
.hui-grid__checkThe 16px check in the selection column. In a row it is decoration - the row carries aria-selected; in the header it is the role="checkbox" that selects the page, mixed when some rows are.
.hui-grid__emptyThe row shown when no rows match: the host’s <template data-empty>, or empty-text.
.hui-grid__errorThe message the server sent as "error" instead of rows.
.hui-grid__statusThe visually hidden polite live region that says what a sort, filter or page did.

States

Answered by the server

With mode="server" the grid never reorders a row. A header press, a filter or a page button sets the query, marks the grid busy and emits hui-grid-query; the host answers with a new JSON child, and the grid draws it and clears the busy state. This example plays the host with a timer. With HTMX the grid carries the request itself - see the Go handler under Server-side mechanics.

Answered by the server
Source
<hui-data-grid id="jobs-grid" label="Jobs" mode="server" page-size="3" quick-filter filter-row="hidden">
<script type="application/json">{"columns":[{"key":"name","label":"Job","filter":"text"},{"key":"took","label":"Took (s)","type":"number"}],"rows":[{"id":"j1","name":"Job 1","took":0},{"id":"j2","name":"Job 2","took":37},{"id":"j3","name":"Job 3","took":74}],"total":9,"query":{"sort":[],"q":"","filters":{},"page":1,"pageSize":3,"columns":["name","took"]}}</script>
</hui-data-grid>
<script>
// The host: answer each query with the page it asks for, echoing the query.
const jobs = Array.from({ length: 9 }, (_, i) => ({ id: 'j' + (i + 1), name: 'Job ' + (i + 1), took: (i * 37) % 100 }));
const grid = document.querySelector('#jobs-grid');
let pending;
grid.addEventListener('hui-grid-query', () => {
const query = JSON.parse(grid.value);
clearTimeout(pending);
pending = setTimeout(() => {
let rows = jobs.filter((job) => job.name.toLowerCase().includes(query.q.toLowerCase()));
const [key] = query.sort;
if (key) rows = rows.slice().sort((a, b) => (a[key.key] < b[key.key] ? -1 : 1) * (key.dir === 'desc' ? -1 : 1));
const start = (query.page - 1) * query.pageSize;
const script = document.createElement('script');
script.type = 'application/json';
script.textContent = JSON.stringify({ columns: [{"key":"name","label":"Job","filter":"text"},{"key":"took","label":"Took (s)","type":"number"}], rows: rows.slice(start, start + query.pageSize), total: rows.length, query });
grid.querySelector(':scope > script').replaceWith(script);
}, 400);
});
</script>

A filter the host placed outside

Any control with data-grid-filter naming a column, and data-grid-for naming the grid’s id, filters it - a hui-select, a hui-input, a native <select>. data-grid-filter="q" is the quick filter; data-grid-bound="min" or "max" is one end of a range. Here the filter row is hidden, the grid is single-selection, its height is set with --hui-grid-height so rows scroll under the sticky header, and sticky-column keeps the name in view while it scrolls sideways.

A filter the host placed outside
Source
<hui-select data-grid-filter="status" data-grid-for="owners-grid" aria-label="Status" placeholder="Any status" style="max-inline-size:12rem">
<div role="option" data-value="">Any status</div>
<div role="option" data-value="ready">ready</div>
<div role="option" data-value="failed">failed</div>
<div role="option" data-value="queued">queued</div>
</hui-select>
<div style="height:.5rem"></div>
<hui-data-grid id="owners-grid" label="Owners" selection="single" sticky-column filter-row="hidden" style="--hui-grid-height:12rem">
<script type="application/json">{"columns":[{"key":"name","label":"Name","filter":"text"},{"key":"size","label":"Size","type":"number","filter":"range"},{"key":"modified","label":"Modified","type":"date","filter":"range","format":{"year":"numeric","month":"short","day":"numeric"}},{"key":"status","label":"Status","filter":"values"},{"key":"owner","label":"Owner","hidden":true}],"rows":[{"id":"f1","name":"report10.pdf","size":2048,"modified":"2026-09-01","status":"ready","owner":"Ada","url":"#f1"},{"id":"f2","name":"report2.pdf","size":512,"modified":"2026-08-15","status":"failed","owner":"Grace","url":"#f2"},{"id":"f3","name":"Budget.xlsx","size":1024,"modified":"2026-07-30","status":"ready","owner":"Ada","url":"#f3"},{"id":"f4","name":"notes.txt","size":12,"modified":"2026-09-20","status":"queued","owner":"Linus","url":"#f4"},{"id":"f5","name":"photo.jpg","size":4096,"modified":"2026-06-02","status":"ready","owner":"Grace","url":"#f5"},{"id":"f6","name":"archive.zip","size":8192,"modified":"2026-05-11","status":"failed","owner":"Ada","url":"#f6"},{"id":"f7","name":"draft.docx","size":256,"modified":"2026-09-10","status":"queued","owner":"Linus","url":"#f7"}],"selected":["f3"]}</script>
</hui-data-grid>

Empty, with the host’s message

When no row matches, the grid shows a <template data-empty> the host wrote, or empty-text. A server that sends "error" instead of rows has that message shown in its place.

Empty, with the host’s message
Source
<hui-data-grid label="Uploads">
<script type="application/json">{"columns":[{"key":"name","label":"Name"},{"key":"size","label":"Size","type":"number"}],"rows":[]}</script>
<template data-empty>No uploads yet. Drop a file anywhere on the page.</template>
</hui-data-grid>

Server-side mechanics

The server owns the rows in both modes - the grid never adds, edits or stores one - and in server mode it also answers the query. The query is the reader’s: reflected to query, announced with hui-grid-query, and submitted as one JSON field under name. With HTMX the grid carries the request itself: hx-get="/files" hx-trigger="hui-grid-query[detail.reason=='filter'] delay:300ms, hui-grid-query[detail.reason!='filter']" hx-target="find script" hx-swap="outerHTML" hx-sync="this:replace". The trigger filter debounces typing and leaves sorting immediate, and hx-sync aborts a superseded request so an out-of-date answer never lands. In Go: json.Unmarshal([]byte(r.URL.Query().Get("query")), &q), then answer with <script type="application/json"> holding the page’s rows, the matching total, and the query you applied, which the grid takes as the truth. The same template renders the full page and the partial. Selected keys submit as repeated selection-name fields, for a bulk-action form.

Accessibility

  • A role="grid" table named by label, with aria-rowcount and aria-rowindex counting every matching row, so a screen reader reports "row 7 of 1,284" on the second page.
  • One tab stop: the active cell. A cell template’s links and buttons are taken out of the tab order and reached through the grid.
  • Sortable headers announce aria-sort; selected rows aria-selected; the select-all box is a role="checkbox" that is mixed when some rows are selected.
  • A polite live region reports each sort, filter and page: "Sorted by Size, descending. 42 of 1,284 rows."
  • While a server query is out, the grid is aria-busy.
  • In forced colours a selected row takes the system Highlight pair, and the focused cell a system outline.

Keyboard

Keyboard
KeysAction
ArrowsThe next cell. Up from the first row reaches the header; the filter row is skipped.
Home, EndThe first or last cell in the row. With Ctrl or Cmd, the first or last cell in the grid.
Page Up, Page DownTen rows up or down.
Enter, Space on a headerCycles the sort; with Shift, adds the column as another key. Space on the select-all box selects or clears the page.
Space on a rowSelects the row; in multiple, toggles it.
Shift + Up, DownIn multiple, extends the selection from the anchor.
Ctrl or Cmd + AIn multiple, selects every row on the page.
Enter on a cellActivates the cell’s link or button, or emits hui-grid-activate.
Alt + Left, Right on a headerNarrows or widens the column by 16px.

Gotchas

  • Nothing is virtualised: every row on the page is rendered. In client mode that is every row matching the query unless page-size is set.
  • A client sort uses the browser’s collation, which can differ from the database’s. If the order must match the server exactly, use server mode.
  • In the JSON a date is an ISO string or a timestamp; a range filter compares it as a time, and a date-only upper bound includes the whole day.
  • A script child in a Go template must have < escaped, as html/template does inside <script>.