elements
Demos UI Feed Discuss Install
Style System
  • Style
  • Themes
  • Behaviors
Forms
  • Button
  • ButtonGroup
  • Checkbox
  • ColorPicker
  • Combobox
  • Field
  • FileUpload
  • Input
  • InputGroup
  • InputOTP
  • Label
  • NativeSelect
  • NumberInput
  • PasswordInput
  • RadioGroup
  • Select
  • Slider
  • Switch
  • TagsInput
  • Textarea
  • Toggle
  • ToggleGroup
Overlays
  • AlertDialog
  • Command
  • ContextMenu
  • Dialog
  • Drawer
  • DropdownMenu
  • HoverCard
  • Menubar
  • NavigationMenu
  • Popover
  • Sheet
  • Toast
  • Tooltip
Display
  • Accordion
  • Alert
  • AspectRatio
  • Avatar
  • Badge
  • Breadcrumb
  • Card
  • Collapsible
  • Empty
  • Item
  • Kbd
  • Pagination
  • Progress
  • Separator
  • Skeleton
  • Spinner
  • Table
  • Tabs
  • Tree
Layout
  • Carousel
  • Resizable
  • ScrollArea
  • Sidebar
Dates
  • Calendar
  • DatePicker
  • DateRangePicker
  • TimePicker
Data
  • AreaChart
  • BarChart
  • Chart
  • DataTable
  • Kanban
  • LineChart
  • PieChart
  • RadarChart
  • RadialChart
  • VirtualList
Chat
  • Attachment
  • Message
  • MessageList
  • PromptInput
Elements UI / Data / DataTable

DataTable

A data grid: sort, search, filter, resize, reorder, pin, group, select, edit, page or virtualize, save views and export.

Payments

A payments table: select rows, sort from the header menu, filter by status, search, hide columns, page through, open a row's actions.

Processing
monserrat18@hotmail.com
$838.47
Success
monserrat34@hotmail.com
$335.73
Pending
carmella20@example.com
$886.27
carmella36@example.com
$108.30
$488.24
$451.55
$720.60
$834.42
$903.28
$553.98
$656.46
$684.15
Actions
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
Processing
ada@hotmail.com
$488.24
Success
grace@yahoo.com
$451.55
Failed
alan@example.com
$7.84
Pending
edsger@gmail.com
$34.05
0 of 42 row(s) selected.
Page 1 of 5
selected: 0 · sorting: [] · page: 0 · size: 10 · search: ·
Code
const COLUMNS: DataTableColumn[] = [
  { key: "status", type: "tag", options: STATUSES, facet: "select" },
  { key: "email", header: "Email" },
  { key: "amount", type: "currency" },
];

const ACTIONS: RowAction[] = [
  { id: "copy", label: "Copy payment ID", icon: "copy", onSelect: (row) => copy(row.id) },
  { id: "customer", label: "View customer", separator: true, onSelect: (row) => go(row) },
  { id: "details", label: "View payment details", onSelect: (row) => open(row) },
];

<DataTable data={payments} columns={COLUMNS} label="Payments"
           selection="multiple" selected={state.selected} actions={ACTIONS}
           groupedColumnMode="reorder" />

Usage

import { DataTable, DataTableColumn, RowAction } from "@elements/ui/data-table";

const COLUMNS: DataTableColumn[] = [
  { key: "status", type: "tag", options: STATUSES, facet: "select" },
  { key: "email", header: "Email", rowHeader: true },
  { key: "amount", type: "currency", summary: "sum", editable: true },
];

<DataTable data={payments} columns={COLUMNS} label="Payments"
           selection="multiple" selected={state.selected}
           actions={ACTIONS} onSave={(changes) => persist(changes)} />

<!-- a LiveView stays live: rows patch in place, edits call payments.update() -->
<DataTable data={payments} columns={COLUMNS} editMode="direct" />

Examples

Every feature

Faceted filters, the filter builder with nested filter groups (Add group), multi-sort (Shift), resize, drag to reorder, pin, hide, group, density, saved views, CSV export, a summary row and a sticky header.

