Table
A responsive, accessible table. Wraps native <table> semantics with shadcn styling.
Installation
bin/rails g shadcnrb:component table
Usage
Compose sui.table do |t| with t.header, t.body,
t.row, t.head, t.cell.
Wrap in a card for the bordered look, or leave plain.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
<%= sui.table class: "rounded-lg border" do |t| %> <%= t.header do %> <%= t.row do %> <%= t.head "Invoice" %> <%= t.head "Status" %> <%= t.head "Method" %> <%= t.head "Amount", class: "text-right" %> <% end %> <% end %> <%= t.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| %> <%= t.row do %> <%= t.cell inv[:id], class: "font-medium" %> <%= t.cell do %> <%= sui.badge inv[:status], variant: (inv[:status] == "Paid" ? :default : :secondary) %> <% end %> <%= t.cell inv[:method] %> <%= t.cell inv[:amount], class: "text-right" %> <% end %> <% end %> <% end %> <% end %>
With caption and footer
| Invoice | Amount |
|---|---|
| INV001 | $250.00 |
| INV002 | $150.00 |
| Total | $400.00 |
A list of your recent invoices.
<%= sui.table class: "rounded-lg border" do |t| %> <%= t.caption "A list of your recent invoices.", class: "text-center" %> <%= t.header do %> <%= t.row do %> <%= t.head "Invoice" %> <%= t.head "Amount", class: "text-right" %> <% end %> <% end %> <%= t.body do %> <%= t.row { t.cell("INV001") + t.cell("$250.00", class: "text-right") } %> <%= t.row { t.cell("INV002") + t.cell("$150.00", class: "text-right") } %> <% end %> <%= t.footer do %> <%= t.row do %> <%= t.cell "Total" %> <%= t.cell "$400.00", class: "text-right" %> <% end %> <% end %> <% end %>
Lazy-loaded rows
Rows that arrive after the page: the body starts with a single
colspan placeholder row, and a Turbo Frame beside the
table fetches a stream that replaces the tbody. A
turbo-frame can't sit inside a table, which is why the
frame stays outside and carries a turbo-stream. The
fixed header keeps its natural layout while the placeholder shows —
widths are only measured against a row with one cell per heading — and
realigns when the rows land. The partial reaches
t.body and friends through sui.table_proxy.
<%= sui.table fixed_header: true, class: "h-64 rounded-lg border" do |t| %> <%= t.header do %> <%= t.row do %> <%= t.head "Invoice" %> <%= t.head "Status" %> <%= t.head "Method" %> <%= t.head "Amount", class: "text-right" %> <% end %> <% end %> <%= t.body id: "lazy-invoices" do %> <%= t.row do %> <%= t.cell "Loading…", colspan: 4, class: "text-center text-muted-foreground animate-pulse" %> <% end %> <% end %> <% end %> <turbo-frame id="lazy-invoices-frame" src="<%= lazy_table_rows_path %>"></turbo-frame>
Fixed header
fixed_header: true keeps the header in place while the rows scroll inside
a bounded box. The header and body render as two tables — the header outside the scroll
container — and a small Stimulus controller keeps their column widths and horizontal
scroll in step, so the scrollbar sits beside the rows only. Give the box a height.
<%= sui.table fixed_header: true, class: "h-64 rounded-lg border" do |t| %> <%= t.header do %> <%= t.row do %> <%= t.head "Invoice" %> <%= t.head "Status" %> <%= t.head "Method" %> <%= t.head "Amount", class: "text-right" %> <% end %> <% end %> <%= t.body do %> <% 1.upto(20) do |i| %> <%= t.row do %> <%= t.cell "INV#{i.to_s.rjust(3, '0')}", class: "font-medium" %> <%= t.cell i.odd? ? "Paid" : "Pending" %> <%= t.cell i % 3 == 0 ? "Bank Transfer" : "Credit Card" %> <%= t.cell "$#{i * 50}.00", class: "text-right" %> <% end %> <% end %> <% end %> <% 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 t.head and
t.cell stay size-agnostic.
| Name | Role |
|---|---|
| Ada Lovelace | Engineer |
| Grace Hopper | Admiral |
<%= sui.table size: :lg, class: "rounded-lg border" do |t| %> <%= t.header do %> <%= t.row do %> <%= t.head "Name" %> <%= t.head "Role" %> <% end %> <% end %> <%= t.body do %> <%= t.row { t.cell("Ada Lovelace", class: "font-medium") + t.cell("Engineer") } %> <%= t.row { t.cell("Grace Hopper", class: "font-medium") + t.cell("Admiral") } %> <% end %> <% end %>
API
| Method | Renders | Notes |
|---|---|---|
sui.table | <table> | Wrapped in an overflow-x-auto div that class: lands on: give it a border, or a height for a fixed header; yields a t proxy. fixed_header: true splits header and body into two tables kept in step by shadcnrb--table--component; size: is :default or :lg. |
t.header / body / footer | <thead> / <tbody> / <tfoot> | Footer gets a top border + muted bg. The header part (thead, or the header container in fixed mode) is data-slot="table-header" and draws the header rule; the body part is table-body. |
t.row | <tr> | Hover highlight, supports data-state="selected". |
t.head | <th> | Muted foreground, left-aligned by default. |
t.cell | <td> | First arg or block for content. |
t.caption | <p> | Renders below the wrapper, outside any border, linked to the table by aria-describedby. |