Data Table
Server-first sortable, filterable tables built from Table and Pagination.
Live Server Demo
This table is backed by GET params:
search, the native status select, sort header links, and pagination all round-trip through the controller.
A dummy-only host Stimulus controller progressively enhances the toolbar so search and status changes refresh
the results frame while the focused controls stay mounted; Apply and Reset remain the no-JavaScript path.
Installation
Add the Data Table component and its Table and Empty dependencies to your project:
rails generate shadcn:add data_table
Usage
Data Table is a Rails translation of the upstream shadcn/ui data-table recipe. It renders table structure, sortable header links, toolbar and footer slots, while your controller owns filtering, safe-listed sorting, and pagination from URL params.
<%= render Shadcn::DataTableComponent.new(
rows: @invoices,
sort: params[:sort],
dir: params[:dir],
params: request.query_parameters,
path: invoices_path,
caption: "#{pluralize(@total_count, 'invoice')} found"
) do |table| %>
<% table.with_toolbar do %>
<%= form_with url: invoices_path, method: :get, data: { turbo_frame: "invoices" }, class: "flex gap-2" do %>
<%= hidden_field_tag :sort, params[:sort] if params[:sort].present? %>
<%= hidden_field_tag :dir, params[:dir] if params[:dir].present? %>
<%= render Shadcn::InputComponent.new(name: "q", value: params[:q], placeholder: "Search invoices...") %>
<%= render Shadcn::NativeSelectComponent.new(name: "status", onchange: "this.form.requestSubmit()") do |select| %>
<% select.with_option(value: "", selected: params[:status].blank?) { "All statuses" } %>
<% %w[Paid Processing Pending Failed].each do |status| %>
<% select.with_option(value: status, selected: params[:status] == status) { status } %>
<% end %>
<% end %>
<%= render Shadcn::ButtonComponent.new(type: "submit") { "Apply" } %>
<%= render Shadcn::ButtonComponent.new(href: invoices_path, variant: :outline) { "Reset" } %>
<% end %>
<% end %>
<% table.with_column(:customer, sortable: true) %>
<% table.with_column(:email, sortable: true) %>
<% table.with_column(:status, sortable: true) %>
<% table.with_column(:amount, sortable: true, align: :end) do |invoice| %>
<%= number_to_currency(invoice.amount / 100.0) %>
<% end %>
<% table.with_footer do %>
<%= render Shadcn::PaginationComponent.new(
pagy: @pagy,
url_builder: ->(page) { invoices_path(**request.query_parameters.merge("page" => page).symbolize_keys) }
) %>
<% end %>
<% end %>
Live Refresh Example
Data Table does not ship a Stimulus controller. If your host app wants live filtering, attach your own controller to the GET form and append actions to leaf controls such as Input and NativeSelect.
<%= form_with url: invoices_path, method: :get, data: { controller: "docs--live-filter", turbo_frame: "invoices" }, class: "flex gap-2" do %>
<%= hidden_field_tag :sort, params[:sort] if params[:sort].present? %>
<%= hidden_field_tag :dir, params[:dir] if params[:dir].present? %>
<%= render Shadcn::InputComponent.new(
name: "q",
value: params[:q],
placeholder: "Search invoices...",
data: { action: "input->docs--live-filter#submitLater" }
) %>
<%= render Shadcn::NativeSelectComponent.new(
name: "status",
data: { action: "change->docs--live-filter#submitNow" }
) do |select| %>
<% select.with_option(value: "", selected: params[:status].blank?) { "All statuses" } %>
<% %w[Paid Processing Pending Failed].each do |status| %>
<% select.with_option(value: status, selected: params[:status] == status) { status } %>
<% end %>
<% end %>
<%= render Shadcn::ButtonComponent.new(type: "submit") { "Apply" } %>
<%= render Shadcn::ButtonComponent.new(href: invoices_path, variant: :outline) { "Reset" } %>
<% end %>
<%= turbo_frame_tag "invoices" do %>
<%= render Shadcn::DataTableComponent.new(
rows: @invoices,
sort: params[:sort],
dir: params[:dir],
params: request.query_parameters,
path: invoices_path,
caption: "#{pluralize(@total_count, 'invoice')} found"
) do |table| %>
<% table.with_column(:customer, sortable: true) %>
<% table.with_column(:email, sortable: true) %>
<% table.with_column(:status, sortable: true) %>
<% table.with_column(:amount, sortable: true, align: :end) do |invoice| %>
<%= number_to_currency(invoice.amount / 100.0) %>
<% end %>
<% end %>
<% end %>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
delay: { type: Number, default: 275 }
}
connect() {
this.timeout = null
}
disconnect() {
this.clearPendingSubmit()
}
submitNow() {
this.clearPendingSubmit()
this.submitForm()
}
submitLater() {
this.clearPendingSubmit()
this.timeout = window.setTimeout(() => {
this.submitForm()
}, this.delayValue)
}
submitForm() {
if (this.element instanceof HTMLFormElement) {
this.element.requestSubmit()
}
}
clearPendingSubmit() {
if (!this.timeout) return
window.clearTimeout(this.timeout)
this.timeout = null
}
}
<%= render Shadcn::DataTableComponent.new(
rows: @invoices,
sort: params[:sort],
dir: params[:dir],
params: request.query_parameters,
path: invoices_path,
caption: "#{pluralize(@total_count, 'invoice')} found"
) do |table| %>
<% table.with_toolbar do %>
<%= form_with url: invoices_path, method: :get, data: { turbo_frame: "invoices" }, class: "flex gap-2" do %>
<%= hidden_field_tag :sort, params[:sort] if params[:sort].present? %>
<%= hidden_field_tag :dir, params[:dir] if params[:dir].present? %>
<%= render Shadcn::InputComponent.new(name: "q", value: params[:q], placeholder: "Search invoices...") %>
<%= render Shadcn::NativeSelectComponent.new(name: "status", onchange: "this.form.requestSubmit()") do |select| %>
<% select.with_option(value: "", selected: params[:status].blank?) { "All statuses" } %>
<% %w[Paid Processing Pending Failed].each do |status| %>
<% select.with_option(value: status, selected: params[:status] == status) { status } %>
<% end %>
<% end %>
<%= render Shadcn::ButtonComponent.new(type: "submit") { "Apply" } %>
<%= render Shadcn::ButtonComponent.new(href: invoices_path, variant: :outline) { "Reset" } %>
<% end %>
<% end %>
<% table.with_column(:customer, sortable: true) %>
<% table.with_column(:email, sortable: true) %>
<% table.with_column(:status, sortable: true) %>
<% table.with_column(:amount, sortable: true, align: :end) do |invoice| %>
<%= number_to_currency(invoice.amount / 100.0) %>
<% end %>
<% table.with_footer do %>
<%= render Shadcn::PaginationComponent.new(
pagy: @pagy,
url_builder: ->(page) { invoices_path(**request.query_parameters.merge("page" => page).symbolize_keys) }
) %>
<% end %>
<% end %>
Controller Recipe
Keep sorting server-side and safe-list the columns your controller may order by. The component only renders URLs and ARIA state.
SORTS = {
"customer" => "invoices.customer",
"email" => "invoices.email",
"status" => "invoices.status",
"amount" => "invoices.amount"
}.freeze
def index
scope = Invoice.all
if params[:q].present?
query = "%#{params[:q]}%"
scope = scope.where("customer ILIKE :query OR email ILIKE :query", query: query)
end
scope = scope.where(status: params[:status]) if params[:status].present?
if SORTS.key?(params[:sort]) && %w[asc desc].include?(params[:dir])
scope = scope.order(SORTS.fetch(params[:sort]) => params[:dir])
end
@total_count = scope.count
@pagy, @invoices = pagy(scope)
end
API Reference
DataTableComponent
| Prop | Type | Default | Description |
|---|---|---|---|
| rows |
Enumerable
|
required
|
Rows rendered by the table. Filtering, sorting, and pagination should already be applied. |
| sort |
String
|
nil
|
Current sort key from params. |
| dir |
String
|
nil
|
Current direction, asc or desc. |
| params |
Hash
|
request.query_parameters
|
Query params preserved by sort header links. |
| path |
String
|
request.path
|
Base path for generated sort URLs. |
| caption |
String
|
nil
|
Optional table caption. |
Column DSL
| Prop | Type | Default | Description |
|---|---|---|---|
| with_column(key) |
Symbol
|
required
|
Defines a column. Without a block, values come from row[key] or row.public_send(key). |
| label |
String
|
key.humanize
|
Header label. |
| sortable |
Boolean
|
false
|
Renders the header as a sort link with aria-sort. |
| sort_key |
String
|
key
|
Param value used for sorting when it differs from the display key. |
| align |
Symbol
|
:start
|
Cell and header alignment: :start, :center, or :end. |
Accessibility
- Sortable headers are real links, so browser navigation and assistive technologies work without custom JavaScript.
- Sortable header cells expose
aria-sortasascending,descending, ornone. - The toolbar uses standard Rails form controls and submits with GET params.
- The footer slot composes PaginationComponent, preserving keyboard and screen-reader behavior from pagination links.