Katherine Hamilton katherine.hamilton104@example.com
Platform
Engineer
Offline
6
$160,000
May 22, 2021
Yes
59%
Katherine Hamilton katherine.hamilton14@example.com
Support
Designer
Active
4
$168,000
May 17, 2018
No
26%
Katherine Hamilton katherine.hamilton32@example.com
Growth
Manager
Away
5
$126,000
May 20, 2023
67%
2
$139,000
May 21, 2016
16%
1
$142,000
May 13, 2018
10%
3
$174,000
Mar 15, 2016
55%
$130,000
Mar 19, 2019
82%
$140,000
Mar 24, 2016
23%
$158,000
Mar 13, 2023
85%
$161,000
Mar 23, 2019
74%
$149,000
Mar 22, 2021
61%
$133,000
Mar 20, 2017
83%
Average: 3.3
Sum: $13,820,000
Ada Lovelace ada.lovelace0@example.com
Platform
Engineer
Active
6
$160,000
Feb 2, 2020
Yes
59%
Grace Thompson grace.thompson1@example.com
Support
Manager
Active
6
$168,000
Jun 24, 2016
Yes
26%
Alan Johnson alan.johnson2@example.com
Design
Writer
Away
4
$126,000
Jun 3, 2016
Yes
6%
Edsger Dijkstra edsger.dijkstra3@example.com
Data
Designer
Offline
4
$139,000
Jan 14, 2022
Yes
67%
Barbara Perlman barbara.perlman4@example.com
Growth
Analyst
Active
5
$142,000
Jan 13, 2023
Yes
16%
Donald Shannon donald.shannon5@example.com
Platform
Designer
Active
6
$174,000
Sep 1, 2024
No
10%
Frances Allen frances.allen6@example.com
Support
Analyst
Active
4
$130,000
Nov 1, 2020
No
55%
Ken Torvalds ken.torvalds7@example.com
Design
Engineer
Away
2
$91,000
Nov 20, 2021
Yes
82%
Margaret Turing margaret.turing8@example.com
Data
Manager
Offline
1
$67,000
Jul 23, 2022
No
23%
Dennis Ritchie dennis.ritchie9@example.com
Growth
Writer
Active
5
$140,000
Aug 25, 2024
No
85%
Radia Lamarr radia.lamarr10@example.com
Platform
Manager
Active
1
$80,000
Sep 15, 2021
Yes
4%
Tim Knuth tim.knuth11@example.com
Support
Writer
Active
1
$71,000
Dec 18, 2018
Yes
1%
Sophie Wilson sophie.wilson12@example.com
Design
Designer
Away
4
$126,000
Aug 19, 2016
No
74%
Linus Hopper linus.hopper13@example.com
Data
Analyst
Offline
5
$158,000
Apr 26, 2022
Yes
61%
Katherine Hamilton katherine.hamilton14@example.com
Growth
Engineer
Active
2
$88,000
Jan 16, 2017
No
2%
John Backus john.backus15@example.com
Platform
Analyst
Active
6
$161,000
Sep 7, 2016
No
2%
Hedy Liskov hedy.liskov16@example.com
Support
Engineer
Active
1
$65,000
Feb 2, 2020
Yes
5%
Claude Lee claude.lee17@example.com
Design
Manager
Away
1
$61,000
Feb 19, 2023
Yes
26%
Ada Lovelace ada.lovelace18@example.com
Data
Writer
Offline
5
$149,000
Jul 5, 2018
No
83%
Grace Thompson grace.thompson19@example.com
Growth
Designer
Active
4
$133,000
Oct 3, 2023
No
25%
Average: 3.3
Sum: $13,820,000
0 of 120 row(s) selected.
Page 1 of 6
refreshed 0 · · density default · view none · pinned | · facets {}
Code
<DataTable data={people} columns={COLUMNS} label="People" height="28rem"
           selection="multiple" pageSize={20} pageJump
           facets={state.facets} filters={state.filters} pinned={state.pinned}
           sizes={state.sizes} hidden={state.hidden} order={state.order}
           views={state.views} view={state.view} density={state.density}
           onStateChange={(s) => save(s)} />

<!-- Filter groups: a rule with rules stands as one rule, joined inside by its own join. -->
filters = [
  { id: "r1", column: "salary", op: "gt", value: "150000" },
  { id: "g1", column: "", op: "contains", value: "", join: "or", rules: [
    { id: "r2", column: "level", op: "equals", value: "5" },
    { id: "r3", column: "level", op: "equals", value: "6" },
  ] },
];

Grouped rows and column groups

Rows grouped by team, with aggregates (mean level, total salary) on the group rows; Work and Pay are column groups over their columns. The search is fuzzy: the letters in order. columnResizeMode=onEnd: a resize handle previews and the column resizes on release.

Katherine Hamilton
Platform
Engineer
6
$160,000
Barbara Perlman
Support
Designer
4
$168,000
Grace Thompson
Growth
Manager
5
$126,000
Platform (40)
2
$139,000
Support (40)
1
$142,000
Growth (40)
3
$174,000
$130,000
$140,000
$158,000
$161,000
$149,000
$133,000
3.2
$4,474,000
Work
Pay
Platform (8)
3.4
$914,000
Support (8)
3.3
$912,000
Design (8)
2
$693,000
Data (8)
3.6
$975,000
Growth (8)
3.6
$980,000
40 rows
grouping: team
Code
<!-- group by team; numbers aggregate (sum, mean, min), headers group columns -->
<DataTable data={people} columns={[
  { key: "name", rowHeader: true },
  { header: "Work", id: "work", columns: [{ key: "team" }, { key: "role" }] },
  { header: "Pay", id: "pay", columns: [{ key: "level", type: "number" }, { key: "salary", type: "currency" }] },
]} grouping={state.grouping} columnResizeMode="onEnd" />

Grouped column removed

groupedColumnMode="remove": the column a table is grouped by leaves the grid, and each group row shows its value in the first column.

monserrat@hotmail.com
$838.47
carmella@example.com
$335.73
alan@example.com
$886.27
Processing (10)
$108.30
Success (10)
$488.24
Pending (10)
$451.55
$3,301.40
Success (4)
$1,734.05
Processing (2)
$581.85
Failed (2)
$65.18
Pending (2)
$920.32
10 rows
grouping: status
Code
<DataTable data={payments} columns={COLUMNS} grouping={["status"]} groupedColumnMode="remove" />

Inline editing with a changeset

Enter, F2 or typing edits a cell; Enter commits, Escape restores. Edits wait in changes (two-way) until Save; invalid values are flagged. Add row inserts one.

Sum: 19
Sum: 19
5 rows
0 unsaved changes
pending: 0 · saved: 0 · first: Write the release notes · rows: 5 · discarded: 0 · added: 0 · new saved: 0
Code
const COLUMNS: DataTableColumn[] = [
  { key: "title", editable: true, validate: (v) => v ? "" : "A title is required" },
  { key: "priority", type: "tag", options: PRIORITIES, editable: true },
  { key: "estimate", type: "number", editable: true, summary: "sum" },
  { key: "due", type: "date", editable: true },
  { key: "done", type: "boolean", editable: true },
];

