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
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.
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
| Name | Type | Default | Description |
|---|---|---|---|
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. |
label | string | - | The grid’s accessible name. Required in practice: a grid with no name is a defect. |
name | string | - | 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-name | string | - | The form field the selected row keys submit under, one entry each. |
row-key | string | "id" | The row field that identifies a row, for selection and for keeping it across a sort or a swap. |
page-size | number | 0 | Rows per page. 0 shows every row with no pager. |
query | JSON | - | The query: { sort, q, filters, page, pageSize, columns }. Viewer-owned: reflected as it changes, and accepted back from the server on the next render. |
widths | JSON | - | Column widths the reader set, in pixels, by key. Viewer-owned. |
quick-filter | boolean | - | 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-menu | boolean | - | Draws the Columns menu, which shows and hides columns. The last visible column cannot be hidden. |
export | string | - | Draws an Export button; the value is the file name, export.csv when empty. |
sticky-column | boolean | - | Keeps the first data column (and the selection column) in view while the grid scrolls sideways. |
empty-text | string | "No results." | Shown when no row matches and the host wrote no empty template. |
data-loading | boolean | - | Reflected while a server query is out. Never written by the host. |
Properties2
| Name | Type | Default | Description |
|---|---|---|---|
value | string | - | 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. |
values | string[] | - | Read-only. The selected row keys, in the order the rows arrived. |
Methods9
| Name | Type | Description |
|---|---|---|
sortBy(key, additive?) | void | Cycles a column’s sort, as a header press does; additive is Shift. |
filterBy(key, filter) | void | Sets a column’s filter: text, an array of values, or { min, max }. |
search(q) | void | Sets the quick filter. |
goToPage(page) | void | Moves to a page, within range. |
setColumnVisible(key, visible) | void | Shows or hides a column. |
toCSV() | string | RFC 4180 CSV of every row matching the query (client) or held (server), over the visible columns, with raw values. |
download(filename?) | void | Downloads toCSV(). |
settle() | void | Clears a pending query’s busy state, for a host whose request failed. |
read() | void | Re-reads the JSON child. Called for you when it changes. |
Events5
| Name | Description |
|---|---|
hui-grid-query | The 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. |
input | The selection changed. A CustomEvent whose detail is the selected keys, which x-model binds. |
change | The selection changed, after input. |
hui-grid-activate | Enter on a cell with no control of its own, or a double-click on a row. Cancellable. Detail: { key, row }. |
hui-grid-columns | Columns were shown, hidden or resized. Detail: { visible, widths }. |
Slots3
| Name | Description |
|---|---|
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
| Name | Default | Description |
|---|---|---|
--hui-grid-height | none | The scrolling box’s maximum height. Set it for rows to scroll under the sticky header. |
--hui-grid-select-width | 2.5rem | The selection column’s width, and the sticky column’s offset after it. |
Classes25
| Name | Description |
|---|---|
.hui-grid | The 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__toolbar | The 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__footer | The row under the table: the count of rows shown, the number selected, and the pager. |
.hui-grid__spacer | Takes the free space in the toolbar and footer, pushing the tools to the end. |
.hui-grid__search | The quick filter: a search box that matches every visible column. |
.hui-grid__filter | A filter control in the filter row: a search box, a hui-select selection="multiple" of values, or one end of a range. |
.hui-grid__range | The pair of from and to inputs a range filter draws for a number or date column. |
.hui-grid__tool | A toolbar or pager button, at the button group’s 28px. |
.hui-grid__pager | The pager: first, previous, "Page n of m", next and last, as a named nav. |
.hui-grid__page | The pager’s "Page n of m". |
.hui-grid__scroll | The box the table scrolls in, both ways. Set --hui-grid-height on the element to scroll rows under the sticky header. |
.hui-grid__table | The role="grid" table, which also takes .hui-table for nova’s table geometry. Cells keep to one line. |
.hui-grid__head | A column header: sortable ones show a pointer and announce aria-sort. |
.hui-grid__label | A column header’s label. |
.hui-grid__sort | The sort mark after a sortable header’s label: Remix’s chevron up or down, faint on hover when unsorted. |
.hui-grid__sort-order | The key’s position when more than one column sorts. |
.hui-grid__resize | The drag handle on a header’s end edge that resizes the column. Decorative: Alt with the arrow keys resizes from the keyboard. |
.hui-grid__filters | The filter row under the header, drawn when a column declares a filter and filter-row is not hidden. |
.hui-grid__align-end | A column aligned to the end: the default for numbers. |
.hui-grid__align-center | A column aligned to the centre. |
.hui-grid__select | The selection column in selection="multiple", with the header’s select-all box. |
.hui-grid__check | The 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__empty | The row shown when no rows match: the host’s <template data-empty>, or empty-text. |
.hui-grid__error | The message the server sent as "error" instead of rows. |
.hui-grid__status | The 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.
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.
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.
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 bylabel, witharia-rowcountandaria-rowindexcounting 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 rowsaria-selected; the select-all box is arole="checkbox"that ismixedwhen 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
Highlightpair, and the focused cell a system outline.
Keyboard
| Keys | Action |
|---|---|
Arrows | The next cell. Up from the first row reaches the header; the filter row is skipped. |
Home, End | The first or last cell in the row. With Ctrl or Cmd, the first or last cell in the grid. |
Page Up, Page Down | Ten rows up or down. |
Enter, Space on a header | Cycles the sort; with Shift, adds the column as another key. Space on the select-all box selects or clears the page. |
Space on a row | Selects the row; in multiple, toggles it. |
Shift + Up, Down | In multiple, extends the selection from the anchor. |
Ctrl or Cmd + A | In multiple, selects every row on the page. |
Enter on a cell | Activates the cell’s link or button, or emits hui-grid-activate. |
Alt + Left, Right on a header | Narrows or widens the column by 16px. |
Gotchas
- Nothing is virtualised: every row on the page is rendered. In
clientmode that is every row matching the query unlesspage-sizeis set. - A
clientsort uses the browser’s collation, which can differ from the database’s. If the order must match the server exactly, useservermode. - 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, ashtml/templatedoes inside<script>.