A data table component for Phoenix LiveView.

A table is composed from slots — every optional region is declared by the presence of a slot, and nothing renders that wasn't declared:

  • <:column> — one per column, with automatic cell rendering by Ecto field type, custom bodies, and URL-driven sorting
  • <:column_checkbox> — row-selection checkboxes stored in the URL
  • <:filter> — whitelists a field for URL-driven filtering and defines its input
  • <:tab> — the tab bar above the table (Filters, Columns, Share, Export, or fully custom tabs), in declaration order
  • <:pagination> — offset or keyset pagination

All table state — sorting, filters, pagination, column layout, 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. 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}>
  <:column field={:id} />
  <:column field={:name} />
  <:column field={:email} />
  <:column field={:inserted_at} />
</Slab.table>

Query mode — omit data and Slab fetches the schema through repo:

<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
  <:column field={:name} sortable />
  <:column field={:email} />
</Slab.table>

The repo can also be configured once, globally:

config :slab, repo: MyApp.Repo

Preload associations for use in column bodies (or exports and saves) with preload, in any shape Ecto.Query.preload/3 accepts:

<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo}
  preload={[:products, organization: :plan]} uri={@uri} params={@params}>

For full control of the base query — scoping what the table can ever see (authorization, multi-tenancy), joins, computed fields — pass query and Slab layers sorting, filtering, and pagination on top. The schema is derived from the query's source for field reflection, or pass schema explicitly alongside:

<Slab.table id="users-table" query={from u in MyApp.User, where: u.org_id == ^@org.id} ...>

A <:column> with no body renders the record's field automatically.

Custom cell rendering

Give a <:column> 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}>
  <:column :let={user} field={:name}>{String.upcase(user.name)}</:column>
  <:column :let={user} label="Actions">
    <.link navigate={~p"/users/#{user}/edit"}>Edit</.link>
  </:column>
</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}>
  <:column field={:name} sortable />
  <:column field={:email} sortable />
</Slab.table>

Filtering

Declare a <:filter> per filterable field 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 fields 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}>
  <:tab name="filters" />
  <:filter field={:name} />
  <:filter field={:inserted_at} />
  <:column field={:name} />
  <:column field={:inserted_at} />
</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.

Filters are field-level, not column-level — a <:filter> needs no matching <:column>, so you can filter on fields the table never shows.

For custom logic — full-text search, filtering through associations — pass a 2-arity query function. It receives the queryable and the raw param value, and can add joins or any Ecto condition:

<:filter field={:organization} query={fn query, value ->
  from u in query,
    join: o in assoc(u, :organization),
    where: ilike(o.name, ^"%#{value}%")
end} />

Each <:filter> also defines its input in the Filters tab (see Tabs). The input type derives from the schema — booleans and Ecto.Enum fields get a select with derived options, everything else a text input — and the type, label, options, placeholder, min_chars, and debounce attrs override the defaults. type="hidden" whitelists the field without rendering an input — for filters driven from elsewhere on the page.

External filter UI

The contract between filter UI and the table is the URL, so any component that patches filter[field] params drives the table — the table only requires the field to be whitelisted by a <:filter>. Slab.filter/1 works standalone anywhere on the page:

<Slab.filter id="filter-name" schema={MyApp.User} field={:name}
  uri={@uri} params={@params} label="Name" placeholder="Search names..." />

<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
  <:filter field={:name} type="hidden" />
  <:column field={:name} sortable />
</Slab.table>

Components not owned by Slab integrate the same way: patch the URL with filter[field]=value (or filter[field][]=value for multi-selects, and filter[field][op]=value for operators) — filter_path/3 builds those paths. State stays in the URL, so sharing, back-button, and exports keep working with any filter UI.

Pagination

Declare a <:pagination> slot with one of two types. :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} uri={@uri} params={@params}>
  <:column field={:name} />
  <:pagination per_page={25} />
