Table
A data table that stays responsive over large result sets. Bound to a named query it is the standard way to put database rows on a screen.


Reach for it when you need:
- Order lists, batch records, downtime logs
- Any query result with more than a few rows
Component name: Table · Category: Display
In the palette: Data table that stays fast over large result sets
Properties
Any property can be bound to a tag, another component's property, or a query.
| Property | Type | Default | Description |
|---|---|---|---|
data | array | [{"id":1,"name":{"value":"Item 1","fontWeight":"bold"},"stat… (truncated) | Array of row objects. A cell can be a plain value OR an object for per-cell styling: { value, color, backgroundColor, fontSize, fontWeight, fontStyle, textDecoration, fontFamily }. A row-level 'backgroundColor' colors the whole row. Example: { status: { value: "Overdue", color: "#fff", backgroundColor: "#dc2626", textDecoration: "underline" } } |
columns | array | [] | Custom column definitions and overrides. |
↳ columns.id | string | "" | Must match the key name in your data object. |
↳ columns.header | object | {"headerTitle":"","alignedHeader":"center","backgroundColor"… (truncated) | — |
↳ columns.header.headerTitle | string | "" | Display label for the column header. |
↳ columns.header.alignedHeader | enum | center | Options: left · center · right. |
↳ columns.header.backgroundColor | color | "" | — |
↳ columns.header.color | color | "" | — |
↳ columns.header.fontSize | number | 0 | px (0 = inherit) |
↳ columns.header.fontWeight | enum | "" | Options: `` · normal · bold · bolder · lighter. |
↳ columns.header.fontStyle | enum | "" | Options: `` · normal · italic. |
↳ columns.header.textDecoration | enum | "" | Options: `` · none · underline · line-through · overline. |
↳ columns.header.fontFamily | string | "" | — |
↳ columns.width | number | 150 | — |
↳ columns.enableHeaderResize | boolean | false | — |
↳ columns.enableSort | boolean | false | — |
↳ columns.sort | enum | none | Options: none · asc · desc. |
↳ columns.rows | object | {"alignedRows":"left","backgroundColor":"","color":"","fontS… (truncated) | Default styling for this column's body cells. A per-cell object in the data ({ value, color, ... }) overrides these. |
↳ columns.rows.alignedRows | enum | left | Options: left · center · right. |
↳ columns.rows.backgroundColor | color | "" | — |
↳ columns.rows.color | color | "" | — |
↳ columns.rows.fontSize | number | 0 | px (0 = inherit) |
↳ columns.rows.fontWeight | enum | "" | Options: `` · normal · bold · bolder · lighter. |
↳ columns.rows.fontStyle | enum | "" | Options: `` · normal · italic. |
↳ columns.rows.textDecoration | enum | "" | Options: `` · none · underline · line-through · overline. |
↳ columns.rows.fontFamily | string | "" | — |
↳ columns.aggregation | enum | "" | Summarise this column. Shows on each group row (when Grouping is on) AND in the grand-total row (when Aggregation is on) — the two are independent, so a total needs no grouping. sum/mean/median/min/max keep the column's own units and are formatted with its dataType; count/uniqueCount are row counts; extent is a min-max pair; unique lists the distinct values. Options: `` · sum · mean · median · min · max · extent · count · uniqueCount · unique · first · last. |
↳ columns.spanRows | boolean | false | Merge this column's vertically adjacent cells when they hold the same value. Needs the table-level cellSpanning.enabled switched on too — both, or nothing happens. The merge follows the rows AS DISPLAYED, so sorting by this column is usually what makes it useful, and it re-forms by itself after a sort, filter or page change. |
↳ columns.dataType | object | {"type":"string","format":""} | — |
↳ columns.dataType.type | enum | string | Options: string · number · boolean · datetime · view. |
↳ columns.dataType.format | format | "" | String: uppercase/lowercase/capitalize. Number: #,###.##. Datetime: yyyy-MM-dd, dd/MM/yyyy, MMM dd, yyyy, HH:mm:ss |
↳ columns.dataType.viewPath | view | "" | type=view: the user-created view to render each cell with |
↳ columns.dataType.valueParam | string | "" | type=view: the name of that view's param that receives the cell value |
freezeColumns | array | [] | List of column IDs to fix to the left side. |
↳ freezeColumns.id | string | "" | — |
↳ freezeColumns.position | enum | left | Options: left · right. |
enableReorder | boolean | false | Let users drag column headers at runtime to reorder them. Pinned columns (row selection checkbox, edit/delete actions, frozen columns) stay in place — only the center group is reorderable. |
groups | array | [] | Column HEADER nesting — stacks banner rows above your columns and leaves the rows untouched. (To fold the ROWS themselves into collapsible buckets, use rowGrouping instead.) A group holds column IDs in columns and/or further groups in groups, so headers nest as deep as you like; each level adds a header row, and a shallow branch spans down to the leaf row on its own. |
↳ groups.header | string | Group | — |
↳ groups.columns | array | [] | Leaf column IDs directly under this group. |
↳ groups.columns.id | string | "" | — |
↳ groups.alignHeader | enum | center | Options: left · center · right. |
↳ groups.backgroundColor | color | "" | — |
↳ groups.groups | array | [] | Groups nested inside this one. Each level adds a header row. |
↳ groups.groups.header | string | Group | — |
↳ groups.groups.columns | array | [] | Leaf column IDs directly under this group. |
↳ groups.groups.columns.id | string | "" | — |
↳ groups.groups.alignHeader | enum | center | Options: left · center · right. |
↳ groups.groups.backgroundColor | color | "" | — |
↳ groups.groups.groups | array | [] | Groups nested inside this one. Each level adds a header row. |
↳ groups.groups.groups.header | string | Group | — |
↳ groups.groups.groups.columns | array | [] | Leaf column IDs directly under this group. |
↳ groups.groups.groups.columns.id | string | "" | — |
↳ groups.groups.groups.alignHeader | enum | center | Options: left · center · right. |
↳ groups.groups.groups.backgroundColor | color | "" | — |
↳ groups.groups.groups.groups | array | [] | Groups nested inside this one. Each level adds a header row. |
↳ groups.groups.groups.groups.header | string | Group | — |
↳ groups.groups.groups.groups.columns | array | [] | Leaf column IDs directly under this group. |
↳ groups.groups.groups.groups.columns.id | string | "" | — |
↳ groups.groups.groups.groups.alignHeader | enum | center | Options: left · center · right. |
↳ groups.groups.groups.groups.backgroundColor | color | "" | — |
rowGrouping | object | — | Collapse ROWS that share a value into expandable group rows — 40 rows become one per distinct value. TURNING THIS ON HIDES rowReorder (grouping decides the order, so drag-to-reorder can never take effect) and stops rowDetail panels from opening all at once if you also set expandedByDefault. TO USE IT: turn enabled on, then add one entry to groupBy and type a COLUMN ID into it — the key name in your data objects ("status"), NOT the header label. A second entry nests inside the first. Each group row shows the value, how many rows are under it, and whatever aggregation each column declares — so set a column aggregation to get a subtotal per group. Not to be confused with groups above, which nests column HEADERS and leaves the rows alone. |
↳ rowGrouping.enabled | boolean | false | — |
↳ rowGrouping.columns | array | [] | COLUMN IDs — the key names in your data objects (e.g. "status"), not the header labels. Add one entry per level and type the id into its columnId box; listing two nests the second inside the first (region, then rep = a rep group inside each region group). Leave an entry blank and it is ignored. Each group row then shows the value, the number of rows under it, and whatever aggregation each column declares — so pair this with a column aggregation to get a subtotal per group. |
↳ rowGrouping.columns.id | string | "" | — |
↳ rowGrouping.mode | enum | reorder | What happens to the columns being grouped BY. Options: reorder · remove · none. |
↳ rowGrouping.expandedByDefault | boolean | false | Start with every group open instead of collapsed. This expands GROUPS only: with rowDetail also on, it does not fling open every row's panel — that would mount one embedded View per row. |
aggregation | object | — | The grand-total row. Which columns total, and how, comes from each column's own aggregation property. Needs no grouping — totals work on a flat table. |
↳ aggregation.enabled | boolean | false | — |
↳ aggregation.showGrandTotal | boolean | true | — |
↳ aggregation.scope | enum | filtered | Filtered = what the user is currently looking at. All = every loaded row, ignoring the search box. Options: filtered · all. |
↳ aggregation.position | enum | bottom | Options: top · bottom. |
↳ aggregation.label | string | Total | — |
↳ aggregation.backgroundColor | color | "" | The total row's own background. Empty keeps the default muted band. Applies to the frozen columns too, so a pinned column does not keep the old shade while the rest changes. 1.0.8 |
↳ aggregation.color | color | "" | The total row's text. Empty inherits, which is what reads correctly against the default band. 1.0.8 |
↳ aggregation.fontWeight | enum | "" | Semibold by default — a total that looks like an ordinary row is the thing this row exists not to be. Options: `` · normal · 500 · 600 · bold. 1.0.8 |
↳ aggregation.fontSize | number | 0 | Point size in px. Empty keeps the body's 12px, so the total lines up with the rows above it. 1.0.8 |
rowPinning | object | — | Let users stick individual rows above or below the scrolling body. Adds pin buttons to the Actions column (creating it if Edit/Delete are both off). Set rowSelection.rowIdField for query data, or a pin follows a row POSITION rather than a row. |
↳ rowPinning.enabled | boolean | false | — |
↳ rowPinning.regions | enum | both | Options: both · top · bottom. |
↳ rowPinning.keepPinnedRows | boolean | true | On: a pinned row stays visible even when a filter or page change would drop it. Off: pinned rows are limited to the current page. |
rowReorder | object | — | Let users drag ROWS into a different order, via a grip column on the left. The order lives in the widget, not the data — the next refresh of a bound table brings whatever the source says — so a drop also fires the rowReorder event with the new id order, which is where you persist it. WITHHELD WHILE A SORT IS ACTIVE: sorting decides the order, so a dropped row would snap straight back. That one is a RUNTIME state — the viewer sorts a column — so this stays configurable and the grips simply do not appear until the sort is cleared. rowGrouping is the other half of the same rule, and because that IS design-time this whole control hides while it is on. |
↳ rowReorder.enabled | boolean | false | — |
rowDetail | object | — | Give each row an expandable panel underneath it, rendered by one of your Views. Adds a chevron column on the left. THE ROW IS SENT AUTOMATICALLY, two ways: every Param the view declares whose name matches a column is filled from that row field (so a view with a 'status' Param just works), and the whole row always arrives under 'rowParam' for anything else — {{ params.row.<field> }}. You do not have to wire either. |
↳ rowDetail.enabled | boolean | false | — |
↳ rowDetail.viewPath | view | "" | The View rendered inside the expanded panel. |
↳ rowDetail.params | object | {} | The Params the chosen View declares, surfaced here so each one can be bound — the same auto-detection EmbeddedView gives you. Pick a view above and its Params appear; anything left alone still falls back to the matching row field, and then to the view's own default. |
↳ rowDetail.rowParam | string | row | The name of the Param on THAT view which receives the whole row object. Defaults to row, and is always sent whether or not the view declares it. Params named after a column are filled from that column separately. |
↳ rowDetail.height | number | 160 | px. Fixed, because the row virtualizer needs to know how tall an expanded row is before rendering it. |
cellSpanning | object | — | TWO STEPS. (1) Turn this on. (2) On each column you want merged, set its own mergeRepeatedCells. This switch alone does nothing — it only permits merging; the columns choose it. Adjacent rows with the SAME value in that column are then drawn as one tall cell, so sorting by that column is usually what makes it worth having. |
↳ cellSpanning.enabled | boolean | false | — |
search | object | — | — |
↳ search.enabled | boolean | false | — |
↳ search.placeholder | string | Search... | — |
↳ search.position | enum | top | Options: top · bottom · both. |
pagination | object | — | — |
↳ pagination.enabled | boolean | true | — |
↳ pagination.pageSize | number | 10 | — |
↳ pagination.position | enum | bottom | Options: top · bottom · both. |
↳ pagination.enableRowPerPage | boolean | true | — |
↳ pagination.pageSizeOptions | string | 10, 25, 50, 100 | Rows-per-page choices, comma separated, e.g. 10, 25, 50, 100, 1000. The current pageSize is always included. |
↳ pagination.engine | enum | auto | Query-bound tables only. auto (default) = start at the server (instant, safe for any size), then transparently switch to the in-browser client engine once the result is provably small enough — zero round-trips after that, no ceiling. server = always page at the DB (a million-row set never enters the browser). client = always load the result ONCE into the in-browser engine then sort/filter/page locally. Identical table UI for all three. Options: auto · server · client. |
↳ pagination.analyticsMaxRows | number | 200000 | client/auto engine: max rows fetched once into the browser (memory guard, and the 'auto' promotion threshold — auto switches to the client engine only when the row count is at or below this). |
zebraStripping | object | — | — |
↳ zebraStripping.enable | boolean | true | — |
↳ zebraStripping.mode | enum | even | Options: odd · even. |
↳ zebraStripping.color | color | rgba(0,0,0,0.02) | — |
rowSelection | object | — | Let users select rows. Adds a pinned checkbox column (stays left even with frozen columns). MULTIPLE gives a select-all box in the header and supports Shift-click to select a range; SINGLE allows one row at a time, so picking a row clears the previous one and there is no select-all. Selection is exposed at runtime as {{ <tableName>.selectedRowIds }} — the durable list, it survives paging — and {{ <tableName>.selectedRows }}, which is the row OBJECTS and therefore only the ones currently loaded. Set rowIdField to a real key for query data, or selection follows row POSITION and breaks the moment the data reorders. |
↳ rowSelection.enabled | boolean | false | — |
↳ rowSelection.mode | enum | multiple | multiple = checkbox per row + select-all. single = one row at a time. Options: multiple · single. |
↳ rowSelection.rowIdField | string | "" | Column holding a stable unique row id (a primary key for SQL, or _id for MongoDB). Recommended for query/paged tables so a selection survives sort/filter/paging. Empty = auto-detect _id/id/ID/uuid/key, else row index (positional, unreliable across pages). |
rowActions | object | — | Per-row Edit/Delete buttons pinned on the right. Each button fires a Table event (rowEdit / rowDelete) with the clicked row as script context — wire them in the Events dialog (Edit → open a form, Delete → run a delete named query: a SQL DELETE or a MongoDB delete op). No inline editing. |
↳ rowActions.enabled | boolean | false | — |
↳ rowActions.edit | boolean | true | Show the Edit button (fires the 'Row edit' event). |
↳ rowActions.delete | boolean | true | Show the Delete button (fires the 'Row delete' event). |
selectedRowIds | array | [] | Output (bind from this): ids of the selected rows. Always reliable across paging/sort. |
selectedRows | array | [] | Output (bind from this): the selected row objects (currently-loaded rows). |
emptyMessage | string | No data available | — |
color | color | "" | The table's text colour. Empty uses the theme's normal foreground, which is what a table of data should be — NOT the canvas default. LiveView sets its base colour to the muted shade on purpose, so SVG shapes drawing with currentColor match the designer; a table that inherited it read as greyed-out next to everything else. A column's own header/rows colour and a per-cell colour both still win over this. 1.0.8 |
style | style | {} | Custom CSS properties |
Events
Attach event actions to these in the Event Manager.
| Event | Label | Group | Payload |
|---|---|---|---|
click | Click | Mouse | The DOM event. |
dblclick | Double click | Mouse | The DOM event. |
contextmenu | Right click | Mouse | The DOM event. |
mouseenter | Mouse enter | Mouse | The DOM event. |
mouseleave | Mouse leave | Mouse | The DOM event. |
rowClick | Row click | Table | row — the clicked row object. |
rowDoubleClick | Row double click | Table | The clicked row, as row. |
selectionChange | Selection change | Table | selectedRows and selectedRowIds. |
rowEdit | Row edit | Table | The edited row. |
rowDelete | Row delete | Table | The row to delete. |
rowReorder | Row reorder | Table | — |
cellClick | Cell click | Table | — |
cellDoubleClick | Cell double click | Table | — |
contextmenu fires your configured actions at runtime only — in LiveView and in preview. On the Designer canvas the right-click is captured to open the Event Manager, so testing it there will not run your actions. Test right-click in preview.
Notes
Its row events carry the clicked row, which makes drill-down a one-action affair: rowClick → navigate("detail", { id: row.id }).
Columns are addressable parts. A column can carry its own cellClick and cellDoubleClick, wired in the Event Manager under the column id — the same id used everywhere else in the Table's configuration — so one column can drill down where the rest of the row does nothing. Rows deliberately are not addressable: a query can produce thousands of them, so branching on one stays a rowClick with an Only when… guard.
→ Events on a part of a widget
Page or limit the query. An unbounded table binding is the usual cause of a LiveView session growing over a shift.
Some features cannot both be on. Two of them deciding the same thing is not a combination the table can honour, so one stands down — and which one is not a coin toss:
| Turn on… | …and this stands down | Why |
|---|---|---|
rowGrouping | rowReorder — hidden in the properties | Grouping decides the order, so a dropped row could never stay put. Grouping is configured here, so the control is taken away rather than left to do nothing. |
| A sort, by the viewer at runtime | the reorder grips | Same reason — but the viewer sorts long after you close the panel, so rowReorder stays configurable and the grips come back once the sort is cleared. |
rowGrouping.expandedByDefault with rowDetail | opening every row panel | Both read one expansion state. Expanding everything means the GROUPS: they open, every row stays shut, and opening one afterwards opens exactly that one. Otherwise every row would mount an embedded view at once. |
| Server-side paging, sorting or filtering | the client-side ones | The table holds one page, so grouping or sorting it would describe that page while claiming to describe the whole result. |
These pair up fine, and are worth knowing because each took work to be true: grouping with row selection, with pagination (a page holds groups, so opening one no longer pushes the others onto page 2), and with per-column aggregation. Row pinning and cell spanning are left to you under grouping — unusual, but they do something sensible.
Fixed in 1.0.8 Changed in 1.0.8
refreshBindingreaches a query-bound table. The table fetches its own pages, so arefreshBindingaction never got to it: a button that inserted a row left the list showing the old rows until the page was reloaded. It now re-runs the query and recounts.- Totals add up numbers that arrive as text. PostgreSQL
numericandbigint, MySQLDECIMALand most JSON encodings deliver numbers as strings, and a column of"12.5"totalled to 0. A column totalled bysum,mean,median,min,maxorextentnow reads a string that is a finite number as that number — which also makes that column sort numerically (9before10). An empty cell stays empty — counting it as zero would drag an average down without anyone noticing. - A merged cell looks merged. A spanned cell was transparent, so the row lines under it showed through. It now has a solid background and keeps only the line at the end of the span.
- The text is the theme's normal colour. It used to inherit the muted grey LiveView gives its canvas, which made a table of data look disabled.
colorsets it; a column's or a cell's own colour still wins. - The total row can be styled:
aggregation.backgroundColor,color,fontWeightandfontSize, applied to frozen columns too so a pinned column does not keep the old shade. - A table in a bounded cell fills it. A table with no height of its own still has a 320 px minimum (it was 360), but inside a grid tile or a box shorter than that it fills the space rather than overflowing it.
- Row events from a large integer column work. Rows read through the in-browser query engine carried 64-bit integers as a type JSON cannot hold, so
rowClickfailed with "event payload is not serializable" androw.x + 1threw. They now arrive as plain numbers, or as exact strings beyond 2^53.