Cinder is a data collection component for Phoenix LiveView with Ash Framework integration. It supports table, list, and grid layouts with shared filtering, sorting, search, and pagination.
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort search>{user.name}</:col>
<:col :let={user} field="email" filter>{user.email}</:col>
<:col :let={user} field="created_at" sort>{user.created_at}</:col>
</Cinder.collection><!-- Table (default) -->
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
</Cinder.collection>
<!-- List -->
<Cinder.collection resource={MyApp.User} actor={@current_user} layout={:list}>
<:col field="name" filter sort />
<:item :let={user}>
<div class="p-4">{user.name}</div>
</:item>
</Cinder.collection>
<!-- Grid -->
<Cinder.collection resource={MyApp.Product} actor={@current_user} layout={:grid} grid_columns={[xs: 1, md: 2, lg: 3]}>
<:col field="name" filter sort />
<:item :let={product}>
<div class="p-4 border rounded">{product.name}</div>
</:item>
</Cinder.collection><!-- Resource -->
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<!-- Pre-configured query -->
<Cinder.collection query={MyApp.User |> Ash.Query.filter(active: true)} actor={@current_user}>
<!-- Custom read action -->
<Cinder.collection query={Ash.Query.for_read(MyApp.User, :active_users)} actor={@current_user}>- Direct fields:
field="name" - Relationships:
field="department.name"(dot notation) - Embedded resources:
field="settings__country"(double underscore)
field- required for data columnsfilter- enables filtering (auto-detects type from Ash attribute)sort- enables sortingsearch- includes field in global searchlabel="Custom"- override column headerclass="css-class"- CSS class for table cells
<!-- Auto-detected from Ash attribute type -->
<:col field="status" filter />
<!-- Specify type -->
<:col field="status" filter={:select} />
<!-- Full configuration -->
<:col field="status" filter={[type: :select, prompt: "All Statuses", options: @statuses]} />
<:col field="price" filter={[type: :number_range, min: 0, max: 1000]} />
<:col field="tags" filter={[type: :multi_select, match_mode: :any]} />
<:col field="active" filter={[type: :boolean, labels: %{true: "Active", false: "Inactive"}]} />
<!-- Custom filter function -->
<:col field="name" filter={[type: :text, fn: &custom_name_filter/2]} /><!-- Basic sorting (cycle: nil → asc → desc → nil) -->
<:col field="name" sort />
<!-- Custom sort cycles -->
<:col field="priority" sort={[cycle: [:desc, :asc]]} />
<:col field="created_at" sort={[cycle: [:desc, :asc, nil]]} /><!-- Additive (default): clicking B while sorted by A gives "A then B" -->
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<!-- Exclusive: clicking a column replaces existing sorts -->
<Cinder.collection resource={MyApp.User} actor={@current_user} sort_mode="exclusive"><:col :let={user} label="Actions">
<.link patch={~p"/users/#{user.id}/edit"}>Edit</.link>
<button phx-click="delete" phx-value-id={user.id}>Delete</button>
</:col>Filter on fields without displaying them as columns:
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<!-- Filter-only fields -->
<:filter field="department.name" type="select" options={@departments} />
<:filter field="active" type="boolean" />
<:filter field="created_at" type="date_range" />
</Cinder.collection>resource={Resource}orquery={query}- data sourceactor={@current_user}- required for Ash authorization
layout={:table | :list | :grid}- layout type (default::table)grid_columns={4}orgrid_columns={[xs: 1, md: 2, lg: 3]}- grid column counttheme="modern"- built-in themes: default, modern, retro, futuristic, dark, daisy_ui, flowbite, compactpage_size={25}- fixed page sizepage_size={[default: 25, options: [10, 25, 50, 100]]}- configurable with dropdownurl_state={@url_state}- enable URL synchronizationclick={fn item -> JS.navigate(~p"/path/#{item.id}") end}- row/item click handleritem_class={fn item -> if item.urgent, do: "bg-red-50" end}- per-row/item class (string or function), appended to the theme classquery_opts={[timeout: 30_000, load: [:association]]}- Ash query optionstenant={@tenant}- multi-tenancy supportscope={@scope}- Ash scope for authorization context
<!-- Auto-enabled when columns have search attribute -->
<:col :let={user} field="name" search filter>{user.name}</:col>
<!-- Custom search configuration -->
<Cinder.collection search={[label: "Search users", placeholder: "Enter name or email"]}>
<!-- Custom search function -->
<Cinder.collection search={[fn: &MyApp.CustomSearch.search/3]}>
<!-- Disable search -->
<Cinder.collection search={false}><!-- Collapsed by default -->
<Cinder.collection show_filters={:toggle}>
<!-- Expanded by default with toggle button -->
<Cinder.collection show_filters={:toggle_open}>
<!-- Always show / always hide -->
<Cinder.collection show_filters={true}>
<Cinder.collection show_filters={false}>Global default: config :cinder, show_filters: :toggle
empty_message="No records found"- custom empty state textloading_message="Loading..."- custom loading state texterror_message="Failed to load"- custom error state textfilters_label="Filters"- customize filter section labelsort_label="Sort by:"- label for sort controls (list/grid layouts)
Auto-detected from Ash resource attributes:
| Ash Type | Filter Type | UI |
|---|---|---|
:string |
:text |
Text input with contains search |
:boolean |
:boolean |
Radio buttons (Yes/No) |
:date, :datetime |
:date_range |
From/To date pickers |
:integer, :decimal |
:number_range |
Min/Max inputs |
Ash.Type.Enum |
:select |
Dropdown with enum values |
{:array, _} |
:multi_select |
Multi-select dropdown |
Additional filter types:
:radio_group- Radio buttons for arbitrary options (not just boolean):multi_checkboxes- Checkbox list for multi-value selection:checkbox- Single checkbox for "show only X" filtering:autocomplete- Searchable dropdown for large option lists
- Text:
operator,case_sensitive,placeholder - Select:
options,prompt - Boolean:
labelsmap withtrue/falsekeys - Date Range:
include_time - Number Range:
min,max,step - Multi-Select:
options,prompt,match_mode(:any/:all) - Multi-Checkboxes:
options,match_mode(:any/:all) - Checkbox:
value,label - Radio Group:
options - Autocomplete:
options,placeholder,max_results
The <:controls> slot replaces the default filter/search layout while keeping state management intact:
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort search>{user.name}</:col>
<:col :let={user} field="status" filter={:select}>{user.status}</:col>
<:controls :let={controls}>
<Cinder.Controls.render_header {controls} />
<div class="flex gap-4">
<Cinder.Controls.render_search search={controls.search} theme={controls.theme} target={controls.target} />
<Cinder.Controls.render_filter
:for={{_name, filter} <- controls.filters}
filter={filter} theme={controls.theme} target={controls.target}
/>
</div>
</:controls>
</Cinder.collection>filters- keyword list of filters keyed by field atomsearch- search input data (or nil)active_filter_count- number of active filterstarget- LiveComponent target forphx-targettheme- resolved theme maptable_id,filters_label,filter_mode,filter_values,raw_filter_params
Cinder.Controls.render_filter/1- single filter (label + input + clear)Cinder.Controls.render_search/1- search inputCinder.Controls.render_header/1- default header (title, active count, clear all, toggle)
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name">{user.name}</:col>
<:loading>
<div class="flex items-center gap-2 p-8 justify-center">Loading...</div>
</:loading>
<:empty :let={context}>
<%= if context.filtered? do %>
<p>No results match your filters.</p>
<% else %>
<p>No records yet.</p>
<% end %>
</:empty>
<:error>
<p>Something went wrong.</p>
</:error>
</Cinder.collection>Empty slot context: filtered?, filters, search_term. State precedence: loading > error > empty > data.
Enable bookmarkable, shareable collection states:
defmodule MyAppWeb.UsersLive do
use MyAppWeb, :live_view
use Cinder.UrlSync
def handle_params(params, uri, socket) do
socket = Cinder.UrlSync.handle_params(params, uri, socket)
{:noreply, socket}
end
def render(assigns) do
~H"""
<Cinder.collection resource={MyApp.User} actor={@current_user} url_state={@url_state} id="users">
<:col :let={user} field="name" filter sort>{user.name}</:col>
</Cinder.collection>
"""
end
endRefresh data while preserving filters, sorting, and pagination:
import Cinder.Refresh
def handle_event("delete", %{"id" => id}, socket) do
# ... delete logic ...
{:noreply, refresh_table(socket, "collection-id")}
end
# Refresh multiple collections
{:noreply, refresh_tables(socket, ["collection1", "collection2"])}For PubSub-driven updates without re-querying:
import Cinder.Update
# Update single item by ID
{:noreply, update_item(socket, "table-id", user_id, fn user -> %{user | status: :active} end)}
# Update multiple items
{:noreply, update_items(socket, "table-id", user_ids, fn user -> %{user | active: false} end)}
# Only update if visible on current page (avoids unnecessary DB calls)
{:noreply, update_if_visible(socket, "table-id", raw_user, fn raw ->
{:ok, loaded} = Ash.load(raw, [:department])
loaded
end)}# config/config.exs
config :cinder, :filters, [
slider: MyApp.Filters.Slider
]# application.ex
def start(_type, _args) do
Cinder.setup() # Registers configured filters
# ... rest of startup
enddefmodule MyApp.Filters.Slider do
@behaviour Cinder.Filter
use Phoenix.Component
@impl true
def render(column, current_value, theme, assigns), do: # HEEx template
@impl true
def process(raw_value, column), do: %{type: :slider, value: raw_value}
@impl true
def validate(filter_value), do: true
@impl true
def default_options, do: [min: 0, max: 100, step: 1]
@impl true
def empty?(value), do: is_nil(value)
@impl true
def build_query(query, field, filter_value), do: # Ash query filter
end
<:col field="price" filter={[type: :slider, min: 0, max: 1000]} /># config/config.exs
config :cinder, default_theme: "modern"<Cinder.collection theme="dark" resource={MyApp.User} actor={@current_user}>"default"- minimal styling"modern"- clean, contemporary design"dark"- dark mode styling"retro"- cyberpunk aesthetic"futuristic"- sci-fi inspired"daisy_ui"- DaisyUI component styles"flowbite"- Flowbite design system"compact"- dense layout
defmodule MyApp.CustomTheme do
use Cinder.Theme
set :container_class, "bg-white shadow rounded-lg"
set :th_class, "px-4 py-2 text-left font-semibold"
endEnable checkbox selection and bulk operations on selected records:
<Cinder.collection resource={MyApp.User} actor={@current_user} selectable>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<!-- Themed buttons (recommended): use label and variant for auto-styled buttons -->
<:bulk_action action={:archive} label="Archive ({count})" variant={:primary} />
<:bulk_action action={:export} label="Export" variant={:secondary} />
<:bulk_action action={:destroy} label="Delete" variant={:danger} confirm="Delete {count}?" />
<!-- Custom buttons: provide inner content for full control -->
<:bulk_action action={&MyApp.Users.soft_delete/2} on_success={:deleted} :let={ctx}>
<button disabled={ctx.selected_count == 0}>Delete Selected</button>
</:bulk_action>
</Cinder.collection>action- Ash action atom or function/2 (required)label- Button text (enables themed button, supports{count}interpolation)variant- Button style::primary(default),:secondary,:dangerconfirm- Confirmation message ({count}interpolates selection count)on_success- Event name sent to parent on successon_error- Event name sent to parent on erroraction_opts- Additional Ash options (e.g.,[return_records?: true])
selectable- Enable checkboxes (works in table/grid/list)on_selection_change- Event name for selection state changes
def handle_info({:deleted, %{count: count}}, socket) do
{:noreply, put_flash(socket, :info, "Deleted #{count} users")}
end
def handle_info({:delete_failed, %{reason: reason}}, socket) do
{:noreply, put_flash(socket, :error, "Failed: #{inspect(reason)}")}
endAll user-facing strings use dgettext("cinder", ...). Supported locales: Brazilian Portuguese (pt_BR), Danish (da), Dutch (nl), English (en), French (fr), German (de), Norwegian (no), Spanish (es), Swedish (sv).
Set locale in mount: Gettext.put_locale("nl")
Use render_async for data-dependent assertions:
{:ok, view, html} = live(conn, ~p"/users")
assert html =~ "Loading..."
assert render_async(view) =~ "John Doe"