<!-- edits and added rows wait until Save; onSave gets them all -->
<DataTable data={state.tasks} columns={COLUMNS} changes={state.changes}
           addable newRow={() => ({ id: crypto.randomUUID(), title: "", estimate: 0, done: false })}
           onSave={(changes, newRows) => persist(changes, newRows)} />

Direct editing

editMode="direct": every commit writes the row at once, the way a LiveView update reaches every browser.

Sum: 19
Sum: 19
5 rows
0 unsaved changes
edits: 0 · first: Write the release notes · estimate: 3 · done: false
Code
<!-- editMode="direct": each edit writes the row at once (a LiveView updates for everyone) -->
<DataTable data={todos} columns={COLUMNS} editMode="direct" onCellEdit={(c) => log(c)} />

Tree rows

subRows makes a row expandable; children indent under it. With selection, checking a parent checks its sub-rows, and a parent with only some of them checked shows mixed.

about.ehtml
folder
12.5 KB
Sep 12, 2026
home.ehtml
file
4.0 KB
Oct 20, 2026
styles.css
2.2 KB
Oct 21, 2026
Oct 18, 2026
Oct 22, 2026
app
folder
Oct 20, 2026
config.jsoc
file
1.4 KB
Oct 1, 2026
README.md
file
2.9 KB
Sep 12, 2026
0 of 8 row(s) selected.
Code
<DataTable data={FILES} columns={COLUMNS} subRows={(row) => row.children} expanded={state.open}
           selection="multiple" pagination={false} />

Row details

detail adds an expand button; the open row (two-way detailRow) shows the detail slot under it.

Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
ken@example.com
$886.27
Expand
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
5 rows
Code
<DataTable data={payments} columns={COLUMNS} detail detailRow={state.open}>
  <div slot="detail">Payment {state.open?.id} to {state.open?.email}</div>
</DataTable>

Pinned rows

rowPinning adds Pin to top and Pin to bottom to each row's menu; pinned rows stay in view while the rest scroll.

Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
barbara@hotmail.com
$886.27
$108.30
$488.24
$451.55
$720.60
$834.42
Actions
Success
monserrat@hotmail.com
$335.73
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
Processing
ada@hotmail.com
$488.24
Success
grace@yahoo.com
$451.55
Failed
alan@example.com
$7.84
Pending
edsger@gmail.com
$34.05
Success
barbara@hotmail.com
$720.60
Processing
donald@yahoo.com
$834.42
12 rows
top: pay-003 · bottom:
Code
<DataTable data={payments} columns={COLUMNS} rowPinning pinnedRows={state.pinnedRows} actions={ACTIONS} />

Ten thousand rows

virtual renders only the rows in view; sorting and search run over all of them.

emp-1
Ada Lovelace
Platform
$160,000
Feb 2, 2020
emp-2
Grace Thompson
Support
$168,000
Jun 24, 2016
emp-3
Alan Johnson
Design
$126,000
Jun 3, 2016
emp-4
Edsger Dijkstra
Data
$139,000
Jan 14, 2022
emp-5
Barbara Perlman
Growth
$142,000
Jan 13, 2023
emp-6
Donald Shannon
Platform
$174,000
Sep 1, 2024
emp-7
Frances Allen
Support
$130,000
Nov 1, 2020
emp-8
Ken Torvalds
Design
$91,000
Nov 20, 2021
emp-9
Margaret Turing
Data
$67,000
Jul 23, 2022
emp-10
Dennis Ritchie
Growth
$140,000
Aug 25, 2024
emp-11
Radia Lamarr
Platform
$80,000
Sep 15, 2021
emp-12
Tim Knuth
Support
$71,000
Dec 18, 2018
emp-13
Sophie Wilson
Design
$126,000
Aug 19, 2016
emp-14
Linus Hopper
Data
$158,000
Apr 26, 2022
emp-15
Katherine Hamilton
Growth
$88,000
Jan 16, 2017
emp-16
John Backus
Platform
$161,000
Sep 7, 2016
emp-17
Hedy Liskov
Support
$65,000
Feb 2, 2020
emp-18
Claude Lee
Design
$61,000
Feb 19, 2023
emp-19
Ada Lovelace
Data
$149,000
Jul 5, 2018
emp-20
Grace Thompson
Growth
$133,000
Oct 3, 2023
emp-21
Alan Johnson
Platform
$62,000
Mar 15, 2016
emp-22
Edsger Dijkstra
Support
$63,000
Nov 23, 2016
emp-23
Barbara Perlman
Design
$66,000
Jan 7, 2022
emp-24
Donald Shannon
Data
$91,000
Jan 23, 2021
emp-25
Frances Allen
Growth
$159,000
Sep 2, 2016
emp-26
Ken Torvalds
Platform
$90,000
Aug 1, 2016
emp-27
Margaret Turing
Support
$144,000
Jan 20, 2016
emp-28
Dennis Ritchie
Design
$93,000
Jan 17, 2018
emp-29
Radia Lamarr
Data
$83,000
Jan 1, 2023
emp-30
Tim Knuth
Growth
$115,000
Apr 1, 2018
10000 rows
end reached: 0
Code
<!-- 10,000 rows, only the visible ones rendered -->
<DataTable data={rows} columns={COLUMNS} virtual pagination={false} height="24rem" rowHeight={40} />