</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} uri={@uri} params={@params}>
  <:column field={:inserted_at} sortable />
  <:pagination type={:cursor} per_page={25} />
</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 type.

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 the options attr). 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

Declare <:tab> slots to render a tab bar above the table. Tabs render in declaration order. Four names have built-in content:

  • <:tab name="filters" /> — one input per non-hidden <:filter>, with a badge showing the active filter count; requires uri
  • <:tab name="columns" /> — a picker driving the columns[] URL param (see Column visibility and order); requires uri
  • <:tab name="share" /> — a copyable link to the exact current view; requires uri
  • <:tab name="export" /> — CSV downloads (see Exporting); takes a limit attr

A body on a built-in tab replaces its default content (the badge count stays params-derived), and any other name defines a custom tab — give it a label, an optional icon and count, and a body:

<:tab name="filters" />
<:tab name="help" label="Help" icon="bookmark-outline">
  <p>Contact #data-team for access questions.</p>
</:tab>

No <:tab> slots, no tab bar. tabs/1, share/1, and filter/1 remain public for composing custom layouts outside the table.

Exporting

The Export tab downloads the table as CSV, generated server-side and delivered through the browser — no extra routes or setup. Two buttons:

  • Download current page — the rows exactly as displayed
  • Download all data — the first limit rows (default 1000) of the current filtered, sorted result; when the total exceeds the limit the button reads "Download first N rows" instead

Exports honor the current filters, sort, and column selection. Columns render their raw field values (see Slab.Export.csv/2 for the value formats), or the result of their export_value function when given — a 1-arity function receiving the record. That is how computed columns (a body but no field) join an export; without a field or an export_value, a virtual column — like action links — is skipped. The file travels over the LiveView socket, so keep limit in the thousands, not the millions:

<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
  <:tab name="export" limit={5000} />
  <:column field={:name} />
  <:column :let={user} label="Products" export_value={fn user -> Enum.map_join(user.products, ", ", & &1.name) end}>
    <.product_badges products={user.products} />
  </:column>
  <:pagination />
</Slab.table>

The download button uses a colocated hook — register Slab's hooks once in assets/js/app.js (see the README's installation section).

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 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" ...>
  <:tab name="columns" />
  <:column field={:name} />
  <:column 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

Declare <:column_checkbox /> and pass the current uri. It renders as the first column, always visible (it is not addressable through the Columns tab or the columns[] param), with checked row IDs stored in the checked query param via push_patch:

<Slab.table id="users-table" data={@users} uri={@uri}>
  <:column_checkbox />
  <:column 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.

Inline editing

Mark columns as editable and pass an on_save function. Editable columns render their input directly in the cell — there is no edit mode — and a save column (no heading) appears at the end of the table. Editing a value highlights the row's save button; clicking it (or pressing Enter) calls on_save once with the row's record and the changed fields:

<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo}
  uri={@uri} params={@params} on_save={&save_user/2}>
  <:column field={:name} editable />
  <:column field={:role} editable />
  <:column field={:inserted_at} />
</Slab.table>

def save_user(user, params) do
  user
  |> MyApp.User.changeset(params)
  |> MyApp.Repo.update()
end

Slab never writes to the database itself: on_save receives the record and a map of only the changed fields, with raw string values (%{"name" => "Ada"}) — cast them with your own changeset. Return {:ok, updated_record} to clear the row's pending state and render the updated record in place, or {:error, changeset_or_message} to keep the edits and show the error under the row.

Input types derive from the schema — booleans and Ecto.Enum fields get a select, everything else a text input. Text inputs read as plain text until focused, keeping the table scannable. Multiple columns can change before one save, and each row saves independently. Pending edits are component state, not URL state: they survive re-renders, sorting, and filtering, but not a page reload.

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 composed from slots.

Renders a tabbed container, typically placed above a table to organize filters, sharing, and other table tooling.

Functions

checked?(uri_or_params)

Returns whether any rows are checked in the given URI string or params map.

