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.

INV001
Paid
Credit Card
$250.00
INV002
Pending
PayPal
$150.00
INV003
Unpaid
Bank Transfer
$350.00
<%= 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

INV001
$250.00
INV002
$150.00

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.

ORD-10421
Ada Lovelace
Gift wrap requested, leave parcel with the concierge if nobody answers.
12 Analytical Row, Marylebone, London W1U 4AA, United Kingdom
Paid
3
$1,240.00
ORD-10422
Grace Hopper
Awaiting bank confirmation.
1 Compiler Court, Arlington, VA 22201
Pending
1
$89.00
ORD-10423
Hedy Lamarr
Customer returned two items; refund issued to the original card.
Frequency Hopping Studios, 88 Spread Spectrum Way, Vienna 1010, Austria
Refunded
7
$2,015.50
ORD-10424
Katherine Johnson
Card declined twice.
Langley Research Center, Hampton, VA 23681
Failed
2
$310.00
ORD-10425
Margaret Hamilton
Split shipment: books ship now, the desk lamp follows next week.
Draper Lab, 555 Technology Square, Cambridge, MA 02139
Paid
12
$4,780.25
ORD-10426
Ada Lovelace
Gift wrap requested, leave parcel with the concierge if nobody answers.
12 Analytical Row, Marylebone, London W1U 4AA, United Kingdom
Paid
3
$1,240.00
ORD-10427
Grace Hopper
Awaiting bank confirmation.
1 Compiler Court, Arlington, VA 22201
Pending
1
$89.00
ORD-10428
Hedy Lamarr
Customer returned two items; refund issued to the original card.
Frequency Hopping Studios, 88 Spread Spectrum Way, Vienna 1010, Austria
Refunded
7
$2,015.50
ORD-10429
Katherine Johnson
Card declined twice.
Langley Research Center, Hampton, VA 23681
Failed
2
$310.00
ORD-10430
Margaret Hamilton
Split shipment: books ship now, the desk lamp follows next week.
Draper Lab, 555 Technology Square, Cambridge, MA 02139
Paid
12
$4,780.25
ORD-10431
Ada Lovelace
Gift wrap requested, leave parcel with the concierge if nobody answers.
12 Analytical Row, Marylebone, London W1U 4AA, United Kingdom
Paid
3
$1,240.00
ORD-10432
Grace Hopper
Awaiting bank confirmation.
1 Compiler Court, Arlington, VA 22201
Pending
1
$89.00
ORD-10433
Hedy Lamarr
Customer returned two items; refund issued to the original card.
Frequency Hopping Studios, 88 Spread Spectrum Way, Vienna 1010, Austria
Refunded
7
$2,015.50
ORD-10434
Katherine Johnson
Card declined twice.
Langley Research Center, Hampton, VA 23681
Failed
2
$310.00
ORD-10435
Margaret Hamilton
Split shipment: books ship now, the desk lamp follows next week.
Draper Lab, 555 Technology Square, Cambridge, MA 02139
Paid
12
$4,780.25
ORD-10436
Ada Lovelace
Gift wrap requested, leave parcel with the concierge if nobody answers.
12 Analytical Row, Marylebone, London W1U 4AA, United Kingdom
Paid
3
$1,240.00
ORD-10437
Grace Hopper
Awaiting bank confirmation.
1 Compiler Court, Arlington, VA 22201
Pending
1
$89.00
ORD-10438
Hedy Lamarr
Customer returned two items; refund issued to the original card.
Frequency Hopping Studios, 88 Spread Spectrum Way, Vienna 1010, Austria
Refunded
7
$2,015.50
ORD-10439
Katherine Johnson
Card declined twice.
Langley Research Center, Hampton, VA 23681
Failed
2
$310.00
ORD-10440
Margaret Hamilton
Split shipment: books ship now, the desk lamp follows next week.
Draper Lab, 555 Technology Square, Cambridge, MA 02139
Paid
12
$4,780.25
Loading…
<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.

Ada Lovelace
Engineer
Grace Hopper
Admiral
<%= 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

MethodRendersNotes
sui.data_griddiv[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 / footerdiv[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.rowdiv[role=row]A subgrid of the grid, or a two-column card. Hover highlight, supports data-state="selected".
g.headdiv[role=columnheader]Column attributes below, when no columns:.
g.celldiv[role=cell]First arg or block for content. colspan: Integer or :all. selectable: false keeps the cell out of the selection.
g.captionpRenders below the wrapper, outside any border, linked to the grid by aria-describedby.

Options

OptionValuesEffect
cards:false (default), true, IntegerRows become two-column cards below 36rem of the grid's own width, or below the given breakpoint in rem.
selectable:false (default), trueAn 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 identifierPut on the grid's wrapper; the grid calls its select, activate, commit, cancel and leave methods when defined.
size::default, :lgRow height and padding in grid layout.

Column attributes

On g.head, or in each hash of columns:.

AttributeValuesEffect
width::auto (default), :fit, Integer, StringThe 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 lengthFloor for a flexible column. The scrollbar appears only once every column is at its floor.
max:a lengthCap for :auto and :fit.
wrap:trueFlexible columns truncate with a tooltip; this wraps instead. Cards always wrap.
align::endRight-aligned heading and cells.
fixed:trueKept in view while the rows scroll sideways: the first column pins at the start, any other at the end.
card::title, :aside, :footer, falsePlacement in the card; the default is a label / value line with the heading as label. false leaves the column out.
order:IntegerPosition in the card.
heading:falseHeading 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.

IntentDetailWhen
selectfrom, to, row, columnThe selected cell changed.
activatecell, via, keyEnter, F2, double-click or a typed key on the selected cell; via says which.
commitcellEnter while editing.
cancelcellEscape while editing.
leavecell, toFocus left an editing cell for to. Not raised when the app removes the editor itself.