Live rows

Rows that change in place (a LiveView patching a row another browser wrote) re-sort and re-total by themselves. An edit goes through the view's update(). columnMenu=false: a header click sorts.

updates: 0 · writes: 0
monserrat@hotmail.com
Processing
carmella@example.com
Success
ken@example.com
Pending
Sum: $3,259.51
carmella@example.com
Pending
ken@example.com
Success
ada@hotmail.com
Processing
grace@yahoo.com
Success
monserrat@hotmail.com
Success
jason@gmail.com
Success
abe@gmail.com
Processing
silas@yahoo.com
Failed
Sum: $3,259.51
8 rows
0 unsaved changes
Code
// route: return new html({ payments: payments.view() });
<DataTable data={payments} columns={COLUMNS} editMode="direct" columnMenu={false}
           sorting={[{ id: "amount", desc: true }]} />
<!-- a LiveView patches rows in place; the table re-sorts and re-totals itself,
     and an edit calls payments.update(row) for every watching browser -->

Server-side data

manual hands sorting and paging to the server: the table shows the page it is given, rowCount sizes the pager, loading veils the rows.

Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
Processing
ada@hotmail.com
$488.24
Success
grace@yahoo.com
$451.55
Failed
alan@example.com
$7.84
Pending
edsger@gmail.com
$34.05
1-10 of 240
Page 1 of 24
page 0 · first pay-001 · loading false
Code
<!-- manual: the server sorts and pages; the table shows what it is given -->
<DataTable data={state.rows} columns={COLUMNS} manual rowCount={state.total}
           page={state.page} pageSize={state.pageSize} sorting={state.sorting}
           loading={state.loading} onStateChange={() => fetchPage(state)} />

Single selection

selection=single with selectOnClick: a click selects the row; double-click or Enter runs onRowAction. canSelect keeps failed payments out; rowTone tints them.

monserrat@hotmail.com
$838.47
carmella@example.com
$335.73
ken@example.com
$886.27
$108.30
Select
ken@example.com
$838.47
abe@gmail.com
$93.61
monserrat@hotmail.com
$335.73
silas@yahoo.com
$57.34
carmella@example.com
$886.27
jason@gmail.com
$108.30
6 rows
selected: · action: · clicks: 0
Code
<DataTable data={payments} columns={COLUMNS} selection="single" selectOnClick
           selected={state.selected} canSelect={(row) => row.status !== "failed"}
           rowTone={(row) => row.status === "failed" ? "danger" : ""}
           onRowClick={(row) => preview(row)} onRowAction={(row) => open(row)} />

Controlled state

sorting, search and page are two-way: set them from outside and the table follows.

These drive the Payments table at the top of the page.

Code
<button onclick={() => state.sorting = [{ id: "amount", desc: true }]}>Largest first</button>
<button onclick={() => state.search = "gmail"}>Search gmail</button>
<button onclick={() => state.page = 2}>Page 3</button>
<DataTable data={payments} columns={COLUMNS} sorting={state.sorting} search={state.search} page={state.page} />

Empty and loading

No rows shows emptyText (or the empty slot); loading veils the rows with a spinner and keeps every size.

No payments yet.
0-0 of 0
Page 1 of 1
Processing
monserrat@hotmail.com
$838.47
Success
ken@example.com
$335.73
Failed
silas@yahoo.com
$93.61
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
4 rows
Code
<DataTable data={[]} columns={COLUMNS} emptyText="No payments yet." />
<DataTable data={payments} columns={COLUMNS} loading={state.loading} />

Column formats

Column formats: tags as badges, a time of day, a phone link, a star rating, JSON, an image, a BigInt and multiline text. Hours is editable within 0 to 40 (a value outside is corrected), its faceted filter counts rows by bucket, and its background marks long and short tickets (a conditional column background). Summary is Markdown: bold, italic, code, strikethrough and links, with any other markup shown as text.

