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.

Complete demo

Server-driven

Turbo Frame GET on each sort/search/page. No client-only state.

Server-driven

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.

Selection + bulk actions

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.

AttributeRequiredPurpose
type: "submit"yesnative submit button
form: FORM_IDyes (button is outside DataTableForm)HTML5 form-association — lets the button submit a form located elsewhere in the DOM
formaction: "/path"yestarget URL, overrides the form's action
formmethod: "post"yesHTTP verb, overrides the form's method
formnovalidate: trueoptionalskip HTML5 validation
data: {turbo_confirm: "Are you sure?"}optionalRails/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.

Column visibility

Custom cell renderers

Plain Ruby helpers for badge/date/currency — the gem does not ship renderers.

Custom cell renderers

Expandable rows

Toggle a detail region below each row. Accessible: aria-expanded, aria-controls, keyboard-focusable button, region role on the expanded content.

Expandable rows

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)