Slab
A data table component for Phoenix LiveView.
Pass records and column slots, get a rendered table with:
- 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 full-value hover tooltip, maps render as code blocks, string arrays render one item per line.
- Custom cell rendering via
<:col>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, pagination, and refreshes — and are shareable as links.
Table state lives in the URL: the component patches query params, and the
parent LiveView reacts to handle_params/3 by requerying. Callers pass in the
current uri and params to enable these features.
Demo app
The /examples directory contains many examples, including a full
Phoenix demo app that demonstrates the different options:
git clone https://github.com/chrislaskey/slab.git
cd slab/examples/demo
mix setup && iex -S mix phx.server
Note: the script examples/regenerate.sh can be used to regenerate the demo from a pinned
phx.new release.
Installation
Add slab to your dependencies in mix.exs (requires LiveView ~> 1.1):
def deps do
[
{:slab, github: "chrislaskey/slab"}
]
end
JavaScript hooks
Slab ships a small amount of JavaScript (the share tab's copy-to-clipboard
button) as a colocated hook, and select/multiselect filters are rendered by
phoenix_select (pulled in
automatically), which does the same — nothing to npm install, but both
hook sets must be registered once in assets/js/app.js:
import {hooks as slabHooks} from "phoenix-colocated/slab"
import {hooks as phoenixSelectHooks} from "phoenix-colocated/phoenix_select"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...colocatedHooks, ...slabHooks, ...phoenixSelectHooks},
// ...
})
Tailwind CSS
The components are styled with Tailwind utility classes. Add both libraries to your Tailwind sources so the classes are generated.
Tailwind v4 (assets/css/app.css):
@source "../../deps/slab/lib";
@source "../../deps/phoenix_select/lib";
Tailwind v3 (assets/tailwind.config.js):
content: [
// ...existing paths
"../deps/slab/lib/**/*.ex",
"../deps/phoenix_select/lib/**/*.ex",
],
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:
<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.Repo
Passing an %Ecto.Query{} lets you 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 User, where: u.org_id == ^@org.id} ...>
Query mode requires Ecto, which is an optional dependency — your app's Ecto version is used as-is. List mode works without Ecto entirely.
A <:col> with no body renders the record's field automatically.
Automatic type-based rendering
Pass a schema (an Ecto.Schema module) and cells render according to each
field's type. Without a schema, values render as plain strings. In query
mode the schema is already known, so typed rendering is automatic.
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={:status}>{String.upcase(user.status)}</: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 — nothing else. In query mode Slab requeries with the new sort
automatically, and 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:
# In the parent LiveView
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:
<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 lower(name) LIKE %ada%
?filter[active]=true # WHERE active = true
?filter[inserted_at][gte]=2026-01-01 # WHERE inserted_at >= ...
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
filter[field][op]= enables eq, neq, gt, gte, lt, lte, and
contains. Only declared columns are ever filtered; invalid values, unknown
operators, and LIKE wildcards in user input are all neutralized.
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} />
Filter inputs
Slab.filter/1 renders a ready-made filter input that drives the
filter[field] param — point it at a filterable column and the table
requeries as the user types or selects:
<div class="flex gap-x-4">
<Slab.filter id="filter-name" field={:name} uri={@uri} params={@params}
label="Name" placeholder="Search names..." min_chars={3} />
<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"}]} />
</div>
<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
<:col field={:name} filterable sortable />
<:col field={:active} filterable />
<:col field={:role} filterable />
</Slab.table>
Text inputs debounce (default 300ms) and can wait for min_chars before
applying. Select and multiselect are searchable, keyboard-navigable
comboboxes from phoenix_select;
clearing the selection clears the filter, and multiselect values filter with
field IN (...). Remember the one-time hook registration from the
installation section.
Prefer your own markup? Build filter UIs in the parent with
Slab.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. The per_page attribute sets the default page size; a per_page
URL param can override it but is clamped to max_per_page (default 100), so
a crafted URL can't request unbounded rows:
<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 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 blobs: ?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 1 to 25 of 223 entries" summary
on the left, numbered page links with ellipses (1 … 4 5 6 … 21) and
prev/next chevrons, and a page-size dropdown (per_page_options, default
[10, 25, 50, 100]). The total comes from a count query cached on the
current filters — page and sort changes never re-count, only filter changes
do. Cursor mode never runs a count query at all; both modes detect a next
page by fetching one extra record.
Tabs and sharing
A tab bar above the table is derived automatically from the table definition — nothing to declare:
a Filters tab appears when any
<:col>isfilterable, containing one input per filterable column and a badge with 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:<:col field={:role} filterable filter_type="multiselect" /><:col field={:name} filterable filter_placeholder="Search names..." filter_min_chars={2} />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 read-only copy of the current URL with a copy-to-clipboard button — meaningful because all table state lives in the URL
No qualifying tabs, no tab bar. The building blocks stay public for custom
layouts outside the table: Slab.tabs/1 (client-side tabs with icons and
count badges), Slab.share/1, and Slab.filter/1, plus the badge helpers
Slab.get_filter_count/1 and Slab.Helpers.URI.get_query_param_count/1.
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 first
Names match the declared columns (a column's key is its field, or a slug
of its label for virtual columns — "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, and sorting/filtering are unaffected by visibility — a hidden column's filter still applies.
Row selection
Track the current URI in handle_params/3 (as above) and pass it along with
checkable?:
<Slab.table id="users-table" data={@users} checkable? uri={@uri}>
<:col field={:name} />
<:col field={:email} />
</Slab.table>
Checked row IDs live in the checked query param. Read them back anywhere:
Slab.get_checked_ids(uri) #=> ["1", "42"]
Slab.get_checked_values(uri, records)
Slab.checked?(uri)
For selections that span paginated results, use
Slab.get_selected_and_missing_ids/3 to split checked IDs into records
already on the current page and IDs that need fetching.
Reference
Full documentation for every attribute, with types and defaults, is generated
from the component declarations — run mix docs or see the Slab module on
HexDocs. The tables below are the quick version.
Slab.table/1 attributes
| Attribute | Default | Example | Description |
|---|---|---|---|
id | required | id="users-table" | Unique component id |
data | nil | data={@users} | Pre-fetched records (list mode); omit for query mode |
schema | nil | schema={MyApp.User} | Rendering hint in list mode; the queryable (schema module or %Ecto.Query{}) in query mode |
repo | nil | repo={MyApp.Repo} | Repo for query mode; falls back to config :slab, repo: MyApp.Repo |
uri | nil | uri={@uri} | Current request URI from handle_params/3; enables sorting, selection, pagination |
params | %{} | params={@params} | Current request params from handle_params/3; carries sort/page/filter state |
checkable? | false | checkable? | Row-selection checkboxes; requires uri |
paginate | nil | paginate={:page} | :page (offset, both modes) or :cursor (keyset, query mode only); requires uri |
per_page | 25 | per_page={50} | Default page size |
max_per_page | 100 | max_per_page={200} | Upper clamp for the URL per_page param, so a crafted URL can't request unbounded rows |
per_page_options | [10, 25, 50, 100] | per_page_options={[25, 100]} | Page sizes offered in the footer dropdown (page mode) |
share_tab? | false | share_tab? | Shows the Share tab above the table when uri is present |
columns_tab? | false | columns_tab? | Shows the Columns tab, letting users toggle and reorder columns via columns[] |
<:col> slot attributes
| Attribute | Default | Example | Description |
|---|---|---|---|
field | nil | field={:name} | Record field to render; optional for virtual columns with a body |
label | humanized field | label="Full Name" | Column header text |
sortable | false | sortable | Header becomes a sort patch link; whitelists the field for ORDER BY in query mode |
filterable | false | filterable | Whitelists the field for filter[...] URL params in query mode and adds an input to the Filters tab |
filter_query | nil | filter_query={&by_org/2} | Custom 2-arity (queryable, value) -> queryable filter; implies filterable, may join associations |
filter_type | derived | filter_type="multiselect" | Filters tab input type ("text", "select", "multiselect"); defaults by schema type — booleans and Ecto.Enum get a select |
filter_options | derived | filter_options={[{"Admin", "admin"}]} | Options for select/multiselect inputs; derived automatically for booleans and Ecto.Enum fields |
filter_placeholder | nil | filter_placeholder="Search..." | Placeholder for the Filters tab input |
filter_min_chars | 0 | filter_min_chars={3} | Minimum characters before a text filter change applies |
optional | false | optional | Starts the column hidden until enabled via the Columns tab or columns[] param |
Columns with a body receive the record via :let:
<:col :let={user} field={:name}>{String.upcase(user.name)}</:col>
Slab.filter/1 attributes
| Attribute | Default | Example | Description |
|---|---|---|---|
id | required | id="filter-name" | Unique component id |
field | required | field={:name} | Filter key; matches a <:col filterable> field |
uri | required | uri={@uri} | Current request URI from handle_params/3 |
params | %{} | params={@params} | Current request params; carries the input's current value |
type | "text" | type="multiselect" | "text", "select", or "multiselect" |
label | nil | label="Status" | Label rendered above the input |
placeholder | nil | placeholder="Search..." | Placeholder for the input |
options | [] | options={[{"Active", "true"}]} | Select options, as [{label, value}] tuples or plain values |
debounce | 300 | debounce={500} | Milliseconds to debounce text input changes |
min_chars | 0 | min_chars={3} | Minimum characters before a text change applies; empty always applies (clearing the filter) |
Slab.tabs/1 attributes
| Attribute | Default | Example | Description |
|---|---|---|---|
id | required | id="table-tabs" | Unique DOM id |
active | first tab | active="Filters" | Label of the initially active tab |
Each <:tab> slot takes label (required), icon (optional, see
Slab.Components.icon/1), and count (optional badge, hidden when zero).
Slab.share/1 attributes
| Attribute | Default | Example | Description |
|---|---|---|---|
uri | required | uri={@uri} | Current request URI shown in the copyable input |
URL params
The URL is Slab's state contract — every param is readable, shareable, and validated against whitelists and field types before touching a query:
| Param | Written by | Example |
|---|---|---|
sort, sort_direction | Sortable headers | ?sort=name&sort_direction=desc |
page, per_page | Page-mode pagination | ?page=3&per_page=50 |
after[id], after[value] | Cursor-mode pagination | ?after[id]=42&after[value]=2026-01-01T00%3A00%3A00Z |
filter[field] | Filter inputs | ?filter[name]=ada |
filter[field][op] | Custom filter UIs | ?filter[age][gte]=21 |
columns | Columns tab picker | ?columns[]=email&columns[]=name |
checked | Row-selection checkboxes | ?checked[]=1&checked[]=42 |
Helper functions
| Function | Description |
|---|---|
Slab.sort_path(uri, params, field) | Path that sorts by field, flipping direction and resetting pagination |
Slab.page_path(uri, page) | Path for a page number; page 1 drops the param |
Slab.filter_path(uri, field, value) | Path that sets/clears filter[field] and resets pagination |
Slab.get_filter_count(uri_or_params) | Number of active filters (for a filters tab badge) |
Slab.Helpers.URI.get_query_param_count(uri) | Recursive count of query params (for a share tab badge) |
Slab.get_checked_ids(uri_or_params) | Checked row IDs as strings |
Slab.get_checked_count(uri_or_params) | Number of checked rows |
Slab.get_checked_values(uri, records, opts) | Records whose ID is checked |
Slab.checked?(uri_or_params) | Whether any rows are checked |
Slab.get_selected_and_missing_ids(records, ids, opts) | Splits selections into current-page records and IDs to fetch (cross-page selection) |
Architecture
Slab.table/1 is a function component — it declares and validates attributes
and slots at compile time, and is the stable public interface. Internally it
renders Slab.Live, a live component that owns interactive state: row
selection, and in query mode the data fetching itself — queries only re-run
when their inputs (source, repo, sort) actually change, not on every parent
re-render. Sorting needs no events at all: headers are plain patch links
built with Slab.sort_path/3.
Testing
mix test
License
MIT — see LICENSE.md.