Dark mode contrast
DocsBilling
11:17 AM
+1 555 0100
Blocked on the auth fix, see the runbook
{"channel":"email","priority":1}
9,007,199,254,740,993
Invoice missing
BugUrgent
10:08 AM
+1 555 0101
Resent the invoice <b>by mail</b>
{"channel":"email","priority":0}
9,007,199,254,741,993
Refund request
BillingUI
12:16 PM
+1 555 0102
Fixed the docs/start page
{"channel":"email","priority":2}
9,007,199,254,742,993
11:07 AM
+1 555 0103
9,007,199,254,743,993
+1 555 0104
9,007,199,254,744,993
+1 555 0105
9,007,199,254,745,993
+1 555 0106
9,007,199,254,746,993
+1 555 0107
9,007,199,254,747,993
+1 555 0108
9,007,199,254,748,993
+1 555 0109
9,007,199,254,749,993
+1 555 0110
9,007,199,254,750,993
+1 555 0111
9,007,199,254,751,993
Login fails
BugUrgent
8:00 AM
+1 555 0100
Blocked on the auth fix, see the runbook
{"channel":"chat","priority":0}
9,007,199,254,740,993
Short note.
Refund request
Billing
11:17 AM
+1 555 0101
Refund of $40 approved
{"channel":"email","priority":1}
9,007,199,254,741,993
Customer reports the issue on every attempt since the last release; asked for a call back.
Export is slow
Urgent
2:34 PM
+1 555 0102
Waiting shipped in 2.4
{"channel":"chat","priority":2}
9,007,199,254,742,993
Short note.
Typo in docs
UIBug
5:51 PM
+1 555 0103
Fixed the docs/start page
{"channel":"email","priority":0}
9,007,199,254,743,993
Customer reports the issue on every attempt since the last release; asked for a call back.
Dark mode contrast
Docs
10:08 AM
+1 555 0104
Needs a contrast check
{"channel":"chat","priority":1}
9,007,199,254,744,993
Short note.
Invoice missing
Bug
1:25 PM
+1 555 0105
Resent the invoice <b>by mail</b>
{"channel":"email","priority":2}
9,007,199,254,745,993
Customer reports the issue on every attempt since the last release; asked for a call back.
Login fails
BillingUI
4:42 PM
+1 555 0106
Blocked on the auth fix, see the runbook
{"channel":"chat","priority":0}
9,007,199,254,746,993
Short note.
Refund request
Urgent
9:59 AM
+1 555 0107
Refund of $40 approved
{"channel":"email","priority":1}
9,007,199,254,747,993
Customer reports the issue on every attempt since the last release; asked for a call back.
Export is slow
UI
12:16 PM
+1 555 0108
Waiting shipped in 2.4
{"channel":"chat","priority":2}
9,007,199,254,748,993
Short note.
Typo in docs
DocsBilling
3:33 PM
+1 555 0109
Fixed the docs/start page
{"channel":"email","priority":0}
9,007,199,254,749,993
Customer reports the issue on every attempt since the last release; asked for a call back.
Dark mode contrast
Bug
8:50 AM
+1 555 0110
Needs a contrast check
{"channel":"chat","priority":1}
9,007,199,254,750,993
Short note.
Invoice missing
Billing
11:07 AM
+1 555 0111
Resent the invoice <b>by mail</b>
{"channel":"email","priority":2}
9,007,199,254,751,993
Customer reports the issue on every attempt since the last release; asked for a call back.
12 rows
0 unsaved changes
Code
<DataTable data={tickets} columns={[
  { key: "labels", type: "tags", options: LABELS, facet: "select" },
  { key: "opened", type: "time" },
  { key: "phone", type: "phone" },
  { key: "rating", type: "rating" },
  { key: "hours", type: "number", editable: true, min: 0, max: 40, facet: "select",
    bucket: (v) => (v < 10 ? "Under 10" : v < 25 ? "10 to 25" : "25 and over"),
    background: (v) => (v >= 25 ? "danger" : v < 10 ? "success" : "") },
  { key: "summary", type: "markdown" },   // **bold**, _italic_, `code`, ~~strike~~, [links](https://...)
  { key: "meta", type: "json" },
  { key: "avatar", type: "image" },
  { key: "bytes", type: "bigint" },
  { key: "notes", type: "multiline" },
]} />

Sorting options

invertSorting puts first place on top of a descending sort; sortUndefined: first; sortDescFirst on Name; maxMultiSort=2 with Shift+click; sortRemoval=false cycles between ascending and descending. Code sorts with textCaseSensitive (capitals first) and Squad with alphanumericCaseSensitive (numbers in order, capitals first).

Barbara
3
241
B12
bronze
Gamma
Pace 10
Edsger
1
228
B21
silver
Alpha
pace 10
Grace
4
236
B3
gold
alpha
Pace 2
2
250
5
Ada
3
241
B12
bronze
beta
pace 10
Grace
1
228
B3
gold
Alpha
Pace 2
Alan
4
B7
alpha
pace 2
Edsger
2
236
B21
silver
Beta
Pace 10
Barbara
5
250
B5
Gamma
pace 1
5 rows
sorting: []
Code
<DataTable data={runners} columns={[
  { key: "name", sortDescFirst: true },
  { key: "place", type: "number", invertSorting: true },
  { key: "time", type: "number", sortUndefined: "first" },
  { key: "code", sortFn: "textCaseSensitive" },          // Alpha, Beta, alpha, beta
  { key: "squad", sortFn: "alphanumericCaseSensitive" }, // Pace 2, Pace 10, pace 1
]} columnMenu={false} maxMultiSort={2} sortRemoval={false} />

Reorder rows

rowReorder adds a drag handle: drag a row, or focus its handle and press Space, the arrows, then Space. An array is reordered in place.

monserrat@hotmail.com
$838.47
carmella@example.com
$335.73
ken@example.com
$886.27
$108.30
Reorder
ken@example.com
$838.47
abe@gmail.com
$93.61
monserrat@hotmail.com
$335.73
silas@yahoo.com
$57.34
carmella@example.com
$886.27
jason@gmail.com
$108.30
6 rows
moves: 0 · order: 001,002,003,004,005,006
Code
<DataTable data={rows} columns={COLUMNS} rowReorder onRowReorder={(id, to) => save(id, to)} />

Cursor paging

manual with rowCount -1: the total is unknown, so the pager shows the page alone, hasNextPage enables Next, and Last stays off (cursor-based server pagination).

Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
1-5
Page 1
page 0 · first pay-001
Code
<DataTable data={state.rows} columns={COLUMNS} manual rowCount={-1} hasNextPage={state.hasNext}
           page={state.page} onPageChange={(p) => fetchPage(p)} />

Cell selection

cellSelection: a click selects a cell; Shift+arrows, Shift+click or a drag select a range, Ctrl or Cmd+click adds a range or takes a selected cell out, Ctrl+A selects every cell, Ctrl+C copies them as tab separated text, Escape clears. cellRanges is two-way, and onTable reaches the engine's cell selection APIs.

