Playground
A table for rows the server keeps: a sticky header, sorting (on the server, or in the table when it has every row), row selection, and a virtual scroll the server drives. The table asks for the rows in view (plus a buffer), the server sends that window, and a table of a million rows ships a few hundred of them.
The rows scroll in an sb-virtual-scroll, after the virtual scroll in Anders Murphy's hyperlith. Every row has the same height (row-height), so the scroll position alone tells which rows are in view. When the view comes halfway into the buffer, the table emits sb-window with the rows it wants; until they arrive, the rows it lacks show as placeholders.
Examples
100,000 stars from the server
The first 100,000 stars of the site's made-up star catalog, stored in SQLite so the server can sort them (/demo/data/rows). Scroll, drag the scrollbar to the middle, click a header to sort, and pick rows. The page never holds more than a few hundred rows:
- On connect, and whenever the view needs rows it doesn't have, the table emits
sb-windowwith{offset, count}and the order to send them in (key,dir). - A header click emits
sb-sortwith the order asked for (key,dir) and the window to answer with (offset0,count). - Both run
@get('/demo/data/rows?…&into=_stars'), and the server patches$_starswith{rows, offset, total, sort}. data-attrhands them back. A newsortscrolls to the top. Answers can land out of order: after a header click, the table takes rows only in the order it asked for, and drops late answers in the order it replaced.
data-indicator sets loading while a request is in flight.
<div data-signals="{_stars: {rows: [], offset: 0, total: 0, sort: {key: '', dir: ''}}, _loading: false, _picked: []}" style="display: grid; gap: 12px">
<sb-data-table label="Star catalog" selection="multiple" style="block-size: 22rem"
columns='[{"key":"name","label":"Name","sortable":true},{"key":"class","label":"Class","width":"6rem","sortable":true},{"key":"constellation","label":"Constellation","sortable":true},{"key":"distance","label":"Distance (ly)","align":"end","sortable":true},{"key":"magnitude","label":"Magnitude","align":"end","width":"7.5rem","sortable":true},{"key":"planets","label":"Planets","align":"end","width":"6rem"}]'
data-attr="{rows: JSON.stringify($_stars.rows), offset: $_stars.offset, total: $_stars.total, sort: JSON.stringify($_stars.sort), loading: $_loading}"
data-preserve-attr="rows offset total sort loading"
data-indicator:_loading
data-on:sb-window="@get('/demo/data/rows?into=_stars&' + new URLSearchParams(evt.detail))"
data-on:sb-sort="@get('/demo/data/rows?into=_stars&' + new URLSearchParams(evt.detail))"
data-on:sb-change="$_picked = evt.detail.value"></sb-data-table>
<span>Selected: <b data-text="$_picked.join(', ') || 'nothing'"></b></span>
</div>
Without Datastar signals, the server can also render the element with the first window in its attributes (rows, offset, total, sort) and morph in the next ones. A table that arrives with the rows in view asks for nothing on connect.
A small table
With all rows given and no total, the table has everything and never asks the server. A click on a sortable header sorts the rows right here, and the table still emits sb-sort for a page that wants to keep the choice.
<sb-data-table label="Moons of Jupiter" selection="single" style="inline-size: min(100%, 30rem)"
columns='[{"key":"name","label":"Moon","sortable":true},{"key":"radius","label":"Radius (km)","align":"end","sortable":true},{"key":"found","label":"Found","align":"end","width":"6rem","sortable":true}]'
rows='[{"id":"io","name":"Io","radius":1821.6,"found":1610},{"id":"europa","name":"Europa","radius":1560.8,"found":1610},{"id":"ganymede","name":"Ganymede","radius":2634.1,"found":1610},{"id":"callisto","name":"Callisto","radius":2410.3,"found":1610},{"id":"amalthea","name":"Amalthea","radius":83.5,"found":1892}]'></sb-data-table>
Server side
The handler behind the first example is Go with the Datastar SDK; any SDK works the same way. It reads the window and the order from the query, and demoAnswer patches the signal named by into (it also serves plain JSON and the demo's delay). The answer carries the order it used, so the header shows what the rows are, not what was clicked:
// demoRows answers a table's window and sort requests (sb-data-table's
// sb-window and sb-sort) with ?count= stars from ?offset= (at most 500) in the
// order asked for (?key=, ?dir=), and says which order it used.
func (s *Server) demoRows(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
offset, _ := strconv.Atoi(q.Get("offset"))
count, err := strconv.Atoi(q.Get("count"))
if err != nil {
count = 100
}
offset, count = max(offset, 0), min(max(count, 1), 500)
key, dir := q.Get("key"), q.Get("dir")
if !queries.DemoStarSortable(key) {
key, dir = "", ""
} else if dir != "desc" {
dir = "asc"
}
var stars []demo.Star
var total int
if err := s.q.View(r.Context(), func(rd *queries.Reader) (err error) {
stars, total, err = rd.DemoStars(r.Context(), key, dir == "desc", offset, count)
return
}); err != nil {
s.fail(w, r, err)
return
}
window := map[string]any{"rows": stars, "offset": offset, "total": total, "sort": map[string]string{"key": key, "dir": dir}}
s.demoAnswer(w, r, "_rows", window, window)
}
// demoAnswer writes the list as JSON, or patches it into the requested signal.
func (s *Server) demoAnswer(w http.ResponseWriter, r *http.Request, defaultSignal string, list, patch any) {
w.Header().Set("Access-Control-Allow-Origin", "*") // public; used from the playground sandbox
if ms, _ := strconv.Atoi(r.URL.Query().Get("delay")); ms > 0 {
select {
case <-time.After(time.Duration(min(ms, 1500)) * time.Millisecond):
case <-r.Context().Done():
return
}
}
// Datastar's own requests accept JSON too: they always get the patch.
if r.Header.Get("Datastar-Request") == "" && strings.Contains(r.Header.Get("Accept"), "application/json") {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
json.NewEncoder(w).Encode(list)
return
}
into := r.URL.Query().Get("into")
if into == "" {
into = defaultSignal
}
if !signalNameRe.MatchString(into) {
http.Error(w, "into must be a signal name", http.StatusBadRequest)
return
}
datastar.NewSSE(w, r).MarshalAndPatchSignals(map[string]any{into: patch})
}
The query asks SQLite for one window in one order. Every sortable column has an index, and the id breaks ties, so the windows of one order fit together. OFFSET walks the index: the last window of 100,000 rows takes about a millisecond.
// starOrder maps a sort key of the star catalog to its column: the class
// sorts by temperature.
var starOrder = map[string]string{
"name": "name", "class": "temp", "constellation": "constellation",
"distance": "distance", "magnitude": "magnitude",
}
// DemoStars returns up to count stars from offset, sorted by sort (a key of
// starOrder, else the catalog's own order), and how many stars there are.
func (r *Reader) DemoStars(ctx context.Context, sort string, desc bool, offset, count int) ([]demo.Star, int, error) {
var total int
if err := r.tx.QueryRowContext(ctx, `SELECT count(*) FROM demo_stars`).Scan(&total); err != nil {
return nil, 0, err
}
col, dir := starOrder[sort], " ASC"
if col == "" {
col = "id"
}
if desc {
dir = " DESC"
}
// Ties in id order (the indexes hold it), so every window of one order fits the next.
rows, err := r.tx.QueryContext(ctx, `SELECT id, name, class, temp, constellation, distance, magnitude, planets FROM demo_stars
ORDER BY `+col+dir+`, id`+dir+` LIMIT ? OFFSET ?`, count, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []demo.Star{}
for rows.Next() {
var s demo.Star
if err := rows.Scan(&s.ID, &s.Name, &s.Class, &s.Temp, &s.Constellation, &s.Distance, &s.Magnitude, &s.Planets); err != nil {
return nil, 0, err
}
out = append(out, s)
}
return out, total, rows.Err()
}
With commands
Give it a name and a selection, and it emits sb-change with { name, value } (an array of row keys) when the selection changes: ready to post as a command. With confirm, it sets :state(pending) until the server's re-rendered selected matches, and revert() goes back to the server's selection when a command is rejected. See Commands and components.
A new selected from the server always wins, and selected="[]" clears it. Markup re-sent with the same selected leaves the user's selection alone. The order (sort) is not part of the value: it only ever comes from the server.
Columns and rows
columns:[{key, label?, width?, align?, sortable?}].widthis a CSS grid track ("8rem","minmax(4rem, 2fr)", a number for px; defaultminmax(6rem, 1fr)). Widths whose minimum is a length keep the columns steady while rows come and go; a table wider than its box scrolls sideways.alignisstart,centerorend.rows: objects keyed by column. Numbers show in the reader's format (4,242.5), everything else as text. The field named byrow-key(defaultid) identifies a row.offsetis the index of the first row inrows, andtotalthe number of rows there are. Withouttotal, the table has what it was given.- Sorting: a table that holds every row (
offset0, and nototalor one no larger than the rows given) sorts them itself when a sortable header is clicked. Numbers sort by value, text in the reader's order (with numbers inside it by value, so "Io 2" comes before "Io 10"), and missing values last. It still emitssb-sort, a newsortfrom the server wins, and rows the server sends in are sorted the same way, so their order doesn't jump. A table that holds only a window asks, and the server sends the rows in the new order. row-heightis fixed (default36px), andbuffer(default4000px of rows) is how much the table keeps ready above and below the view.- A server that sends fewer rows than asked for (a cap) still fills the view: the table then asks for windows that fit the cap.
- Browsers cap how tall an element can be, Firefox at about 17.9 million px (Chrome and Safari at about 33.5 million). Keep rows ×
row-heightunder that: 100,000 rows of 36 px are 3.6 million px.
Forms
sb-data-table is not a form-associated element: a <form> doesn't submit its selection, and FormData and Datastar's contentType: 'form' don't see it. Send the selection as a command instead: sb-change carries { name, value } (see With commands).
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size: it fills the width it is given and is at most
24remtall; setblock-sizeormax-block-sizeon the element to change that. Rows arerow-heighttall. Setfont-sizeon the element (default0.875rem) to scale the text. - Fonts: the header and the cells use your page's font.
- Colours: the table is
--sb-surface-cardwith a--sb-borderframe, rows are separated by--sb-border-subtlelines, and placeholders are sb-virtual-scroll's--sb-surface-hoverbars. The header is--sb-surface-raisedwith--sb-text-2labels; cells are--sb-text-1. A row turns--sb-surface-hoveron hover; a selected row is--sb-brand-subtlewith a--sb-brandedge. The focus ring and the sort arrow are--sb-brand-light. Corners are--sb-radius, or pixel notches while--sb-notchis 1. - Parts:
grid(the sb-virtual-scroll that scrolls),header(the header row),column(a header cell),rowandcell. Selected rows are alsoselected, so::part(row selected)styles only those. Your page's::part()rules win over the component's own, without!important.
<style>
.my-table { --sb-brand: var(--sb-accent); }
.my-table::part(header) { text-transform: uppercase; font-size: 0.75rem; }
.my-table::part(row selected) { font-weight: 700; }
</style>
<sb-data-table class="my-table" label="Planets" selection="single" selected='["earth"]' row-height="40"
columns='[{"key":"name","label":"Planet"},{"key":"moons","label":"Moons","align":"end"}]'
rows='[{"id":"venus","name":"Venus","moons":0},{"id":"earth","name":"Earth","moons":1},{"id":"mars","name":"Mars","moons":2}]'></sb-data-table>
Accessibility
It follows the WAI-ARIA grid pattern:
- Structure: a
gridwitharia-rowcountfor every row there is (plus the header), andaria-rowindexon each rendered row, so a screen reader knows where it is in the whole table, not in the few rows the page holds. Sorted headers havearia-sort; with aselection, rows havearia-selected. The grid isaria-busywhileloadingis set. - Focus: one cell at a time is in the tab order: the first header, then the cell focused last, or the header again once that cell's row is gone. Sortable headers are buttons.
- Keys: the arrows move between cells, Home and End to the first and last cell of the row, Ctrl+Home to the first header, Ctrl+End to the last cell of the last row, Page Up and Page Down by a view. Moving past the rendered rows scrolls there, and the focus lands when the rows arrive. Space selects or unselects the row, Enter emits
sb-row-activate(so does a double click, which selects only once). On a sortable header, Enter or Space sorts.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later.
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-data-table label="Planets" columns='[{"key":"name","label":"Planet"},{"key":"moons","label":"Moons","align":"end"}]' rows='[{"id":"earth","name":"Earth","moons":1},{"id":"mars","name":"Mars","moons":2}]'></sb-data-table>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes. It renders <sb-virtual-scroll>, so that is pinned here too.
<script type="importmap">
{
"imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": {
"https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG",
"https://starbase.zweiundeins.gmbh/c/data-table@1f02b2398364/data-table.min.js": "sha384-uq6i9NkMjMG8WV0na7/qlQBgLDy2PKaixleT90Uri5NeJiOQJ/pssumF8evNYjyi",
"https://starbase.zweiundeins.gmbh/c/virtual-scroll@37b12bfee9a2/virtual-scroll.min.js": "sha384-bFcx0EoC34mIg1yPxLBlfm2whzOxwekW2m48EbLys9d3pZ7Jh6p+sVeKPVA/S0jh"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/data-table@1f02b2398364/data-table.min.js" integrity="sha384-uq6i9NkMjMG8WV0na7/qlQBgLDy2PKaixleT90Uri5NeJiOQJ/pssumF8evNYjyi"></script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/virtual-scroll@37b12bfee9a2/virtual-scroll.min.js" integrity="sha384-bFcx0EoC34mIg1yPxLBlfm2whzOxwekW2m48EbLys9d3pZ7Jh6p+sVeKPVA/S0jh"></script>
<sb-data-table label="Planets" columns='[{"key":"name","label":"Planet"},{"key":"moons","label":"Moons","align":"end"}]' rows='[{"id":"earth","name":"Earth","moons":1},{"id":"mars","name":"Mars","moons":2}]'></sb-data-table>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": {
"https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG",
"https://starbase.zweiundeins.gmbh/c/data-table@1f02b2398364/data-table.min.js": "sha384-uq6i9NkMjMG8WV0na7/qlQBgLDy2PKaixleT90Uri5NeJiOQJ/pssumF8evNYjyi",
"https://starbase.zweiundeins.gmbh/c/virtual-scroll@37b12bfee9a2/virtual-scroll.min.js": "sha384-bFcx0EoC34mIg1yPxLBlfm2whzOxwekW2m48EbLys9d3pZ7Jh6p+sVeKPVA/S0jh"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@5fa59f8dc44a/autoloader.js" integrity="sha384-uIkM/3WJfrJGqPA5t7gqz6wwMDPADCPt9hxjY8Iax8QzMQbEnlfRNoTA5xCgZcTU"></script>
<sb-data-table label="Planets" columns='[{"key":"name","label":"Planet"},{"key":"moons","label":"Moons","align":"end"}]' rows='[{"id":"earth","name":"Earth","moons":1},{"id":"mars","name":"Mars","moons":2}]'></sb-data-table>Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<sb-data-table><sb-virtual-scroll>
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/data-table/data-table.min.js"></script>
<script type="module" src="/js/virtual-scroll/virtual-scroll.min.js"></script>
<sb-data-table label="Planets" columns='[{"key":"name","label":"Planet"},{"key":"moons","label":"Moons","align":"end"}]' rows='[{"id":"earth","name":"Earth","moons":1},{"id":"mars","name":"Mars","moons":2}]'></sb-data-table>Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
data-table.js | 21.4 kB | 8.0 kB | 7.1 kB | 4.1 kB |
<sb-virtual-scroll> renders it | 9.0 kB | 3.7 kB | 3.2 kB | 1.7 kB |
| Total | 30.4 kB | 11.7 kB | 10.2 kB | 5.8 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
columns | json | [] | The columns: [{key, label?, width?, align?, sortable?}]. width is a CSS grid track (a number is px; default minmax(6rem, 1fr)); align is start, center or end. |
rows | json | [] | The rows at hand, objects keyed by column: all of them, or the window the server sent, starting at row offset. |
offset | number | 0 | Index of the first row in rows. |
total | number | 0 | How many rows there are (at least offset plus the rows given). |
row-key | string | "id" | The field that identifies a row, for the selection and sb-row-activate. |
row-height | number | 36 | The height of every row, in px: fixed, so the scroll position tells which rows are in view. |
buffer | number | 4000 | How much to keep ready above and below the view, in px of rows. |
sort | json | {} | The order the rows are in: {key, dir: "asc" | "desc"}. The server sends it with the rows. When the table holds every row (offset 0, no more than total), a header click sorts them here too; otherwise it asks for the order (sb-sort). A new sort from the server wins. |
loading | boolean | false | Rows are on their way (bind it to data-indicator): the table is aria-busy. |
selection | "none" | "single" | "multiple" | "none" | How many rows can be selected. |
selected | json | [] | The selection: a JSON array of row keys. A new list from the server replaces it; the live value is the selected property. |
label | string | "Table" | Accessible name. |
confirm | boolean | false | Server-confirmed selection: :state(pending) while the local selection differs from the server's selected attribute (see revert()). |
name | string | "" | Name reported in sb-change (e.g. the field of a command). |
Events
| Name | Description |
|---|---|
sb-window | The view needs rows the table doesn't have (also on connect and on resize): detail { offset, count }, plus key and dir of the order to send them in. Answer with rows from that offset, offset and total. |
sb-sort | A sortable header was clicked. detail: { key, dir }, plus the window to answer with (offset 0, count). Answer with those rows in that order, and the new sort; a table that holds every row has sorted them already. |
sb-row-activate | Enter or a double click on a row. detail: { key }. |
change | The selection changed. |
sb-change | The selection changed. detail: { name, value } (an array of row keys): ready for a command. |