filter(assigns)

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" — debounced text input; with a string-typed field 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 with field IN (...)

When type is omitted it derives from schema — booleans and Ecto.Enum fields get a select with derived options, everything else a text input.

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-role" schema={MyApp.User} field={:role}
  uri={@uri} params={@params} label="Role" />

<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 <:filter> 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 %{}.
  • schema (:atom) - optional Ecto.Schema module used to derive the input type and options when they are not given. Defaults to nil.
  • type (:string) - the input type: "text", "select", or "multiselect"; derived from schema when omitted, defaulting to text. Defaults to nil.
  • label (:string) - optional label rendered beside the input. Defaults to nil.
  • placeholder (:string) - placeholder for the input. Defaults to nil.
  • options (:list) - select options, as [{label, value}] tuples or plain values; derived from schema for booleans and Ecto.Enum fields when not given. Defaults to [].
  • debounce (:integer) - milliseconds to debounce text input changes. Defaults to 300.
  • min_chars (:integer) - minimum characters before a text change applies (empty always applies, clearing the filter); submitting the form applies regardless. Defaults to 0.

filter_path(uri, field, value)

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"

get_checked_count(uri_or_params)

Returns the number of checked rows from a URI string or a params map.

get_checked_ids(uri)

Returns the list of checked row IDs (as strings) from a URI string or a params map.

get_checked_values(uri, records, options \\ [])

Returns the records whose ID is checked in the given URI.

Options

  • :key - the record field to match against checked IDs (default: :id)

get_filter_count(uri)

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

get_selected_and_missing_ids(current_page_records, checked_ids, options \\ [])

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 page
  • checked_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 selected
  • missing_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

page_path(uri, page)

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 types 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"

share(assigns)

Renders a share row: the current URL in a read-only input with a copy-to-clipboard button.

Because all table state — sorting, filters, pagination, selection — lives in the URL, the copied link reproduces the exact current view. Typically rendered inside a tabs/1 Share tab.

The copy button uses a colocated hook — register Slab's hooks once in assets/js/app.js (see the README's installation section).

Examples

<Slab.share uri={@uri} />

Attributes

  • id (:string) - unique DOM id, when rendering more than one. Defaults to "slab-share".
  • uri (:string) (required) - the current request URI, from handle_params/3.

sort_path(uri, params, field)

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"

table(assigns)

Renders a data table composed from slots.

Every optional region is declared by the presence of a slot — columns, row-selection checkboxes, filters, tabs, pagination. See the module docs for full usage of each. Interactive features are handled internally by a live component; sorting is handled with patch links. URL-driven features require the current uri and params.

Data comes from one of two modes:

  • List mode — pass data with pre-fetched records. schema is an optional rendering hint.
  • Query mode — omit data and pass schema (an Ecto.Schema module or an %Ecto.Query{}) plus a repo; Slab runs the query itself, applying sorting, filtering, and pagination from params. The repo may also be set globally with config :slab, repo: MyApp.Repo.

Examples

<Slab.table id="users-table" data={@users} uri={@uri} params={@params}>
  <:column field={:name} sortable />
  <:column :let={user} label="Actions">
    <.link navigate={"/users/#{user.id}/edit"}>Edit</.link>
  </:column>
</Slab.table>

<Slab.table id="users-table" schema={MyApp.User} repo={MyApp.Repo} uri={@uri} params={@params}>
  <:tab name="filters" />
  <:tab name="share" />
  <:filter field={:name} placeholder="Search names..." />
  <:column_checkbox />
  <:column field={:name} sortable />
  <:pagination per_page={25} />
</Slab.table>

