Data Table

Server-first sortable, filterable tables built from Table and Pagination.

No JavaScript Required Turbo Friendly

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.

Reset
11 invoices found
Customer Email Status Amount
Lucas Miller lucas@example.com Processing $12.00

Page 3 of 3

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 %>
app/javascript/controllers/docs_live_filter_controller.js
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
  }
}
Reset
3 invoices found
Customer Email Status Amount
Olivia Martin olivia@example.com Paid $19.99
Jackson Lee jackson@example.com Paid $39.00
Sofia Davis sofia@example.com Paid $39.00
data-table/default
<%= 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-sort as ascending, descending, or none.
  • 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.