Payment sheet

Shift+arrows or a drag select cells; Ctrl+Space selects a column; Ctrl+C copies.

Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
ken@example.com
$886.27
$108.30
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
6 rows
cells: ranges:
Code
<h3 id="sheet-title">Payment sheet</h3>
<DataTable data={payments} columns={COLUMNS} cellSelection labelledBy="sheet-title"
           cellRanges={state.ranges} onCellSelectionChange={(c, ranges) => state.cells = c}
           onTable={(api) => api.table.selectAllCells()} />

Spreadsheet

The spreadsheet example: cellSelection with editable columns. Type over a cell, or press Enter, F2 or double click to edit it; Enter commits and moves down, Tab moves right. Ctrl+V pastes tab separated text from the selection (one value fills it), Delete and Ctrl+X clear it, Ctrl+Z and Ctrl+Shift+Z undo and redo, and a right click opens Cut, Copy, Paste, Clear contents, Merge cells and Sort. The value bar shows the active cell's name and value and writes it back; drag the fill handle on the selection's corner to repeat values or continue a series (Ctrl+D and Ctrl+R fill down and right); Ctrl+M merges the selected cells; zoom scales the grid; each sheet keeps its own rows, merges and undo.

fx
Conference tickets
Jordan
Hardware
$4,200.00
Mar 16, 2026
Design tool seats
Priya
Software
$1,350.00
Jan 12, 2026
Cloud credits
Sam
Events
$2,800.00
Apr 20, 2026
$1,500.00
$3,000.00
Sum: $13,810.00
Laptops
Priya
Hardware
$4,200.00
Jan 5, 2026
Monitors
Sam
Hardware
$1,350.00
Jan 12, 2026
Design tool seats
Ana
Software
$960.00
Feb 2, 2026
Team offsite
Jordan
Events
$2,800.00
Mar 16, 2026
Conference tickets
Lee
Events
$1,500.00
Apr 1, 2026
Cloud credits
Priya
Software
$3,000.00
Apr 20, 2026
Sum: $13,810.00
6 rows
0 unsaved changes
100%
edits: 0 · sheet: sheet-1 · merges: []
Code
<!-- One table per sheet (the key remounts it, so each sheet keeps its own
     selection and undo). merges is two-way; zoom scales the grid. -->
<DataTable e:for={sheet of [activeSheet]} e:key={(s) => s.id}
           data={sheet.rows} merges={sheet.merges} mergeable valueBar zoom={zoom}
           cellSelection editMode="direct" columns={[
  { key: "item", header: "Item", editable: true },
  { key: "owner", header: "Owner", editable: true },
  { key: "category", header: "Category", editable: true },
  { key: "cost", header: "Cost", type: "currency", editable: true, summary: "sum" },
]} onTable={(t) => api = t} />

<!-- The selection's corner has a fill handle: drag it to repeat values or
     continue a series (100, 200 then 300). Ctrl+D and Ctrl+R fill down and right.
     Ctrl+M or the cell menu merges the selected cells, or unmerges them. -->
<button onclick={() => api.toggleMerge()}>Merge cells</button>
<button onclick={() => api.undo()}>Undo</button>

Cell selection options

cellSelectionDrag=false: a drag selects the cell it started on. cellRangeSelection=false: Shift+click starts over. Status opts out (cellSelection: false on the column). autoResetCellSelection: new data clears the selection.

Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
ken@example.com
$886.27
$108.30
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
6 rows
ranges: 0 · loads: 0
Code
<DataTable data={rows} columns={[{ key: "status", cellSelection: false }, ...]} cellSelection
           cellSelectionDrag={false} cellRangeSelection={false} autoResetCellSelection />

Cell spanning

Region merges equal adjacent values (spanRows: true), Month merges by a predicate (the same month), each total row's label runs to the end of the row (spanColumns: Infinity), Status opts out. A merged cell is one keyboard stop and is selected whole.

South
February 2026
South total: 89 units
44
Processing
North
January 2026
North total: 81 units
14
Success
Lamp
23
Pending
81
45
30
89
North
January 2026
Desk
44
Success
Chair
14
Processing
February 2026
Lamp
23
Success
North total: 81 units
South
January 2026
Chair
45
Pending
February 2026
Desk
14
Success
Shelf
30
Processing
South total: 89 units
8 rows
Code
<DataTable data={sales} cellSelection columns={[
  { key: "region", spanRows: true },
  { key: "month", spanRows: (row, anchor, value, anchorValue) => sameMonth(value, anchorValue) },
  { key: "product", spanColumns: (row) => (row.isSummary ? Infinity : 1) },
  { key: "units", type: "number" },
  { key: "status", spanning: false },
]} />

Table methods and events

The table's methods through onTable, and its events: Change Page, Change Filter, Click Cell, Select Cell, Blur, Expand Row, Discard.

Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
barbara@hotmail.com
$886.27
$108.30
$488.24
$451.55
$720.60
$834.42
Select
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
1-5 of 12
Page 1 of 3
selected: ·
Code
<DataTable data={payments} columns={COLUMNS} selection="single" onTable={(api) => table = api}
           onPageChange={...} onFiltersChange={...} onCellClick={...} onCellFocus={...} onBlur={...} />
table.selectNextRow(); table.setSort([{ id: "amount", desc: true }]); table.exportData({ format: "json" });

Summaries

summary puts an aggregate of the filtered rows in the footer: sum, min, max, mean, median, count, uniqueCount, extent, first, last and unique, a summary row.

