petal playground
1k
primary
secondary
gray
radius
type

Data table

Sortable, paged and filter-aware, driven by one State struct. Wire it to events or to the URL - the two live demos below run the same free in-memory engine, one per mode.

Event mode

Every interaction pushes a single op-grammar event, the handler applies it with State helpers and re-runs the engine. Sort this one and the URL stays put - the state lives on the server.

Email Status
Amy amy@example.com pending $157
Bea bea@example.com refunded $294
Cal cal@example.com paid $31
Dan dan@example.com pending $168
Dee dee@example.com refunded $305

Link mode

The same rows and engine, driven entirely by the URL: sort, filter, search and page all patch State-encoded query params, handle_params decodes them back through the field whitelist and re-runs the engine. No events - every view is a link you can share, bookmark or curl. No selection or column controls here either: those are UI state, and UI state rides events, never URLs. Try sorting, then reload.

The state this demo decoded from the URL:

Sorted, paged, engine-run

The whole surface from one State struct: sortable headers (aria-sort included), the range summary, and pagination that picks numbered mode because total is known. This static example runs the free in-memory engine over 20 rows at render time - zero setup, no database. In your app the same State drives handle_params (link mode) or a single op-grammar event (event mode).

Email
Tia tia@example.com $360
Quin quin@example.com $349
Ned ned@example.com $338
Kim kim@example.com $327
Gus gus@example.com $316
<% state = %State{order_by: [amount: :desc], page: 1, page_size: 5} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table id="sx-dt-basic" rows={rows} state={state} path="#">
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} sortable align="right">${row.amount}</:col>
</.data_table>

The full toolbar: search, typed filters, per-page

searchable sweeps string fields case-insensitively before filters (totals stay honest); filterable columns get buttons that open typed popover editors - operator + value for text/number/date (between reveals its second bound, no JS), a checkbox list for select - and read their predicate while active, with an inline clear. page_size_options adds the per-page select; Reset filters appears while any filter is active. Event mode posts one op-grammar event for all of it (State.handle_op/3 is the whole handler); link mode patches curl-able URLs.

Email Status
Amy amy@example.com pending $157
Dan dan@example.com pending $168
Fay fay@example.com pending $179
Joy joy@example.com pending $190
Kim kim@example.com refunded $327
Static preview - This preview renders a fixed state, so its controls do not respond. The code below is the real, working markup.
<% state = %State{
search: "i",
filters: [%{field: :status, op: :in, value: ["pending", "refunded"]}],
page_size: 5
} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-toolbar"
rows={rows}
state={state}
on_change="table"
searchable
page_size_options={[5, 10, 20]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email} filterable="text">{row.email}</:col>
<:col
:let={row}
field={:status}
filterable="select"
options={[{"Paid", "paid"}, {"Pending", "pending"}, {"Refunded", "refunded"}]}
>
<.badge
size="sm"
variant="soft"
color={
case row.status do
"paid" -> "success"
"pending" -> "warning"
_ -> "danger"
end
}
label={row.status}
/>
</:col>
<:col :let={row} field={:amount} sortable align="right" filterable="number">
${row.amount}
</:col>
</.data_table>

Row selection with a morphing toolbar

selectable adds a checkbox column keyed by row_id (a field or a function - it must uniquely identify records across ALL pages). The header checkbox is tri-state; while rows are selected the toolbar morphs into the count, your :bulk_action slot (:let receives the ids) and a clear button. Selection is UI state: it rides the on_ui event (defaulting to on_change) with select / select_all / clear_selection ops and never touches URLs - a MapSet plus three clauses is the whole backend.

2 selected
Email Amount
Amy amy@example.com $157
Bea bea@example.com $294
Cal cal@example.com $31
Dan dan@example.com $168
Static preview - This preview renders a fixed state, so its controls do not respond. The code below is the real, working markup.
<% state = %State{page_size: 4} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-selection"
rows={rows}
state={state}
on_change="table"
selectable
selected={["1", "3"]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} align="right">${row.amount}</:col>
<:bulk_action :let={ids}>
<.button size="sm" variant="soft" color="danger">Archive {length(ids)}</.button>
</:bulk_action>
</.data_table>

Columns visibility and order

column_toggle renders a Columns dropdown - every declared column with a checkbox, toggling immediately (the panel stays open for multi-toggling). reorderable adds move controls posting a field + dir delta; DataTable.move_column/4 applies it to your current order in one line, race-free under rapid clicks. Both are presentation state on the on_ui event, never in URLs; the last visible column can't be hidden.

Amount
Amy $157
Bea $294
Cal $31
Dan $168
Static preview - This preview renders a fixed state, so its controls do not respond. The code below is the real, working markup.
<% state = %State{page_size: 4} %>
<% {rows, state} = Engine.List.run(PetalComponents.Showcase.DataTable.sample_rows(), state) %>
<.data_table
id="sx-dt-columns"
rows={rows}
state={state}
on_change="table"
column_toggle
reorderable
hidden_columns={["email"]}
>
<:col :let={row} field={:name} sortable>{row.name}</:col>
<:col :let={row} field={:email}>{row.email}</:col>
<:col :let={row} field={:amount} align="right">${row.amount}</:col>
</.data_table>

