Data Grid
A CSS grid with table semantics: columns that size like flex items, optional cards when narrow, a fixed header, pinned columns, cell selection and rows that stream in. For a plain semantic table, see Table.
Installation
bin/rails g shadcnrb:component data_grid
Usage
Compose sui.data_grid do |g| with g.header, g.body,
g.row, g.head, g.cell.
Wrap in a card for the bordered look, or leave plain.
<%= sui.data_grid class: "rounded-lg border" do |g| %> <%= g.header do %> <%= g.row do %> <%= g.head "Invoice" %> <%= g.head "Status" %> <%= g.head "Method" %> <%= g.head "Amount", align: :end %> <% end %> <% end %> <%= g.body do %> <% [ {id: "INV001", status: "Paid", method: "Credit Card", amount: "$250.00"}, {id: "INV002", status: "Pending", method: "PayPal", amount: "$150.00"}, {id: "INV003", status: "Unpaid", method: "Bank Transfer", amount: "$350.00"} ].each do |inv| %> <%= g.row do %> <%= g.cell inv[:id], class: "font-medium" %> <%= g.cell do %> <%= sui.badge inv[:status], variant: (inv[:status] == "Paid" ? :default : :secondary) %> <% end %> <%= g.cell inv[:method] %> <%= g.cell inv[:amount] %> <% end %> <% end %> <% end %> <% end %>
With caption and footer
A list of your recent invoices.
<%= sui.data_grid class: "rounded-lg border" do |g| %> <%= g.caption "A list of your recent invoices.", class: "text-center" %> <%= g.header do %> <%= g.row do %> <%= g.head "Invoice" %> <%= g.head "Amount", align: :end %> <% end %> <% end %> <%= g.body do %> <%= g.row { g.cell("INV001") + g.cell("$250.00") } %> <%= g.row { g.cell("INV002") + g.cell("$150.00") } %> <% end %> <%= g.footer do %> <%= g.row do %> <%= g.cell "Total" %> <%= g.cell "$400.00" %> <% end %> <% end %> <% end %>
Responsive, fixed header, lazy rows
Every feature at once. Each g.head sizes and places its column, and the
options turn on cards, the fixed header, selection and editing; all of them are listed
under API below. Drag the corner of the dashed box to see the cards. Press Enter on a
cell, or just type, then Enter or Escape. Scroll the body for more rows.
<div class="@container min-w-80 max-w-full resize-x overflow-auto rounded-lg border border-dashed p-3"> <%= sui.data_grid cards: true, selectable: true, editor: "cell-editor", class: "h-80 @xl:rounded-lg @xl:border" do |g| %> <%= g.header fixed: true do %> <%= g.row do %> <%= g.head "Order", width: :fit, card: :title, fixed: true %> <%= g.head "Customer" %> <%= g.head "Notes", width: 2, min: "12rem" %> <%= g.head "Ship to", width: 1, min: "10rem" %> <%= g.head "Status", width: :fit, card: :aside %> <%= g.head "Items", width: :fit, align: :end %> <%= g.head "Total", width: :fit, align: :end %> <%= g.head "Placed", width: :fit, card: false %> <%= g.head "Actions", width: :fit, card: :footer, heading: false, fixed: true %> <% end %> <% end %> <%= g.body id: "orders-body" do %> <%= render "demos/order_rows", g: g, orders: demo_orders(1) %> <%= render "demos/orders_loader", page: 2 %> <% end %> <% end %> </div> </div>
The reference editor, the row partial both paths share, the loader, the stream, the action and route, and the demo data:
app/javascript/controllers/cell_editor_controller.js
import { Controller } from "@hotwired/stimulus" // Reference inline editor for the data grid docs. Named on the grid as // `editor: "cell-editor"`, so the grid calls these by name; an app would // load its editor from the server instead. Swaps the cell for an input. export default class extends Controller { activate({ cell, via, key }) { if (cell.dataset.selectable === "false" || cell.querySelector("input")) return cell.dataset.original = cell.innerHTML const input = document.createElement("input") input.className = "w-full bg-transparent outline-none" input.value = via === "key" ? (key.length === 1 ? key : "") : cell.textContent.trim() cell.replaceChildren(input) input.focus() if (via !== "key") input.select() } commit({ cell }) { this.finish(cell, cell.querySelector("input")?.value) } cancel({ cell }) { this.finish(cell) } // Focus left mid-edit: this demo keeps the value; an app may discard. leave({ cell }) { this.finish(cell, cell.querySelector("input")?.value, { refocus: false }) } // The original is taken before the swap: removing the input drops focus, // and the grid's reaction must find nothing left to finish. finish(cell, value = null, { refocus = true } = {}) { if (!("original" in cell.dataset)) return const original = cell.dataset.original delete cell.dataset.original cell.innerHTML = original if (value !== null) cell.textContent = value this.grid.stamp(cell.parentElement) if (refocus) this.grid.focusCell(cell) } get grid() { return this.application.getControllerForElementAndIdentifier(this.element, "shadcnrb--data-grid--component") } }
app/views/demos/_order_rows.html.erb
<!-- Rows for the orders table: the page renders the first page inside the table, later pages arrive as a stream appended to its body. --> <% g ||= sui.data_grid_proxy %> <% orders.each do |order| %> <%= g.row do %> <%= g.cell order[:order] %> <%= g.cell order[:customer] %> <%= g.cell order[:notes] %> <%= g.cell order[:ship_to] %> <%= g.cell { sui.badge order[:status], variant: demo_badge_variant(order[:status]) } %> <%= g.cell order[:items] %> <%= g.cell order[:total] %> <%= g.cell order[:placed] %> <%= g.cell selectable: false do %> <div class="flex justify-end gap-1"> <%= sui.button variant: :ghost, size: :"icon-sm", icon: :pencil, "aria-label": "Edit #{order[:order]}" %> <%= sui.button variant: :ghost, size: :"icon-sm", icon: :"trash-2", "aria-label": "Delete #{order[:order]}" %> <%= sui.button variant: :ghost, size: :"icon-sm", icon: :ellipsis, "aria-label": "More for #{order[:order]}" %> </div> <% end %> <% end %> <% end %>
app/views/demos/more_orders.turbo_stream.erb
<%= turbo_stream.replace "orders-loader" do %> <%= render "demos/order_rows", orders: demo_orders(@page) %> <%= render "demos/orders_loader", page: @page + 1 if @page < TableDemoHelper::PAGES %> <% end %>
app/views/demos/_orders_loader.html.erb
<!-- Last child of the body: a lazy frame that fetches the next page once it scrolls into view. The stream replaces it with the page's rows and, if there is more, a fresh loader. --> <%= turbo_frame_tag "orders-loader", src: more_orders_path(page: page, format: :turbo_stream), loading: :lazy, class: "block p-4 text-center text-sm text-muted-foreground animate-pulse" do %>Loading…<% end %>
app/controllers/demos_controller.rb
# Replaces the orders grid's loader row with the next page and a fresh loader. def more_orders sleep 1 # simulate server work @page = params[:page].to_i end
config/routes.rb
get "demos/more_orders" => "demos#more_orders", as: :more_orders
app/helpers/table_demo_helper.rb
# Data for the data grid demo's orders, in pages the loader row fetches. module TableDemoHelper PAGES = 3 PER_PAGE = 20 BADGE_VARIANT = { "Paid" => :default, "Pending" => :secondary, "Refunded" => :outline, "Failed" => :destructive }.freeze ORDER_SEEDS = [ { customer: "Ada Lovelace", notes: "Gift wrap requested, leave parcel with the concierge if nobody answers.", ship_to: "12 Analytical Row, Marylebone, London W1U 4AA, United Kingdom", status: "Paid", items: 3, total: "$1,240.00" }, { customer: "Grace Hopper", notes: "Awaiting bank confirmation.", ship_to: "1 Compiler Court, Arlington, VA 22201", status: "Pending", items: 1, total: "$89.00" }, { customer: "Hedy Lamarr", notes: "Customer returned two items; refund issued to the original card.", ship_to: "Frequency Hopping Studios, 88 Spread Spectrum Way, Vienna 1010, Austria", status: "Refunded", items: 7, total: "$2,015.50" }, { customer: "Katherine Johnson", notes: "Card declined twice.", ship_to: "Langley Research Center, Hampton, VA 23681", status: "Failed", items: 2, total: "$310.00" }, { customer: "Margaret Hamilton", notes: "Split shipment: books ship now, the desk lamp follows next week.", ship_to: "Draper Lab, 555 Technology Square, Cambridge, MA 02139", status: "Paid", items: 12, total: "$4,780.25" } ].freeze def demo_orders(page) PER_PAGE.times.map do |n| i = (page - 1) * PER_PAGE + n ORDER_SEEDS[i % ORDER_SEEDS.size].merge(order: "ORD-#{10421 + i}", placed: (Date.new(2026, 9, 12) + i).iso8601) end end def demo_badge_variant(status) = BADGE_VARIANT[status] end
Sizes
size: :lg relaxes row height and padding for index-style lists; data
grids keep the dense default. The size is set as data-size on the table
and applied through descendant selectors, so g.head and
g.cell stay size-agnostic.
<%= sui.data_grid size: :lg, class: "rounded-lg border" do |g| %> <%= g.header do %> <%= g.row do %> <%= g.head "Name" %> <%= g.head "Role" %> <% end %> <% end %> <%= g.body do %> <%= g.row { g.cell("Ada Lovelace", class: "font-medium") + g.cell("Engineer") } %> <%= g.row { g.cell("Grace Hopper", class: "font-medium") + g.cell("Admiral") } %> <% end %> <% end %>
API
| Method | Renders | Notes |
|---|---|---|
sui.data_grid | div[role=table] | role=grid when selectable, inside a wrapper that class: lands on: give it a border, or a height for a fixed header. Options below. columns:, an array of column attribute hashes or labels, renders the header itself. |
sui.data_grid_proxy | — | Rows rendered outside a grid block, e.g. a Turbo stream appending to the body. No arguments: cells are stamped from their heading when the row lands. |
g.header / body / footer | div[role=rowgroup] | g.header fixed: true makes the body the scroll box; with columns: it needs no block. A non-row child of the body, a loader frame say, spans every column. Header and footer are hidden in card mode. |
g.row | div[role=row] | A subgrid of the grid, or a two-column card. Hover highlight, supports data-state="selected". |
g.head | div[role=columnheader] | Column attributes below, when no columns:. |
g.cell | div[role=cell] | First arg or block for content. colspan: Integer or :all. selectable: false keeps the cell out of the selection. |
g.caption | p | Renders below the wrapper, outside any border, linked to the grid by aria-describedby. |
Options
| Option | Values | Effect |
|---|---|---|
cards: | false (default), true, Integer | Rows become two-column cards below 36rem of the grid's own width, or below the given breakpoint in rem. |
selectable: | false (default), true | An ARIA grid with one selected cell: click or arrow keys, Home / End within the row, Ctrl+Home / End to the corners. Kept in view clear of pinned columns. |
editor: | a Stimulus identifier | Put on the grid's wrapper; the grid calls its select, activate, commit, cancel and leave methods when defined. |
size: | :default, :lg | Row height and padding in grid layout. |
Column attributes
On g.head, or in each hash of columns:.
| Attribute | Values | Effect |
|---|---|---|
width: | :auto (default), :fit, Integer, String | The column's track: auto takes its content width and wraps; :fit is max-content; an Integer n is minmax(min, nfr), sharing the slack like a flex item; a String is a fixed length. |
min: | a length | Floor for a flexible column. The scrollbar appears only once every column is at its floor. |
max: | a length | Cap for :auto and :fit. |
wrap: | true | Flexible columns truncate with a tooltip; this wraps instead. Cards always wrap. |
align: | :end | Right-aligned heading and cells. |
fixed: | true | Kept in view while the rows scroll sideways: the first column pins at the start, any other at the end. |
card: | :title, :aside, :footer, false | Placement in the card; the default is a label / value line with the heading as label. false leaves the column out. |
order: | Integer | Position in the card. |
heading: | false | Heading kept for screen readers but not shown. |
Editing
Editing belongs to the app. The grid reports intent, as methods on the editor:
controller and as cancelable data-grid: events with the same
detail. A cell is editing while focus sits inside it rather than on it, or
while data-mode="editing" is set on it; the arrow keys then belong to the
editor, the tooltip is off and the cell stops truncating.
| Intent | Detail | When |
|---|---|---|
select | from, to, row, column | The selected cell changed. |
activate | cell, via, key | Enter, F2, double-click or a typed key on the selected cell; via says which. |
commit | cell | Enter while editing. |
cancel | cell | Escape while editing. |
leave | cell, to | Focus left an editing cell for to. Not raised when the app removes the editor itself. |