Barbara
3
3
3
3
3
3
3
3
3
3
3
Edsger
1
1
1
1
1
1
1
1
1
1
1
Grace
4
4
4
4
4
4
4
4
4
4
4
2
2
2
2
2
2
2
2
2
2
2
5
5
5
5
5
5
5
5
5
5
5
Sum: 15
Min: 1
Max: 5
Average: 3
Median: 3
Count: 5
Distinct: 5
Range: 1 to 5
First: 3
Last: 5
Values: 3, 1, 4, 2, 5
sum
min
max
mean
median
count
uniqueCount
extent
first
last
unique
Ada
3
3
3
3
3
3
3
3
3
3
3
Grace
1
1
1
1
1
1
1
1
1
1
1
Alan
4
4
4
4
4
4
4
4
4
4
4
Edsger
2
2
2
2
2
2
2
2
2
2
2
Barbara
5
5
5
5
5
5
5
5
5
5
5
Sum: 15
Min: 1
Max: 5
Average: 3
Median: 3
Count: 5
Distinct: 5
Range: 1 to 5
First: 3
Last: 5
Values: 3, 1, 4, 2, 5
5 rows
Code
<DataTable data={runners} columns={[
  { key: "place", summary: "sum" },     // or min, max, mean, median, count,
  { key: "place", summary: "extent" },  // uniqueCount, unique, extent, first, last
]} />

Tree filtering

filterFromLeafRows keeps a folder when a file in it matches the search; paginateExpanded=false keeps a folder's open rows on its page.

about.ehtml
folder
12.5 KB
Sep 12, 2026
home.ehtml
file
4.0 KB
Oct 20, 2026
styles.css
2.2 KB
Oct 21, 2026
Oct 18, 2026
Oct 22, 2026
app
folder
Oct 20, 2026
config.jsoc
file
1.4 KB
Oct 1, 2026
1-2 of 3
Page 1 of 2
Code
<DataTable data={FILES} columns={FILE_COLUMNS} subRows={(r) => r.children}
           filterFromLeafRows paginateExpanded={false} pageSize={2} />

Modifier keys

isMultiSortEvent and isRowRangeSelectionEvent pick the modifier: here Alt+click on a header adds it to the sort and Alt+click on a checkbox selects the range from the last one. Below, isCellRangeSelectionEvent makes Alt+click extend a cell range and isMultiCellRangeSelectionEvent makes Shift+click add one.

Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
ken@example.com
$886.27
$108.30
$488.24
$451.55
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
Processing
ada@hotmail.com
$488.24
Success
grace@yahoo.com
$451.55
0 of 8 row(s) selected.
Processing
monserrat@hotmail.com
$838.47
Success
carmella@example.com
$335.73
Pending
ken@example.com
$886.27
$108.30
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
6 rows
sorting: none · selected: none · ranges: 0
Code
<!-- Alt+click a header adds it to the sort; Alt+click a checkbox selects the range -->
<DataTable data={payments} columns={COLUMNS} selection="multiple"
           isMultiSortEvent={(e) => e.altKey}
           isRowRangeSelectionEvent={(e) => e.altKey} />

<!-- Alt+click extends a cell range; Shift+click adds one -->
<DataTable data={payments} columns={COLUMNS} cellSelection
           isCellRangeSelectionEvent={(e) => e.altKey}
           isMultiCellRangeSelectionEvent={(e) => e.shiftKey} />

Resetting on new data

autoResetPageIndex, autoResetSorting and autoResetExpanded (or autoResetAll for each): new data returns to page one, clears the sort and collapses groups. All off by default, since a LiveView hands the table new rows on every change. Data that shrinks under the page always shows its last page.

Processing
monserrat18@hotmail.com
$838.47
Success
monserrat34@hotmail.com
$335.73
Pending
carmella20@example.com
$886.27
carmella36@example.com
$108.30
$488.24
$451.55
$720.60
$834.42
$903.28
$553.98
$656.46
$684.15
Success
ken@example.com
$838.47
Processing
abe@gmail.com
$93.61
Success
monserrat@hotmail.com
$335.73
Failed
silas@yahoo.com
$57.34
Pending
carmella@example.com
$886.27
Success
jason@gmail.com
$108.30
Processing
ada@hotmail.com
$488.24
Success
grace@yahoo.com
$451.55
Failed
alan@example.com
$7.84
Pending
edsger@gmail.com
$34.05
1-10 of 42
Page 1 of 5
page: 0 · sorting: none · expanded: 0
Code
<!-- New data (a new array) returns to page one, clears the sort and collapses groups -->
<DataTable data={state.rows} columns={COLUMNS} autoResetAll />
<!-- or one at a time -->
<DataTable data={state.rows} columns={COLUMNS} autoResetPageIndex autoResetSorting autoResetExpanded />

Aggregation options

Level shows two aggregates on each group (min and max) and three in the summary; Salary uses a custom aggregation (Payroll); Status aggregates a value of its own (aggregationValue: 1 for active). maxAggregationDepth limits aggregates to the outer groups, and getAggregationValue takes them from the caller (server totals).

