A data table component for Phoenix LiveView.
Renders a table from a list of records and <:col> slot definitions, with
support for:
- Automatic cell rendering based on Ecto schema field types — booleans render as check/x icons, datetimes render with absolute and relative formats, UUIDs are truncated with a hover tooltip, maps render as code blocks.
- Custom cell rendering via slot bodies.
- Sortable column headers rendered as patch links that set
sortandsort_directionquery params on the URL. - Row selection (checkboxes) stored in the URL query string, so selections survive navigation and pagination.
Table state — sorting and row selection — lives in the URL. The component
patches query params; the parent LiveView reacts to handle_params/3.
Callers pass the current uri and params (both from handle_params/3)
to enable these features.
Usage
Render a table with Slab.table/1, defining columns as <:col> slots.
Data comes from one of two modes:
List mode — pass pre-fetched records via data. An optional schema
(an Ecto.Schema module) makes values render according to their field
type; without one, values render as strings:
<Slab.table id="users-table" data={@users} schema={MyApp.User}>
<:col field={:id} />
<:col field={:name} />
<:col field={:email} />
<:col field={:inserted_at} />
</Slab.table>Query mode — omit data and Slab fetches for you. schema becomes
the source to query (an Ecto.Schema module or an %Ecto.Query{}), run
through repo:
<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
<:col field={:name} sortable />
<:col field={:email} />
</Slab.table>The repo can also be configured once, globally:
config :slab, repo: MyApp.RepoPassing an %Ecto.Query{} lets the caller scope what the table can ever
see (authorization, multi-tenancy) while Slab layers sorting on top:
<Slab.table id="users-table" schema={from u in MyApp.User, where: u.org_id == ^@org.id} ...>A <:col> with no body renders the record's field automatically.
Custom cell rendering
Give a <:col> a body to take over rendering. The body receives the record
via :let. A field is optional — omit it for virtual columns like
actions, and give the column a label instead:
<Slab.table id="users-table" data={@users}>
<:col :let={user} field={:name}>{String.upcase(user.name)}</:col>
<:col :let={user} label="Actions">
<.link navigate={~p"/users/#{user}/edit"}>Edit</.link>
</:col>
</Slab.table>Sorting
Mark columns as sortable and pass the current uri and params. Sortable
headers render as patch links that set the sort and sort_direction
query params. In query mode Slab requeries with the new sort automatically —
only fields declared sortable are ever compiled into ORDER BY, so URL
tampering cannot sort by arbitrary columns. In list mode, the parent
LiveView reacts in handle_params/3 by requerying:
def handle_params(params, uri, socket) do
socket =
socket
|> assign(:uri, uri)
|> assign(:params, params)
|> assign(:users, list_users(params))
{:noreply, socket}
end
<Slab.table id="users-table" data={@users} uri={@uri} params={@params}>
<:col field={:name} sortable />
<:col field={:email} sortable />
</Slab.table>Filtering
Mark columns as filterable and Slab translates filter URL params into
WHERE conditions in query mode. Values are cast with Ecto.Type.cast/2
against the schema's field types — strings match with a case-insensitive
contains, other types by equality, and an operator form enables
comparisons. Only declared columns are ever filtered; invalid values and
unknown operators are ignored:
<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
<:col field={:name} filterable />
<:col field={:inserted_at} filterable />
</Slab.table>
?filter[name]=ada # WHERE name ILIKE %ada%
?filter[active]=true # WHERE active = true
?filter[inserted_at][gte]=2026-01-01 # WHERE inserted_at >= ...Operators: eq, neq, gt, gte, lt, lte, contains.
For custom logic — full-text search, filtering through associations — pass
a 2-arity filter_query function instead. It receives the queryable and the raw
param value, and can add joins or any Ecto condition (a filter_query function
implies filterable):
<:col field={:organization} filter_query={fn query, value ->
from u in query,
join: o in assoc(u, :organization),
where: ilike(o.name, ^"%#{value}%")
end} />Render filter inputs with filter/1, which patches the filter[field]
param as the user types or selects:
<Slab.filter id="filter-name" field={:name} uri={@uri} params={@params}
label="Name" placeholder="Search names..." />Or build custom filter UIs in the parent with filter_path/3, which sets
the param and resets pagination. For anything beyond per-field filters,
scope the schema query itself — the escape hatch always works.
Pagination
Pass paginate with one of two modes. :page is classic offset
pagination driven by page and per_page URL params — it works in both
data modes (in list mode the list is sliced in memory):
<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo}
paginate={:page} per_page={25} uri={@uri} params={@params}>
<:col field={:name} />
</Slab.table>:cursor is keyset pagination driven by an after URL param — built for
constantly-updated data, where new inserts would shift offset pages
underneath the viewer. Cursors paginate relative to the last-seen record,
stay correct as records land, avoid deep-offset scans, and need no count
query. Query mode only; navigation is First/Next (no random page access):
<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo}
paginate={:cursor} per_page={25} uri={@uri} params={@params}>
<:col field={:inserted_at} sortable />
</Slab.table>Cursors are readable, not opaque: ?after[id]=...&after[value]=... holds
the last record's id (always the ordering tiebreaker) plus its sort-field
value when sorting. Both are cast against the schema's field types with
Ecto.Type.cast/2 — a tampered cursor falls back to the first page rather
than erroring or reaching the query. Changing the sort resets pagination
in either mode.
Page mode renders a full footer: a "Showing X to Y of Z entries" summary,
numbered page links with ellipses, and a page-size dropdown (see
per_page_options). The total comes from a count query cached on the
current filters — page and sort changes never re-count. Cursor mode never
runs a count query at all; it detects a next page by fetching one extra
record.
Tabs and sharing
A tab bar above the table is derived automatically from the table definition — no separate declaration:
- a Filters tab appears when any
<:col>isfilterable, with one input per filterable column and a badge showing the active filter count. Input types derive from the schema — booleans andEcto.Enumfields get a select with derived options, everything else a text input — and the col'sfilter_type,filter_options,filter_placeholder, andfilter_min_charsattrs override the defaults - a Columns tab appears when
columns_tab?is set anduriis given — see the next section - a Share tab appears when
share_tab?is set anduriis given, holding a copyable link to the exact current view
No qualifying tabs, no tab bar. tabs/1, share/1, and filter/1
remain public for composing custom layouts outside the table.
Column visibility and order
The columns[] URL param controls which columns render, in param
order — column layout is shareable user state like everything else:
?columns[]=email&columns[]=name # email and name only, email firstNames are matched against the declared columns (a column's key is its
field, or a slug of its label for virtual columns like "Actions" →
actions); unknown names are ignored, and no matches falls back to the
default view. With no param, columns render in declaration order minus
those marked optional:
<Slab.table id="users-table" ... columns_tab?>
<:col field={:name} />
<:col field={:email} optional />
</Slab.table>The Columns tab renders a multi-select picker driving the param; its selection order becomes the column order. Changing columns never resets pagination — the result set is unchanged. Sorting and filtering are unaffected by visibility: a hidden column's filter still applies.
Row selection
Pass checkable? and the current uri. Checked row IDs are stored in the
checked query param via push_patch:
<Slab.table id="users-table" data={@users} checkable? uri={@uri}>
<:col field={:name} />
</Slab.table>Read selections back with get_checked_ids/1, get_checked_values/3, and
checked?/1. For selections spanning paginated results, see
get_selected_and_missing_ids/3.
Styling
Markup is styled with Tailwind CSS utility classes. Ensure your app's Tailwind configuration includes this dependency's files so the classes are generated — see the README for details.
Summary
Functions
Returns whether any rows are checked in the given URI string or params map.
Renders a filter input that drives a filter[field] URL param.
Returns the path to patch to when filtering field by value.
Returns the number of checked rows from a URI string or a params map.
Returns the list of checked row IDs (as strings) from a URI string or a params map.
Returns the records whose ID is checked in the given URI.
Returns the number of active filters from a URI string or a params map.
Returns selected records from current page and IDs that need to be fetched.
Returns the path to patch to for the given page number.
Renders a share row: the current URL in a read-only input with a copy-to-clipboard button.
Returns the path to patch to when sorting by field.
Renders a data table.
Renders a tabbed container, typically placed above a table to organize filters, sharing, and other table tooling.
Functions
Returns whether any rows are checked in the given URI string or params map.
Renders a filter input that drives a filter[field] URL param.
Pairs with table/1: point field at a <:col filterable> column and the
table requeries as the user types or selects. On change the component
patches the URL via push_patch — the parent LiveView only needs to track
uri and params in handle_params/3, as with everything else in Slab.
Three input types:
"text"(default) — debounced text input; with a string-typed column this becomes a case-insensitive contains match"select"— a searchable single select (PhoenixSelect); clearing the selection clears the filter"multiselect"— a searchable multi select; the selected values filter withfield IN (...)
The select types render via PhoenixSelect's colocated hook — register it
once in assets/js/app.js (see the README's installation section).
Examples
<Slab.filter id="filter-name" field={:name} uri={@uri} params={@params}
label="Name" placeholder="Search names..." />
<Slab.filter id="filter-active" field={:active} uri={@uri} params={@params}
type="select" label="Status"
options={[{"Active", "true"}, {"Inactive", "false"}]} />
<Slab.filter id="filter-role" field={:role} uri={@uri} params={@params}
type="multiselect" label="Roles"
options={[{"Admin", "admin"}, {"Member", "member"}, {"Guest", "guest"}]} />Attributes
id(:string) (required)field(:any) (required) - the filter key — matches a<:col filterable>field on the table.uri(:string) (required) - the current request URI, from handle_params/3.params(:map) - the current request params, from handle_params/3; carries the current value. Defaults to%{}.type(:string) - the input type. Defaults to"text". Must be one of"text","select", or"multiselect".label(:string) - optional label rendered above the input. Defaults tonil.placeholder(:string) - placeholder for the input. Defaults tonil.options(:list) - select options, as[{label, value}]tuples or plain values. Defaults to[].debounce(:integer) - milliseconds to debounce text input changes. Defaults to300.min_chars(:integer) - minimum characters before a text change applies (empty always applies, clearing the filter); submitting the form applies regardless. Defaults to0.
Returns the path to patch to when filtering field by value.
Sets the filter[field] query param — pass a string for the default
operator (contains for strings, equality otherwise), a map for explicit
operators, or nil/"" to clear the filter. Changing a filter resets
pagination, since the result set is different.
Use this to build filter UIs in the parent LiveView; Slab applies the resulting params to the query in query mode.
Examples
iex> Slab.filter_path("https://example.com/users", :name, "ada")
"/users?filter[name]=ada"
iex> Slab.filter_path("https://example.com/users?page=3", :age, %{"gte" => "21"})
"/users?filter[age][gte]=21"
iex> Slab.filter_path("https://example.com/users?filter[name]=ada", :name, nil)
"/users"
Returns the number of checked rows from a URI string or a params map.
Returns the list of checked row IDs (as strings) from a URI string or a params map.
Returns the records whose ID is checked in the given URI.
Options
:key- the record field to match against checked IDs (default::id)
Returns the number of active filters from a URI string or a params map.
Useful as the count badge on a filters tab. Counts filter entries
recursively, so an operator filter (filter[age][gte]=21) counts once per
operator and a multi-select counts as one.
Examples
iex> Slab.get_filter_count(%{"filter" => %{"name" => "ada", "role" => ["admin", "member"]}})
2
iex> Slab.get_filter_count("/users?filter[name]=ada&sort=name")
1
iex> Slab.get_filter_count(%{})
0
Returns selected records from current page and IDs that need to be fetched.
This is useful for pagination scenarios where you need to maintain full record data for selections across multiple pages.
Parameters
current_page_records- List of records currently displayed on the pagechecked_ids- List of selected IDs (typically from URI query params)options- Keyword list of options:key- The field to use as the ID (default::id):parse_ids- Function to parse/convert IDs (default:&to_string/1)
Returns
A tuple of {selected_from_current_page, missing_ids} where:
selected_from_current_page- Records from current page that are selectedmissing_ids- IDs that need to be fetched (not on current page)
Examples
# In a LiveView:
checked_ids = Slab.get_checked_ids(uri)
{current_selected, missing_ids} = Slab.get_selected_and_missing_ids(
users.entries,
checked_ids
)
# Fetch missing records
missing_users = Repo.all(from u in User, where: u.id in ^missing_ids)
# Combine
all_selected = current_selected ++ missing_users
Returns the path to patch to for the given page number.
Page 1 removes the page param entirely, keeping first-page URLs clean.
The after cursor param is always removed — the two pagination modes are
mutually exclusive.
Examples
iex> Slab.page_path("https://example.com/users?page=2", 3)
"/users?page=3"
iex> Slab.page_path("https://example.com/users?page=2", 1)
"/users"
Returns the path to patch to when sorting by field.
Sets the sort and sort_direction query params on the given URI. Clicking
the currently ascending sort field flips the direction to descending;
anything else sorts ascending. Changing the sort resets pagination — the
page and after params are removed, since neither an offset nor a cursor
is meaningful under a different ordering.
Examples
iex> Slab.sort_path("https://example.com/users", %{}, "name")
"/users?sort=name&sort_direction=asc"
iex> Slab.sort_path(
...> "https://example.com/users?sort=name&sort_direction=asc",
...> %{"sort" => "name", "sort_direction" => "asc"},
...> "name"
...> )
"/users?sort=name&sort_direction=desc"
iex> Slab.sort_path("https://example.com/users?page=3", %{}, "name")
"/users?sort=name&sort_direction=asc"
Renders a data table.
Columns are defined with <:col> slots — see the module docs for full
usage. Interactive features (row selection) are handled internally by a
live component; sorting is handled with patch links. Both require the
current uri, and sorting additionally reads the current params.
Data comes from one of two modes:
- List mode — pass
datawith pre-fetched records.schemais an optional rendering hint. - Query mode — omit
dataand passschema(anEcto.Schemamodule or an%Ecto.Query{}) plus arepo; Slab runs the query itself, applying sorting fromparams. The repo may also be set globally withconfig :slab, repo: MyApp.Repo.
Examples
<Slab.table id="users-table" data={@users} uri={@uri} params={@params}>
<:col field={:name} sortable />
<:col :let={user} label="Actions">
<.link navigate={"/users/#{user.id}/edit"}>Edit</.link>
</:col>
</Slab.table>
<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
<:col field={:name} sortable />
</Slab.table>Attributes
id(:string) (required)data(:list) - pre-fetched records to render; omit to have Slab query via schema and repo. Defaults tonil.schema(:any) - an Ecto.Schema module or Ecto.Query; renders cell values by field type, and in query mode is the source Slab fetches from. Defaults tonil.repo(:atom) - the Ecto.Repo used to run queries in query mode; falls back toconfig :slab, repo: MyApp.Repo. Defaults tonil.uri(:string) - the current request URI, from handle_params/3; enables sorting and row selection. Defaults tonil.params(:map) - the current request params, from handle_params/3; carries sort state. Defaults to%{}.checkable?(:boolean) - renders row-selection checkboxes; requires uri. Defaults tofalse.paginate(:atom) - pagination mode;:pageuses page/per_page params (works in both data modes),:cursoruses keyset cursors for constantly-updated data (query mode only); requires uri. Defaults tonil. Must be one ofnil,:page, or:cursor.per_page(:integer) - default page size; the URL per_page param overrides it up to max_per_page. Defaults to25.max_per_page(:integer) - upper clamp for the URL per_page param. Defaults to100.per_page_options(:list) - page sizes offered in the footer dropdown (page mode); values above max_per_page are dropped, and the current size is always included. Defaults to[10, 25, 50, 100].share_tab?(:boolean) - shows the Share tab above the table when uri is present. Defaults tofalse.columns_tab?(:boolean) - shows the Columns tab above the table when uri is present, letting users toggle and reorder columns via the columns[] URL param. Defaults tofalse.
Slots
col(required) - one slot per column. Accepts attributes:field(:any) - the record field to render; optional for virtual columns with a body.label(:string) - the column header; defaults to the humanized field name.sortable(:boolean) - renders the header as a sort patch link (requires uri); in query mode, also whitelists the field for ORDER BY.filterable(:boolean) - whitelists the field for filter URL params in query mode and adds an input to the Filters tab; strings match with case-insensitive contains, other types by equality, and filter[field][op]= enables eq/neq/gt/gte/lt/lte/contains.filter_query(:any) - custom 2-arity filter function (queryable, value) -> queryable; implies filterable, skips type casting, and may join associations.filter_type(:string) - overrides the Filters tab input type; defaults by schema type — booleans and Ecto.Enum fields get a select, everything else text. Must be one of"text","select", or"multiselect".filter_options(:list) - options for select/multiselect filter inputs, as[{label, value}]tuples or plain values; derived automatically for booleans and Ecto.Enum fields.filter_placeholder(:string) - placeholder for the Filters tab input.filter_min_chars(:integer) - minimum characters before a text filter change applies (default 0).optional(:boolean) - starts the column hidden until enabled through the Columns tab or the columns[] URL param.
Renders a tabbed container, typically placed above a table to organize filters, sharing, and other table tooling.
Tabs switch client-side (no server round trip). Each <:tab> takes a
label, an optional icon (see Slab.Components.icon/1 for the
available names), and an optional count badge — pass
get_filter_count/1 for a filters tab or
Slab.Helpers.URI.get_query_param_count/1 for a share tab so users can
see at a glance that the current view is filtered.
Examples
<Slab.tabs id="table-tabs" active="Filters">
<:tab label="Filters" icon="funnel-outline" count={Slab.get_filter_count(@params)}>
<div class="flex gap-x-4">
<Slab.filter id="filter-name" field={:name} uri={@uri} params={@params} />
</div>
</:tab>
<:tab label="Share" icon="bookmark-outline" count={Slab.Helpers.URI.get_query_param_count(@uri)}>
<Slab.share uri={@uri} />
</:tab>
</Slab.tabs>Attributes
id(:string) (required)active(:string) - label of the initially active tab; defaults to the first tab. Defaults tonil.flush_bottom?(:boolean) - opens the panel's bottom edge (no bottom border or rounding, extra bottom padding) so following content — like the table card — can overlap into it. Defaults tofalse.
Slots
tab(required) - one slot per tab. Accepts attributes:label(:string) (required) - the tab label.icon(:string) - optional icon name rendered before the label.count(:integer) - optional badge count rendered after the label; hidden when zero.