Data Table
A Hotwire-first data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission.
Complete demo
Full feature set — search, sort, numbered pagination, per-page, select-all, row checkboxes, bulk actions, row actions dropdown, column visibility, badge cells.
FORM_ID = "employees_form"
DataTable(id: "employees_list") do
DataTableToolbar do
DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search)
div(class: "flex items-center gap-2") do
DataTableColumnToggle(columns: [
{key: :email, label: "Email"},
{key: :department, label: "Department"}
])
DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page)
DataTableBulkActions do
Button(type: "submit", form: FORM_ID, formaction: bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" }
Button(type: "submit", form: FORM_ID, formaction: bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" }
end
end
end
DataTableForm(id: FORM_ID, action: "") do
div(class: "rounded-md border") do
Table do
TableHeader do
TableRow do
TableHead(class: "w-10") { DataTableSelectAllCheckbox() }
DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path)
DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path)
end
end
TableBody do
@employees.each do |e|
TableRow do
TableCell { DataTableRowCheckbox(value: e.id) }
TableCell { e.name }
TableCell { e.salary }
end
end
end
end
end
end
DataTablePaginationBar do
DataTableSelectionSummary(total_on_page: @employees.size)
DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path)
end
end
Server-driven
Turbo Frame GET on each sort/search/page. No client-only state.
DataTable(id: "server") do
DataTableToolbar do
DataTableSearch(path: my_path, preserved_params: {"sort" => @sort, "direction" => @direction}.compact_blank)
end
Table do
TableHeader do
TableRow do
DataTableSortHead(column_key: :name, label: "Name", path: my_path)
end
end
TableBody do
@rows.each { |r| TableRow { TableCell { r.name } } }
end
end
DataTablePagination(page: @page, per_page: @per_page, total_count: @total, path: my_path)
end
Selection + bulk actions
DataTableBulkActions is a plain slot — put any Phlex content inside. Row checkboxes are <input name="ids[]"> elements inside DataTableForm. Bulk action buttons submit that form with the selected IDs via HTML5 form-association attributes.
DataTable(id: "selection") do
DataTableToolbar do
div
DataTableBulkActions do
Button(type: "submit", form: "selection_form",
formaction: bulk_delete_path, formmethod: "post",
data: {turbo_confirm: "Delete selected?"},
variant: :destructive, size: :sm) { "Delete" }
Button(type: "submit", form: "selection_form",
formaction: bulk_export_path, formmethod: "post",
variant: :outline, size: :sm) { "Export" }
end
end
DataTableForm(id: "selection_form", action: "") do
Table do
TableHeader do
TableRow do
TableHead { DataTableSelectAllCheckbox() }
TableHead { "Name" }
end
end
TableBody do
@rows.each do |r|
TableRow do
TableCell { DataTableRowCheckbox(value: r.id) }
TableCell { r.name }
end
end
end
end
end
DataTableSelectionSummary(total_on_page: @rows.size)
end
Bulk action button attributes
Because the submit buttons live inside DataTableToolbar (outside DataTableForm), you must use HTML5 form-association attributes to wire them up. Server receives params[:ids] as an array.
| Attribute | Required | Purpose |
|---|
type: "submit" | yes | native submit button |
form: FORM_ID | yes (button is outside DataTableForm) | HTML5 form-association — lets the button submit a form located elsewhere in the DOM |
formaction: "/path" | yes | target URL, overrides the form's action |
formmethod: "post" | yes | HTTP verb, overrides the form's method |
formnovalidate: true | optional | skip HTML5 validation |
data: {turbo_confirm: "Are you sure?"} | optional | Rails/Turbo confirmation dialog before submit |
button_tobutton_to "Delete", path, method: :delete, form: {data: {turbo_confirm: "..."}}
Rails controller example
Your endpoint receives the selected IDs as params[:ids] (an array of strings):
class EmployeesController < ApplicationController
def bulk_delete
ids = Array(params[:ids]).map(&:to_i)
Employee.where(id: ids).destroy_all
redirect_to employees_path, notice: "Deleted #{ids.size} employees"
end
def bulk_export
ids = Array(params[:ids]).map(&:to_i)
employees = Employee.where(id: ids)
send_data employees.to_csv, filename: "employees.csv"
end
end
Column visibility
Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching.
Column visibility is client-side and resets on every Turbo Frame swap (sort/search/page re-renders). If you need it to persist, encode it in a URL param (e.g. `?columns=name,status`) or store in localStorage.
DataTable(id: "columns") do
DataTableToolbar do
DataTableColumnToggle(columns: [
{key: :email, label: "Email"},
{key: :salary, label: "Salary"}
])
end
Table do
TableHeader do
TableRow do
TableHead { "Name" }
TableHead(data: {column: "email"}) { "Email" }
TableHead(data: {column: "salary"}) { "Salary" }
end
end
TableBody do
@rows.each do |r|
TableRow do
TableCell { r.name }
TableCell(data: {column: "email"}) { r.email }
TableCell(data: {column: "salary"}) { r.salary }
end
end
end
end
end
Custom cell renderers
Plain Ruby helpers for badge/date/currency — the gem does not ship renderers.
| Name | Status | Salary |
|---|
| Alice | Active | $90,000 |
| Bob | Inactive | $75,000 |
def status_badge(status)
variant = {"Active" => :success, "Inactive" => :destructive}.fetch(status, :outline)
Badge(variant: variant, size: :sm) { plain status }
end
DataTable(id: "renderers") do
Table do
TableHeader do
TableRow do
TableHead { "Name" }
TableHead { "Status" }
TableHead(class: "text-right") { "Salary" }
end
end
TableBody do
@rows.each do |r|
TableRow do
TableCell { r.name }
TableCell { status_badge(r.status) }
TableCell(class: "text-right") { plain view_context.number_to_currency(r.salary, precision: 0) }
end
end
end
end
end
Expandable rows
Toggle a detail region below each row. Accessible: aria-expanded, aria-controls, keyboard-focusable button, region role on the expanded content.
DataTable(id: "expand_demo") do
Table do
TableHeader do
TableRow do
TableHead(class: "w-10") { }
TableHead { "Name" }
TableHead { "Role" }
end
end
TableBody do
@rows.each do |r|
detail_id = "row-#{r.id}-detail"
TableRow do
TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle details for #{r.name}") }
TableCell { r.name }
TableCell { r.email }
end
TableRow(id: detail_id, class: "hidden", role: "region") do
TableCell(colspan: 3, class: "bg-muted/40") do
div(class: "p-4 space-y-1") do
p { "Salary: $#{r.salary}" }
p { "Status: #{r.status}" }
end
end
end
end
end
end
end
Pagination adapters
DataTablePagination accepts a pagination source via one of four keyword forms. Each resolves to an internal adapter exposing current_page, total_pages, total_count, and per_page.
Manual
No gem required. Pass page/per_page/total_count directly.
DataTablePagination(
page: @page,
per_page: @per_page,
total_count: @total_count,
path: employees_path
)
Pagy
If you use Pagy, pass the pagy object directly.
@pagy, @employees = pagy(Employee.all)
DataTablePagination(pagy: @pagy, path: employees_path)
Kaminari
If you use Kaminari, pass the paginated collection.
@employees = Employee.page(params[:page]).per(25)
DataTablePagination(kaminari: @employees, path: employees_path)
Custom adapter
Any object responding to current_page, total_pages, total_count and per_page works via the with: keyword. Useful when wrapping a different gem or custom pagination logic.
class MyAdapter
def initialize(result)
@result = result
end
def current_page = @result.page
def total_pages = @result.total_pages
def total_count = @result.count
def per_page = @result.limit
end
DataTablePagination(with: MyAdapter.new(@result), path: employees_path)