Katherine Hamilton
Platform
Engineer
6
$160,000
offline
Barbara Perlman
Support
Designer
4
$168,000
active
Grace Thompson
Growth
Manager
5
$126,000
away
Platform (24)
2
$139,000
Support (24)
1
$142,000
Growth (24)
$174,000
$130,000
$140,000
$158,000
$161,000
$149,000
$133,000
Min: 1 · Max: 6
$2,711,000
Active: 14 · Count: 24
Min: 1 · Max: 6 · Average: 3.3
Platform (5)
Min: 1 · Max: 6
$637,000
Active: 5 · Count: 5
Support (5)
Min: 1 · Max: 6
$497,000
Active: 5 · Count: 5
Design (5)
Min: 1 · Max: 4
$470,000
Active: 0 · Count: 5
Data (5)
Min: 1 · Max: 5
$604,000
Active: 0 · Count: 5
Growth (4)
Min: 2 · Max: 5
$503,000
Active: 4 · Count: 4
Min: 1 · Max: 6 · Average: 3.3
24 rows
Code
const COLUMNS: DataTableColumn[] = [
  { key: "level", type: "number", aggregate: ["min", "max"], summary: ["min", "max", "mean"] },
  { key: "salary", type: "currency", aggregate: { label: "Payroll", fn: (values) => sum(values) } },
  { key: "status", aggregationValue: (row) => (row.status === "active" ? 1 : 0),
    aggregate: [{ label: "Active", fn: (values) => sum(values) }, "count"] },
];

<DataTable data={people} columns={COLUMNS} grouping={["team", "role"]} maxAggregationDepth={1}
           getAggregationValue={(group, column) => serverTotals[group.value]?.[column]} />

Grouped and expanded on the server

manualGrouping: the server sends team rows with their people as sub-rows, and the table keeps them as they are instead of grouping again. manualExpanding: expanded is state only, and the caller places an open folder's files in the data. onTable's pinRow pins a team with its people (includeLeafRows).

Katherine Hamilton
Platform
Engineer
Barbara Perlman
Support
Designer
Grace Thompson
Growth
Manager
Platform
Platform
5 people
Support
Support
5 people
Design
Design
5 people
Data
Data
5 people
Growth
Growth
4 people
5 rows
about.ehtml
folder
Sep 12, 2026
home.ehtml
file
Oct 20, 2026
styles.css
Oct 21, 2026
Oct 18, 2026
Oct 22, 2026
app
folder
Oct 20, 2026
config.jsoc
file
Oct 1, 2026
README.md
file
Sep 12, 2026
3 rows
grouping: team · expanded: none
Code
<!-- The server sends team rows with their people as subRows: the table does not group again -->
<DataTable data={teams} columns={COLUMNS} subRows={(row) => row.people}
           grouping={["team"]} manualGrouping />

<!-- manualExpanding: the caller places an open folder's files in the data -->
<DataTable data={visibleFiles(state.expanded)} columns={COLUMNS} subRows={(row) => row.children}
           expanded={state.expanded} manualExpanding />

Generated columns

columnsFromData builds the columns from every row, and defaultColumn gives each one its size. Note is HTML, cut down to safe inline formatting; Priority sorts by the label it shows (sortMode display). density dense gives extra small rows; alwaysShowScrollbars keeps both scrollbars drawn.

Laptop stand
Normal
Backordered, see the order
Sep 28, 2026
ops@example.com
officeit
Keys Co
Monitor arm
Low
Ships Monday
Oct 11, 2026
it@example.com
office
Desk lamp
High
Two left
Oct 2, 2026
it
Laptop stand
Normal
Ships Monday
Oct 2, 2026
ops@example.com
office
Monitor arm
P10
Backordered, see the order
Oct 5, 2026
it@example.com
officeit
Desk lamp
Low
In stock
Sep 28, 2026
ops@example.com
Keyboard
High
Two left
Oct 11, 2026
it@example.com
it
Keys Co
4 rows
Code
<!-- Columns generated from the data; every column starts 160px wide -->
<DataTable data={rows} columns={columnsFromData(rows)} defaultColumn={{ size: 160, minSize: 96 }}
           density="dense" alwaysShowScrollbars height="14rem" />

{ key: "note", type: "html" }             <!-- inline HTML, cut down to safe formatting -->
{ key: "priority", sortMode: "display" }  <!-- sorts by the text shown, not the raw value -->

Row models in a web worker

Ten thousand rows sorted, searched and paged off the main thread: the table is manual, and a web worker answers each change of page, sort or search with one page of rows.

Ada Lovelace
Platform
$160,000
Feb 2, 2020
Grace Thompson
Support
$168,000
Jun 24, 2016
Alan Johnson
Design
$126,000
Jun 3, 2016
Edsger Dijkstra
Data
$139,000
Jan 14, 2022
Barbara Perlman
Growth
$142,000
Jan 13, 2023
Donald Shannon
Platform
$174,000
Sep 1, 2024
Frances Allen
Support
$130,000
Nov 1, 2020
Ken Torvalds
Design
$91,000
Nov 20, 2021
Margaret Turing
Data
$67,000
Jul 23, 2022
Dennis Ritchie
Growth
$140,000
Aug 25, 2024
1-10 of 10000
Page 1 of 1000
total 10000 · first Ada Lovelace · loading false
Code
<!-- The row models run in a web worker: the table is manual, the worker sorts, searches and pages -->
<DataTable data={state.rows} columns={COLUMNS} manual rowCount={state.total}
           page={state.page} pageSize={state.pageSize} sorting={state.sorting} search={state.search}
           loading={state.loading} onStateChange={() => worker.postMessage(query(state))} />
Reference DataTable in the manual Every part, attribute, slot, key and data attribute.
Prev ChartNext Kanban
Product
Manual Pricing Compare
Company
History Contact
Legal
Privacy Policy Acceptable Use Terms License Agreement
© Elements Dev Corporation 2026