Attributes

  • id (:string) (required)
  • data (:list) - pre-fetched records to render; omit to have Slab query via schema and repo. Defaults to nil.
  • schema (:atom) - an Ecto.Schema module; renders cell values by field type, derives filter inputs, casts URL values, and in query mode is the default source Slab fetches from. Defaults to nil.
  • query (:any) - an Ecto.Query used as the base of every fetch instead of the schema — for scoping (authorization, multi-tenancy), joins, or preloads; schema still provides field reflection, and is derived from the query's source when omitted. Defaults to nil.
  • preload (:any) - associations to preload on fetched records, in any shape Ecto.Query.preload/3 accepts; applies on top of schema or query. Defaults to nil.
  • repo (:atom) - the Ecto.Repo used to run queries in query mode; falls back to config :slab, repo: MyApp.Repo. Defaults to nil.
  • uri (:string) - the current request URI, from handle_params/3; required by URL-driven slots. Defaults to nil.
  • params (:map) - the current request params, from handle_params/3; carries sort, filter, pagination, column, and selection state. Defaults to %{}.
  • on_save (:any) - 2-arity function (record, changed_params) -> {:ok, record} | {:error, error} called when a row's save button is clicked; required when any column is editable — Slab never writes to the database itself. Defaults to nil.

  • row_click (:any) - 1-arity function (record) -> %Phoenix.LiveView.JS{} attached as phx-click on each data cell; skips checkbox and editable columns. Defaults to nil.

Slots

  • tab - tabs rendered above the table, in declaration order; the names filters, columns, share, and export have built-in content (a body replaces it), any other name is a custom tab requiring a label and a body. Accepts attributes:
    • name (:string) (required) - the tab type, or a custom tab's identity.
    • label (:string) - the tab label; derived for built-in names, required for custom tabs.
    • icon (:string) - icon rendered before the label; derived for built-in names — see Slab.Components.icon/1 for the available names.
    • count (:integer) - badge count for custom tabs; built-in tabs compute their own.
    • limit (:integer) - export only: maximum rows in a full-data export (default 1000); the download travels over the LiveView socket, so keep it modest.
  • filter - one slot per filterable field; whitelists the field for filter URL params in query mode and defines its input in the Filters tab. Accepts attributes:
    • field (:atom) (required) - the field to filter on.
    • label (:string) - label for the Filters tab input; defaults to the humanized field name.
    • type (:string) - the Filters tab input type; defaults by schema type — booleans and Ecto.Enum fields get a select, everything else text. "hidden" whitelists the field without rendering an input, for filters driven from elsewhere on the page. Must be one of "text", "select", "multiselect", or "hidden".
    • options (:list) - options for select/multiselect inputs, as [{label, value}] tuples or plain values; derived automatically for booleans and Ecto.Enum fields.
    • placeholder (:string) - placeholder for the Filters tab input.
    • min_chars (:integer) - minimum characters before a text filter change applies (default 0).
    • debounce (:integer) - milliseconds to debounce text input changes (default 300).
    • query (:any) - custom 2-arity filter function (queryable, value) -> queryable; skips type casting and may join associations.
  • column (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.
    • optional (:boolean) - starts the column hidden until enabled through the Columns tab or the columns[] URL param.
    • export_value (:any) - 1-arity function (record) -> value used when exporting this column; makes virtual columns exportable and overrides the raw field value on field columns.
    • editable (:boolean) - renders the cell as an input feeding the row's save action; requires a field and the table's on_save function, and cannot combine with a body.
  • column_checkbox - renders row-selection checkboxes as the first column, always visible; checked row IDs live in the checked URL param; requires uri.
  • pagination - paginates the table; at most one. Accepts attributes:
    • type (:atom) - :page uses page/per_page params (works in both data modes), :cursor uses keyset cursors for constantly-updated data (query mode only); requires uri; defaults to :page. Must be one of :page, or :cursor.
    • per_page (:integer) - default page size (25); the URL per_page param overrides it up to max_per_page.
    • max_per_page (:integer) - upper clamp for the URL per_page param (100).
    • options (:list) - page sizes offered in the footer dropdown (page mode, default [10, 25, 50, 100]); values above max_per_page are dropped, and the current size is always included.

tabs(assigns)

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 to nil.
  • 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 to false.

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.