Loading skeletons

loading swaps the page for skeleton rows - one per page_size row, respecting column count and alignment. Flip it off when the query resolves.

Name Email Amount
<% state = %State{total: 74, page_size: 4} %>
<.data_table id="sx-dt-loading" rows={[]} state={state} path="#" loading>
<:col :let={row} field={:name}>{row}</:col>
<:col :let={row} field={:email}>{row}</:col>
<:col :let={row} field={:amount} align="right">{row}</:col>
</.data_table>

The filters-aware empty state

An empty result set with active filters says so and offers the way out - a clear-filters patch link in link mode, the op-grammar event in event mode. Without filters it is a plain no-results line. Override either with the :empty slot.

Name Email
<% state = %State{total: 0, filters: [%{field: :name, op: :contains, value: "zz"}]} %>
<.data_table id="sx-dt-empty" rows={[]} state={state} path="#">
<:col :let={row} field={:name}>{row}</:col>
<:col :let={row} field={:email}>{row}</:col>
</.data_table>
Attribute Type Default Description
actions_label string "Actions" the actions column's header, announced but not shown
apply_label string "Apply" the filter editors' submit label, localizable
class any nil
clear_filters_label string "Clear filters"
clear_selection_label string "Clear selection"
column_order list [] fields in display order (atoms or strings) - presentation state on the `on_ui` event, like `hidden_columns`, never in URLs. Fields not listed keep their declared order after the listed ones. Empty means the declared `:col` order.
column_toggle boolean false render a columns-visibility dropdown in the toolbar (rides the `on_ui` event)
column_toggle_label string "Columns"
density string "comfortable"
one of: "comfortable", "compact"
filter_op_labels map %{} overrides for the operator display names, e.g. %{contains: "enthält"}
filter_options_placeholder string "Filter options…" the select editor's option-filter placeholder (shown from 8 options up), localizable
hidden_columns list [] fields currently hidden (atoms or strings) - presentation state, never in URLs
id* string
loading boolean false render skeleton rows instead of data
max_height string nil caps the table body's height (any CSS length), making it scroll under a pinned header
move_down_label string "Move down"
move_up_label string "Move up" reorder buttons, localizable
no_filtered_results_text string "No results for these filters" the empty message while filters are active
no_results_text string "No results"
of_label string "of" the range summary's connective, localizable
on_change string nil event mode: the event every table interaction pushes, with an op-shaped payload (`op` of "sort" | "page" | "clear_filters").
on_ui string nil the event UI-state ops (selection) push, both wiring modes. Defaults to `on_change`; required alongside `selectable` in link mode.
page_label string "Page" cursor mode's page word, localizable
page_size_options list [] when non-empty, render a rows-per-page select in the footer
path string nil link mode: the base path sort and page changes patch to, with the state encoded as query params. Required unless `on_change` is set.
per_page_label string "Per page" the rows-per-page label, localizable
reorderable boolean false render move up/down controls in the Columns menu (requires `column_toggle`)
reset_filters_label string "Reset filters"
results_label string "results" the announced result-count noun, localizable
row_id any :id row identity for selection: a field (atom) or a 1-arity function of the row. The key must uniquely identify a record across ALL pages, not just the visible one - a selection retained while paging is only as sound as this key. Same-page duplicates raise.
row_label any nil a 1-arity function returning a human name for a row, used as the selection checkbox's accessible name. Without it the checkbox announces its position ("Select row 3"), because a primary key - a UUID, say - is not something a screen reader user can act on.
rows list []
search_debounce integer 300 quick-search debounce in ms, both wiring modes
search_placeholder string "Search…"
searchable boolean false render the quick-search input in the toolbar (drives `state.search`)
select_all_label string "Select all rows" the header checkbox's aria-label
select_row_label string "Select row" the row checkbox's aria-label prefix, localizable
selectable boolean false render a leading checkbox column
selected list [] the currently selected row ids (any terms; compared as strings)
selected_label string "selected" the selection count's word, localizable
state* {:struct, PetalComponents.DataTable.State}
sticky_header boolean false pin the header row. Inside a data table this needs `max_height` too: the scroll region is what the header sticks to, and a wrapper that scrolls only sideways cannot pin anything.
striped boolean false
target any nil event mode: the phx-target (e.g. @myself)
variant string "basic"
one of: "ghost", "basic"
:action slot slot trailing actions column, `:let` receives the row
:bulk_action slot slot toolbar content while rows are selected; `:let` receives the selected ids
:col slot slot
:empty slot slot custom empty state; a filters-aware default renders otherwise
:toolbar slot slot custom toolbar content rendered above the table