Table
Full-featured data table with sorting, row selection, pagination, and responsive layouts.
Key Features
- Collection Mode - Pass a collection, define columns declaratively — no manual loop
- Column Formatters - 10 built-in formatters (date, currency, boolean, badge, etc.)
- Data-Driven Rows - Auto-generate cells from data objects
- Toolbar Slot - Search, filters, and actions above the table
- Sorting - Client-side or server-side column sorting
- Row Selection - Checkbox selection with select-all
- Bulk Actions - Action bar for selected rows
- 5 Sizes - xs, sm, base, lg, xl
- 2 Shapes - Rounded or square corners
- Visual Variants - Striped rows, striped columns, bordered, hoverable
- Visible Caption - Title and description header
- Sticky Header - Fixed header when scrolling
- 3 Responsive Modes - Scroll, cards, stack
- Pagination - Built-in pagination with live example
- Empty State - Custom empty content slot
- Loading State - Skeleton loading animation
- Footer - Summary row support
- Turbo Integration - Turbo Frame wrapper
- Full Accessibility - ARIA grid role, keyboard navigation
- JavaScript API - Programmatic control with
data-actionattributes
Collection Mode (Recommended)
Pass a collection as the first argument. The table iterates internally — no manual loop needed. Use table.column(:key) to define columns, and optional blocks for custom cell content.
Basic
| Name | Role | |
|---|---|---|
| Alice Johnson | alice@example.com | Admin |
| Bob Smith | bob@example.com | Editor |
| Carol Davis | carol@example.com | Viewer |
<%= rui_table(@users) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) %>
<% end %>
Custom Cell Content
Use a block to customize how a column renders. The block receives the record.
| Name | Role | |
|---|---|---|
| Alice Johnson | alice@example.com | Admin |
| Bob Smith | bob@example.com | Editor |
| Carol Davis | carol@example.com | Viewer |
<%= rui_table(@users, striped: true) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) { |user| rui_badge(text: user.role, color: :primary) } %>
<% end %>
With Formatters
| Name | Balance | Created At |
|---|---|---|
| Alice Johnson | $2,450.00 | Mar 15, 2025 |
| Bob Smith | $1,280.50 | Jun 22, 2025 |
| Carol Davis | $890.75 | Sep 10, 2025 |
<%= rui_table(@users) do |table| %>
<% table.column(:name) %>
<% table.column(:balance, formatter: :currency) %>
<% table.column(:created_at, formatter: :date) %>
<% end %>
With Selection & Sorting
| Name | Balance | ||
|---|---|---|---|
| Alice Johnson | alice@example.com | $2,450.00 | |
| Bob Smith | bob@example.com | $1,280.50 | |
| Carol Davis | carol@example.com | $890.75 |
<%= rui_table(@users, selectable: true, sortable: true) do |table| %>
<% table.column(:name, sortable: true) %>
<% table.column(:email) %>
<% table.column(:balance, formatter: :currency, sortable: true) %>
<% end %>
Slot Mode (Manual Iteration)
For full control, use the slot-based API with with_column, with_row, and with_cell.
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | Admin |
| Jane Smith | jane@example.com | Editor |
| Bob Johnson | bob@example.com | Viewer |
<%= rui_table do |table| %>
<% table.with_column(key: :name, label: "Name") %>
<% table.with_column(key: :email, label: "Email") %>
<% table.with_column(key: :role, label: "Role") %>
<% table.with_row(id: 1) do |row| %>
<% row.with_cell { "John Doe" } %>
<% row.with_cell { "john@example.com" } %>
<% row.with_cell { "Admin" } %>
<% end %>
<% table.with_row(id: 2) do |row| %>
<% row.with_cell { "Jane Smith" } %>
<% row.with_cell { "jane@example.com" } %>
<% row.with_cell { "Editor" } %>
<% end %>
<% end %>
Visible Caption
Add a title and description above the table for context. Unlike the accessibility caption slot, these are visible to all users.
Team Members
A list of all team members including their name, title, and email address.
| Name | Title | |
|---|---|---|
| John Doe | Software Engineer | john@example.com |
| Jane Smith | Product Manager | jane@example.com |
| Bob Wilson | Designer | bob@example.com |
<%= rui_table(@users,
title: "Team Members",
description: "A list of all team members including their name, title, and email.",
bordered: true,
shape: :rounded
) do |table| %>
<% table.column(:name) %>
<% table.column(:title) %>
<% table.column(:email) %>
<% end %>
# Title only
<%= rui_table(@users, title: "Recent Orders") do |table| %>
<% table.column(:name) %>
<% end %>
# Description only
<%= rui_table(@users, description: "Showing orders from the last 30 days") do |table| %>
<% table.column(:name) %>
<% end %>
Columns
Configure columns with labels, alignment, width, and sortable options.
| ID | Product | Price | In Stock |
|---|---|---|---|
| 001 | Wireless Keyboard | $49.99 | 125 |
| 002 | USB-C Hub | $79.99 | 42 |
# Collection mode — table.column(:key, ...)
<%= rui_table(@products) do |table| %>
<% table.column(:id, label: "ID", width: "60px", align: :center) %>
<% table.column(:product) %>
<% table.column(:price, align: :right, width: "100px", formatter: :currency) %>
<% table.column(:stock, label: "In Stock", align: :center) %>
<% end %>
# Slot mode — table.with_column(key:, label:, ...)
<% table.with_column(key: :id, label: "ID", width: "60px", align: :center) %>
<% table.with_column(key: :product, label: "Product") %>
<% table.with_column(key: :price, label: "Price", align: :right, width: "100px") %>
<% table.with_column(key: :stock, label: "In Stock", align: :center) %>
# Column options (same for both modes):
# - key: Symbol - Column identifier (used for sorting and data extraction)
# - label: String - Header text (auto-generated from key if not provided)
# - sortable: Boolean - Enable sorting on this column
# - align: Symbol - :left, :center, :right
# - width: String - CSS width value (e.g., "100px", "20%")
# - formatter: Symbol/Hash - Auto-format values (:currency, :date, :boolean, etc.)
Rows & Cells
Add rows with optional ID, data object, and color highlighting. Cells can contain any content.
| Status | Order | Customer | Total |
|---|---|---|---|
| Completed | #ORD-001 | Alice Brown | $299.00 |
| Pending | #ORD-002 | Charlie Davis | $149.50 |
| Cancelled | #ORD-003 | David Wilson | $89.00 |
# Collection mode — use row_color: for conditional colors
<%= rui_table(@orders,
row_color: ->(order) {
case order.status
when "completed" then :success
when "pending" then :warning
when "cancelled" then :danger
end
}
) do |table| %>
<% table.column(:status) { |o| rui_badge(o.status, color: :zinc) } %>
<% table.column(:order) %>
<% table.column(:customer) %>
<% table.column(:total, formatter: :currency) %>
<% end %>
# Slot mode — use color: per row
<% table.with_row(id: 1, color: :success) do |row| %>
<% row.with_cell { rui_badge("Completed", color: :success) } %>
<% row.with_cell { "#ORD-001" } %>
<% end %>
<% table.with_row(id: 2, color: :warning) do |row| %>
<% row.with_cell { rui_badge("Pending", color: :warning) } %>
<% row.with_cell { "#ORD-002" } %>
<% end %>
# Row color options: :success, :warning, :danger
Column Formatters
Apply built-in formatters to columns for automatic data formatting. Supports dates, currency, percentages, booleans, and more.
Subscription Plans
Demonstrating all built-in formatters
| Plan Name | Price | Discount | Status | Created | Description |
|---|---|---|---|---|---|
| Premium Plan | $99.99 | 0.2% | 3 days ago | Unlimited access to all features... | |
| Basic Plan | $29.99 | 0.0% | 7 days ago | Essential features for small teams... | |
| Legacy Plan | $49.99 | 0.2% | 2 months ago | Deprecated plan no longer available... |
Available Formatters
:date
- Format as date (Jan 15, 2024)
:datetime
- Format as date and time
:time
- Format as time only (2:30 PM)
:relative_time
- Time ago (3 days ago)
:currency
- Format as currency ($1,234.56)
:number
- Format with separators (1,234,567)
:percentage
- Format as percentage (75.5%)
:boolean
- Format as Yes/No or custom text
:truncate
- Truncate with ellipsis
:badge
- Render as colored badge
# Collection mode — formatters work the same way
<%= rui_table(@plans) do |table| %>
<% table.column(:name) %>
<% table.column(:price, formatter: :currency) %>
<% table.column(:created_at, formatter: :date) %>
<% table.column(:active, formatter: :boolean) %>
<% table.column(:discount, formatter: { type: :percentage, precision: 1 }) %>
<% table.column(:description, formatter: { type: :truncate, length: 50 }) %>
<% end %>
# Formatter with options (hash)
<% table.column(:status, formatter: { type: :boolean, true_text: "Active", false_text: "Inactive" }) %>
# Badge formatter with color mapping
<% table.column(:status, formatter: {
type: :badge,
colors: { active: :success, pending: :warning, cancelled: :danger }
}) %>
# Custom formatter with Proc
<% table.column(:email, formatter: ->(value) { mail_to(value, value, class: "text-blue-600") }) %>
Date & Time Formatters
| Event | Date (:date) | Relative (:relative_time) | Time (:time) |
|---|---|---|---|
| Meeting | May 10, 2026 | 2 days ago | 02:30 PM |
| Deadline | May 19, 2026 | 7 days ago | 05:00 PM |
| Review | May 12, 2026 | less than a minute ago | 09:15 AM |
Currency & Number Formatters
| Item | Amount | Growth | Units |
|---|---|---|---|
| Revenue | $1,234,567.89 | 0.2% | 45,678 |
| Expenses | $876,543.21 | -0.0% | 12,345 |
| Profit | $358,024.68 | 0.3% | 33,333 |
Badge Formatter
| Order ID | Customer | Status |
|---|---|---|
| #ORD-001 | Alice Brown | completed |
| #ORD-002 | Bob Wilson | pending |
| #ORD-003 | Carol Davis | cancelled |
| #ORD-004 | Dave Smith | processing |
Data-Driven Rows
Pass a data: object to rows for automatic cell generation. Column keys are used to extract values from the object.
Combined with formatters, this creates a fully declarative table definition.
Users
Collection mode with automatic cell generation
| Name | Balance | Verified | Joined | |
|---|---|---|---|---|
| Alice Johnson | alice@example.com | $1,250.00 | 14 days ago | |
| Bob Smith | bob@example.com | $750.50 | about 1 month ago | |
| Carol Williams | carol@example.com | $0.00 | 3 days ago |
# Collection mode (recommended) — no manual iteration needed
<%= rui_table(@users) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:balance, formatter: :currency) %>
<% table.column(:verified, formatter: :boolean) %>
<% table.column(:created_at, label: "Joined", formatter: :relative_time) %>
<% end %>
# Slot mode — manual iteration with data:
<%= rui_table do |table| %>
<% table.with_column(key: :name, label: "Name") %>
<% table.with_column(key: :balance, label: "Balance", formatter: :currency) %>
<% @users.each do |user| %>
<% table.with_row(id: user.id, data: user) %>
<% end %>
<% end %>
# Both modes work with: ActiveRecord models, Structs, OpenStructs, Hashes
Three Ways to Build a Table
Manual Cells (verbose)
<% @users.each do |user| %>
<% table.with_row(id: user.id) do |row| %>
<% row.with_cell { user.name } %>
<% row.with_cell { user.email } %>
<% row.with_cell { number_to_currency(user.balance) } %>
<% end %>
<% end %>
Data-Driven (cleaner)
<% table.with_column(key: :name) %>
<% table.with_column(key: :balance,
formatter: :currency) %>
<% @users.each do |user| %>
<% table.with_row(data: user) %>
<% end %>
Collection Mode (best)
<%= rui_table(@users) do |table| %>
<% table.column(:name) %>
<% table.column(:balance,
formatter: :currency) %>
<% end %>
<%# No manual iteration needed %>
Toolbar
Use the toolbar slot to add content above the table. Perfect for search inputs, filters, and action buttons.
How the Toolbar Works
- Layout only: The toolbar slot provides styled placement inside the table wrapper
- You wire the logic: Search/filter inputs must be connected to your own controller actions
- Part of the table: When using
shape: :rounded, the toolbar appears inside the rounded border (this is intentional) - Use Turbo: Combine with
turbo_framefor live filtering without page reloads
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | Admin |
| Jane Smith | jane@example.com | Editor |
# Collection mode — toolbar + column definitions
<%= rui_table(@users) do |table| %>
<% table.with_toolbar do %>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex-1 max-w-sm">
<%= rui_input(name: :search, placeholder: "Search...", size: :sm, validation: false) do |input| %>
<% input.with_prefix do %>
<%= rui_icon(:search, size: :sm, standalone: false) %>
<% end %>
<% end %>
</div>
<div class="flex items-center gap-2">
<%= rui_button("Export", variant: :outline, size: :sm) do |btn| %>
<% btn.with_icon(:file_up) %>
<% end %>
<%= rui_button("Add User", color: :primary, size: :sm) do |btn| %>
<% btn.with_icon(:plus) %>
<% end %>
</div>
</div>
<% end %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) { |u| rui_badge(u.role, color: :primary, size: :sm) } %>
<% end %>
# Toolbar slot works identically in both collection and slot modes
Toolbar with Filters
| Name | Status | Role |
|---|---|---|
| Alice Brown | Active | Admin |
| Bob Wilson | Pending | Editor |
Making Filters Work (Turbo Integration)
To make search and filters actually work, wrap your toolbar inputs in a form and use Turbo Frames. The table component doesn't include filtering logic - you implement it in your controller.
# Collection mode with toolbar + Turbo search
<%= rui_table(@users, turbo_frame: "users-table") do |table| %>
<% table.with_toolbar do %>
<%= form_with url: users_path, method: :get,
data: { turbo_frame: "users-table" } do |f| %>
<div class="flex items-center gap-3">
<%= f.text_field :q, value: params[:q],
placeholder: "Search...",
data: { action: "input->debounce#perform" } %>
<%= f.select :status, options_for_select(
[["All", ""], ["Active", "active"]], params[:status]) %>
</div>
<% end %>
<% end %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:status) { |u| rui_badge(u.status, color: :success) } %>
<% end %>
# In your controller:
def index
@users = User.all
@users = @users.where("name ILIKE ?", "%#{params[:q]}%") if params[:q].present?
@users = @users.where(status: params[:status]) if params[:status].present?
end
Sorting
Enable sortable columns for client-side sorting or provide a URL for server-side sorting.
| Name | Created | |
|---|---|---|
| Alice Brown | alice@example.com | Jan 15, 2024 |
| Bob Wilson | bob@example.com | Feb 22, 2024 |
# Collection mode — sortable columns
<%= rui_table(@users, sortable: true, sort_column: "name", sort_direction: "asc") do |table| %>
<% table.column(:name, sortable: true) %>
<% table.column(:email, sortable: true) %>
<% table.column(:created_at, sortable: true, formatter: :date) %>
<% end %>
# Server-side sorting — provide sort_url
<%= rui_table(@users,
sortable: true,
sort_column: params[:sort],
sort_direction: params[:dir],
sort_url: users_path
) do |table| %>
<% table.column(:name, sortable: true) %>
<% table.column(:email, sortable: true) %>
<% end %>
Row Selection
Enable checkbox selection with a select-all header checkbox.
| Name | Status | ||
|---|---|---|---|
| John Doe | john@example.com | Active | |
| Jane Smith | jane@example.com | Pending | |
| Bob Johnson | bob@example.com | Inactive |
# Collection mode — row IDs extracted automatically via record.id
<%= rui_table(@users, selectable: true) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:status) { |u| rui_badge(u.status, color: :success, size: :sm) } %>
<% end %>
# Custom ID method (e.g., for UUIDs)
<%= rui_table(@users, selectable: true, id_method: :uuid) do |table| %>
...
<% end %>
# JavaScript API - get selected row IDs:
# tableController.getSelectedIds() => [1, 3, 5]
Bulk Actions
Add an action bar that appears when rows are selected.
| Name | ||
|---|---|---|
| John Doe | john@example.com | |
| Jane Smith | jane@example.com |
# Collection mode — bulk_actions slot works the same way
<%= rui_table(@users, selectable: true) do |table| %>
<% table.with_bulk_actions do %>
<%= rui_button("Delete Selected", color: :danger, size: :sm) %>
<%= rui_button("Export", color: :zinc, size: :sm) %>
<% end %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
# The bulk actions bar shows "X selected" count
# and is hidden when no rows are selected
Quick Setup: rui_bulk_action Helper
Use rui_bulk_action for automatic form wiring. Selected IDs are automatically submitted.
<%= rui_table(@posts, selectable: true) do |table| %>
<% table.with_bulk_actions do %>
<%= rui_bulk_action "Delete Selected",
url: bulk_destroy_posts_path,
method: :delete,
confirm: "Delete selected posts?",
color: :danger,
size: :sm %>
<%= rui_bulk_action "Export",
url: export_posts_path,
method: :post,
color: :zinc,
size: :sm %>
<% end %>
<% table.column(:title) %>
<% table.column(:status) { |p| rui_badge(p.status, color: :success) } %>
<% end %>
# app/controllers/posts_controller.rb
def bulk_destroy
ids = JSON.parse(params[:ids])
Post.where(id: ids).destroy_all
redirect_to posts_path, notice: "#{ids.size} posts deleted"
end
def export
ids = JSON.parse(params[:ids])
@posts = Post.where(id: ids)
respond_to do |format|
format.csv { send_data @posts.to_csv }
end
end
# config/routes.rb
resources :posts do
collection do
delete :bulk_destroy
post :export
end
end
Options
| Parameter | Type | Default | Description |
|---|---|---|---|
| text | String | — | Button label text |
| url | String | — | Form action URL (required) |
| method | Symbol | :post |
HTTP method (:post, :patch, :put, :delete) |
| confirm | String | nil |
Turbo confirm dialog message |
| param_name | Symbol | :ids |
Name of the param containing selected IDs |
| color, size, variant | Symbol | Button defaults | All rui_button options are supported |
Visual Variants
Customize appearance with striped rows, borders, and hover effects.
Striped
| Name | |
|---|---|
| User 1 | user1@example.com |
| User 2 | user2@example.com |
| User 3 | user3@example.com |
| User 4 | user4@example.com |
Bordered
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | Admin |
| Jane Smith | jane@example.com | Editor |
Sticky Header
Keep the header visible during vertical scrolling. The component automatically creates a scrollable container with the specified max height.
The header automatically gets a solid background color to prevent content from showing through when scrolling.
| Customer | Date | Status | Amount |
|---|---|---|---|
| Customer 1 | May 12, 2026 | Paid | $90.5 |
| Customer 2 | May 11, 2026 | Pending | $131.0 |
| Customer 3 | May 10, 2026 | Refunded | $171.5 |
| Customer 4 | May 09, 2026 | Paid | $212.0 |
| Customer 5 | May 08, 2026 | Pending | $252.5 |
| Customer 6 | May 07, 2026 | Paid | $293.0 |
| Customer 7 | May 06, 2026 | Refunded | $333.5 |
| Customer 8 | May 05, 2026 | Paid | $374.0 |
| Customer 9 | May 04, 2026 | Pending | $414.5 |
| Customer 10 | May 03, 2026 | Paid | $455.0 |
| Customer 11 | May 02, 2026 | Paid | $495.5 |
| Customer 12 | May 01, 2026 | Refunded | $536.0 |
# All variants work with collection mode
<%= rui_table(@users, striped: true) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
<%= rui_table(@users, bordered: true) do |table| %>
...
<% end %>
<%= rui_table(@users, sticky_header: true, sticky_max_height: "16rem") do |table| %>
...
<% end %>
<%= rui_table(@users, hoverable: false) do |table| %>
...
<% end %>
Shape
Tables can have square (default) or rounded corners. Rounded tables work best with borders for a polished look.
Rounded
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | Admin |
| Jane Smith | jane@example.com | Editor |
Square (Default)
| Name | Role | |
|---|---|---|
| John Doe | john@example.com | Admin |
| Jane Smith | jane@example.com | Editor |
# Rounded corners (best with bordered) — works with collection mode
<%= rui_table(@users, shape: :rounded, bordered: true) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
# Square corners (default)
<%= rui_table(@users, shape: :square) do |table| %>
...
<% end %>
Striped Columns
In addition to striped rows, you can enable striped columns for alternating column backgrounds. This is useful for wide tables to help track data across rows.
| Q1 2024 | Q2 2024 | Q3 2024 | Q4 2024 |
|---|---|---|---|
| $12,500 | $15,200 | $18,900 | $22,100 |
| $8,300 | $9,100 | $11,400 | $13,200 |
| $6,700 | $7,800 | $9,200 | $10,500 |
Combined with Striped Rows
| Product | Jan | Feb | Mar |
|---|---|---|---|
| Widget A | 150 | 180 | 210 |
| Widget B | 90 | 120 | 145 |
| Widget C | 200 | 230 | 280 |
| Widget D | 75 | 85 | 95 |
# Striped columns — works with both collection and slot modes
<%= rui_table(@quarterly_data, striped_columns: true) do |table| %>
<% table.column(:q1, label: "Q1 2024") %>
<% table.column(:q2, label: "Q2 2024") %>
<% table.column(:q3, label: "Q3 2024") %>
<% table.column(:q4, label: "Q4 2024") %>
<% end %>
# Combined: striped rows + striped columns
<%= rui_table(@data, striped: true, striped_columns: true) do |table| %>
...
<% end %>
Sizes
Five size options control cell padding and text size.
Size: xs
| Name | |
|---|---|
| John Doe | john@example.com |
Size: sm
| Name | |
|---|---|
| John Doe | john@example.com |
Size: base
| Name | |
|---|---|
| John Doe | john@example.com |
Size: lg
| Name | |
|---|---|
| John Doe | john@example.com |
Size: xl
| Name | |
|---|---|
| John Doe | john@example.com |
# All sizes work with collection mode
<%= rui_table(@users, size: :xs) do |table| %>
<% table.column(:name) %>
<% end %>
<%= rui_table(@users, size: :sm) do |table| %> # Small
<%= rui_table(@users, size: :base) do |table| %> # Base (default)
<%= rui_table(@users, size: :lg) do |table| %> # Large
<%= rui_table(@users, size: :xl) do |table| %> # Extra large
Responsive Modes
Three modes for handling tables on mobile devices. Resize your browser or view on mobile to see the difference.
Scroll Mode (Default)
Table scrolls horizontally on small screens. Best for data-heavy tables where users need to see exact values.
| ID | Name | Role | Status | |
|---|---|---|---|---|
| 001 | John Doe | john@example.com | Admin | Active |
| 002 | Jane Smith | jane@example.com | Editor | Pending |
Cards Mode
Each row becomes a card on mobile with label/value pairs. Best for user-facing data displays.
Stack Mode
Cells stack vertically on mobile with labels above values. Good for forms or detail views.
# All responsive modes work with collection mode
<%= rui_table(@users, responsive_mode: :scroll) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) %>
<% end %>
# Cards mode - each row becomes a card on mobile
<%= rui_table(@users, responsive_mode: :cards) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
# Stack mode - cells stack vertically on mobile
<%= rui_table(@users, responsive_mode: :stack) do |table| %>
...
<% end %>
# Disable responsive behavior
<%= rui_table(@users, responsive: false) do |table| %>
...
<% end %>
Pagination
RapidRailsUI includes built-in pagination via rui_paginate — no external gems needed. Call it in your controller and pass the result to your table. The table automatically shows "Showing X-Y of Z" in the footer.
See the Pagination documentation for standalone pagination with variants, sizes, colors, alignment, page jumper, and more.
Blog Posts
Showing page 1 of 22 (110 total posts)
| Title | Category | Author | Status |
|---|---|---|---|
| Untitled Post | Draft | ||
| Untitled Post | Draft | ||
| Untitled Post | Draft | ||
| Untitled Post | Draft | ||
| Untitled Post | Draft |
Standalone Pagination
You can also use rui_pagination outside the table for custom layouts.
# In your controller (rui_paginate is built-in, no gems needed):
def index
@pagy, @posts = rui_paginate(Post.all, limit: 5)
end
# Collection mode (recommended):
<%= rui_table(@posts,
pagy: @pagy,
title: "Blog Posts",
description: "Page <%%= @pagy.page %> of <%= @pagy.last %>"
) do |table| %>
<% table.column(:title) %>
<% table.column(:category) { |p| rui_badge(p.category, color: :zinc) } %>
<% table.column(:status) { |p| rui_badge(p.status, color: :success) } %>
<% end %>
# Add pagination below the table
<%= rui_pagination(pagy: @pagy) %>
# The table footer automatically shows "Showing 1-5 of 26"
Empty State
Customize what shows when the table has no data.
Default Empty State
| Name | |
|---|---|
No data found | |
Custom Message
| Name | |
|---|---|
No users match your search criteria | |
Custom Empty State
| Name | |
|---|---|
No users yetGet started by creating a new user. | |
# Collection mode — empty collection triggers empty state
<%= rui_table([], empty_message: "No users found") do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
# Custom empty state slot (works with both modes)
<%= rui_table(@users) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.with_empty_state do %>
<div class="text-center py-8">
<%= rui_icon(:inbox, size: :xl3) %>
<h3 class="mt-4 font-medium">No users yet</h3>
<%= rui_button("Add User", color: :primary) %>
</div>
<% end %>
<% end %>
Loading State
Show skeleton placeholders while data is loading.
| Name | Role | |
|---|---|---|
# Loading works with both modes — columns define skeleton widths
<%= rui_table(loading: true) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
# Customize number of skeleton rows
<%= rui_table(loading: true, loading_rows: 10) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) %>
<% end %>
Turbo Integration
Wrap the table in a Turbo Frame for seamless updates without full page reloads. The pagination example above demonstrates this - clicking page numbers updates only the table content.
Built-in Turbo Frame
Pass turbo_frame to automatically wrap table content:
# Collection mode with turbo_frame
<%= rui_table(@users, turbo_frame: "users_table") do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) { |u| rui_badge(u.role, color: :primary) } %>
<% end %>
# Manual Turbo Frame wrapper (more control)
<turbo-frame id="users_table">
<%= rui_table(@users) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% end %>
<%= link_to "Next", users_path(page: 2),
data: { turbo_frame: "users_table" } %>
</turbo-frame>
Turbo Stream Updates
For real-time updates (e.g., row added/removed), use Turbo Streams:
# In controller action
def create
@user = User.create!(user_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.prepend(
"users_table_body",
partial: "users/row",
locals: { user: @user }
)
end
end
end
# In view - add target to tbody
<%= rui_table do |table| %>
...
<!-- Add id to tbody for streaming -->
<tbody id="users_table_body">
...
</tbody>
<% end %>
Real-World Examples
RapidRailsUI components are designed to work seamlessly together. Tables frequently combine with other components to create rich, interactive interfaces. This div demonstrates real-world patterns using multiple components.
Dialog + Table
Modal dialogs for detail views, edit forms, and delete confirmations.
Dropdown + Table
Action menus for row operations like edit, duplicate, delete.
Combobox + Table
Searchable selects for inline editing like assignee or category.
Date + Table
Date pickers for due dates, scheduled times, or date ranges.
User Management
A complete user management table with avatars, status badges, role indicators, and action buttons.
Team Members
Manage your team members and their account permissions.
Order History
E-commerce order table with order numbers, product details, and payment status.
| Order | Product | Date | Status | Amount |
|---|---|---|---|---|
| #ORD-2024-001 |
Premium Subscription (Annual)
| Jan 5, 2024 | Completed | $199.00 |
| #ORD-2024-002 |
Pro License (Monthly)
| Jan 12, 2024 | Processing | $29.00 |
| #ORD-2024-003 |
Enterprise Setup
| Jan 15, 2024 | Refunded | $499.00 |
| Total Revenue: | $228.00 | |||
Analytics Dashboard
Metrics table with trend indicators and progress visualization using striped columns.
| Metric | Today | This Week | This Month | Trend |
|---|---|---|---|---|
|
New Users
| 152 | 1,024 | 4,392 |
+12%
|
|
Page Views
| 8,234 | 52,891 | 198,432 |
+8%
|
|
Avg. Session
| 4m 32s | 4m 18s | 3m 56s |
-3%
|
|
Revenue
| $2,340 | $18,290 | $72,450 |
+24%
|
Overflow Scrolling
Tables with many columns can use horizontal scrolling. Set responsive_mode: :scroll (the default) to enable overflow behavior.
Resize your browser window to see horizontal scrolling in action. The table container becomes scrollable when content exceeds available width.
| ID | Full Name | Email Address | Department | Job Title | Office Location | Hire Date | Status | Annual Salary |
|---|---|---|---|---|---|---|---|---|
| 001 | Alexandra Thompson | a.thompson@company.com | Engineering | Senior Developer | San Francisco | Mar 2021 | Active | $145,000 |
| 002 | Benjamin Rodriguez | b.rodriguez@company.com | Marketing | Marketing Manager | New York | Jun 2020 | Active | $98,000 |
| 003 | Catherine Williams | c.williams@company.com | Design | UX Lead | Austin | Sep 2022 | On Leave | $115,000 |
# Collection mode with wide table — scrolls horizontally
<%= rui_table(@employees, responsive_mode: :scroll, bordered: true, shape: :rounded) do |table| %>
<% table.column(:id, label: "ID", width: "60px") %>
<% table.column(:name, label: "Full Name", width: "180px") %>
<% table.column(:email, label: "Email Address", width: "220px") %>
<% table.column(:department, width: "150px") %>
<% table.column(:role, label: "Job Title", width: "180px") %>
<% table.column(:location, label: "Office Location", width: "150px") %>
<% table.column(:hire_date, formatter: :date, width: "120px") %>
<% table.column(:status, width: "100px") { |e| rui_badge(e.status, color: :success) } %>
<% table.column(:salary, formatter: :currency, align: :right, width: "130px") %>
<% end %>
Table Filter
Combine tables with input fields and dropdowns to create filterable data views. This pattern uses existing RapidRailsUI components.
| Name | Role | Status | |
|---|---|---|---|
| Alice Johnson | alice@example.com | Admin | Active |
| Bob Smith | bob@example.com | Editor | Active |
| Carol Davis | carol@example.com | Viewer | Pending |
<%# Filter Bar %>
<div class="flex flex-col sm:flex-row gap-3 mb-4">
<div class="flex-1">
<%= rui_input(placeholder: "Search users...", prefix_icon: :search, size: :sm) %>
</div>
<div class="flex gap-2">
<%= rui_dropdown(text: "Status", size: :sm, variant: :outline, chevron: true) do |d| %>
<% d.with_item(text: "All Statuses") %>
<% d.with_item(text: "Active") %>
<% d.with_item(text: "Pending") %>
<% end %>
</div>
</div>
<%# Results Table — collection mode with Turbo Frame %>
<%= turbo_frame_tag "users_table" do %>
<%= rui_table(@users, striped: true, hoverable: true) do |table| %>
<% table.column(:name) %>
<% table.column(:email) %>
<% table.column(:role) %>
<% table.column(:status) { |u| rui_badge(u.status, color: :success) } %>
<% end %>
<% end %>
Table with Modal
Combine tables with the Dialog component for detail views, edit forms, or confirmations. Dialogs support various triggers and can contain any content including forms.
| Product | SKU | Stock | Actions |
|---|---|---|---|
|
Wireless Headphones Pro
| WHP-001 | 42 | |
|
USB-C Charging Hub
| UCH-042 | 8 | |
<%= rui_dialog(
trigger: rui_button("View", size: :sm, variant: :outline),
title: "Product Details",
description: "Wireless Headphones Pro"
) do %>
<div class="grid grid-cols-2 gap-4">
<div>
<span class="text-zinc-500">Price</span>
<p class="font-semibold">$199.99</p>
</div>
<div>
<span class="text-zinc-500">Stock</span>
<%= rui_badge("In Stock", color: :success, size: :sm) %>
</div>
</div>
<% end %>
Edit Form Modal
Use dialogs to display edit forms. The form can submit via Turbo for seamless updates.
| Name | Role | EDIT | |
|---|---|---|---|
|
AJ
Alice Johnson
| alice@example.com | Admin | |
|
BS
Bob Smith
| bob@example.com | Editor | |
<%= rui_dialog(
trigger: rui_button("Edit", size: :sm, variant: :ghost, color: :sky),
title: "Edit User",
description: "Update user information"
) do %>
<div class="space-y-4">
<%= rui_input(method: :name, label: "Name", value: user.name, size: :sm) %>
<%= rui_input(method: :email, type: :email, label: "Email", size: :sm) %>
<%= rui_select(method: :role, label: "Role", collection: roles, size: :sm) %>
<div class="flex justify-end gap-2 pt-4">
<%= rui_button("Cancel", variant: :outline, size: :sm, data: { action: "click->dialog#close" }) %>
<%= rui_button("Save", color: :primary, size: :sm, data: { action: "dialog#submit" }) %>
</div>
</div>
<% end %>
Delete Confirmation Modal
Use dialogs for destructive action confirmations. The danger color variant signals the action severity.
| Name | Created | |
|---|---|---|
| Project Alpha | Jan 15, 2026 | |
| Project Beta | Feb 3, 2026 | |
<%= rui_dialog(
trigger: rui_button("Delete", size: :xs, variant: :ghost, color: :danger),
title: "Delete Project",
description: "Are you sure you want to delete this project?"
) do %>
<div class="space-y-4">
<%= rui_alert(
type: :danger,
title: "This action cannot be undone",
message: "All project data will be permanently deleted."
) %>
<div class="flex justify-end gap-2">
<%= rui_button("Cancel", variant: :outline, size: :sm, data: { action: "click->dialog#close" }) %>
<%= rui_button("Delete", color: :danger, size: :sm) %>
</div>
</div>
<% end %>
Table with Dropdown Actions
Use the Dropdown component for compact action menus on each row. This pattern is ideal when you have multiple actions but limited horizontal space.
| Order | Customer | Total | Status | |
|---|---|---|---|---|
| #ORD-2024-001 |
SC
Sarah Connor
| $1,249.00 | Shipped |
|
| #ORD-2024-002 |
JW
John Wick
| $89.99 | Processing |
|
| #ORD-2024-003 |
ER
Ellen Ripley
| $456.50 | Pending |
|
<% row.with_cell(align: :right) do %>
<%= rui_dropdown(icon: :more_vertical, size: :sm, variant: :ghost, width: :auto) do |d| %>
<% d.with_item(text: "View Order", icon: :eye) %>
<% d.with_item(text: "Edit Order", icon: :edit) %>
<% d.with_item(text: "Download Invoice", icon: :download) %>
<% d.with_divider %>
<% d.with_item(text: "Refund", icon: :rotate_ccw, color: :warning) %>
<% d.with_item(text: "Cancel Order", icon: :x, color: :danger) %>
<% end %>
<% end %>
# Dropdown triggers:
# - icon: :more_vertical (three dots) - most common
# - icon: :more_horizontal (three dots horizontal)
# - icon: :chevron_down (with text)
# - text: "Actions" (text-only)
Table with Combobox
Use the Combobox component for searchable inline selection. Perfect for assigning users, selecting categories, or any field with many options.
| Task | Priority | Assignee | Status |
|---|---|---|---|
| | High | | In Progress |
| | Medium | | Todo |
| | Low | | Todo |
<% row.with_cell do %>
<%= rui_combobox(
collection: [
["Alice Johnson", "alice"],
["Bob Smith", "bob"],
["Carol Davis", "carol"]
],
selected: "alice",
placeholder: "Assign...",
size: :sm,
searchable: true
) %>
<% end %>
# Collection format: ["Label", "value"]
# Optional third element: icon symbol like :user
# Use size: :sm for compact inline display
Table with Date Picker
Use the Date component for due dates, scheduling, or any date-related fields. The date picker integrates seamlessly within table cells.
| Project | Milestone | Due Date | Status |
|---|---|---|---|
|
Website Redesign
| Phase 1 - Discovery |
| On Track |
|
Mobile App v2
| Beta Release |
| At Risk |
|
API Migration
| Database Cutover |
| Overdue |
<% row.with_cell do %>
<%= rui_date(
value: project.due_date,
size: :sm,
min: Date.today # Optional: prevent past dates
) %>
<% end %>
# The date component automatically formats dates
# and opens a calendar/date picker on click
# Use size: :sm for compact inline display
Headless Table
Set headless: true to hide the table header. Useful for simple data displays or when labels are self-explanatory.
|
Meeting with Design Team
| Today, 2:00 PM | Upcoming |
|
Quarterly Report Due
| Tomorrow, 5:00 PM | Pending |
|
Code Review Completed
| Yesterday | Done |
# Headless works with both collection and slot modes
# Slot mode is typical here since headless tables often have custom cell layouts
<%= rui_table(headless: true, bordered: true, shape: :rounded) do |table| %>
<% table.with_row(id: 1) do |row| %>
<% row.with_cell do %>
<div class="flex items-center gap-3">
<%= rui_icon(:calendar, size: :sm, color: :primary) %>
<span class="font-medium">Meeting with Design Team</span>
</div>
<% end %>
<% row.with_cell { "Today, 2:00 PM" } %>
<% row.with_cell(align: :right) { rui_badge("Upcoming", color: :info) } %>
<% end %>
<% end %>
# When headless: true, column definitions are optional
# but can still be used for mobile responsive views
Custom Styling
Override or extend styles using the class: parameter. RapidRailsUI uses tailwind_merge to intelligently merge custom classes.
Available Class Overrides
class:- Applied to the outer wrapper element- Any HTML attribute via
**options(id, data-*, aria-*, etc.)
Custom Wrapper Styling
| Name | Status |
|---|---|
| Custom styled table | Active |
| With shadow and ring | Pro |
# Custom classes work with collection mode
<%= rui_table(@users,
class: "shadow-lg ring-1 ring-zinc-900/5",
bordered: true,
shape: :rounded
) do |table| %>
<% table.column(:name) %>
<% table.column(:status) { |u| rui_badge(u.status, color: :success) } %>
<% end %>
# Custom data attributes
<%= rui_table(@users,
id: "my-table",
data: { controller: "my-custom-table" }
) do |table| %>
<% table.column(:name) %>
<% end %>
Accessibility
The Table component follows WAI-ARIA best practices for accessible data tables.
ARIA Attributes
-
role="grid"on the table element for screen readers -
scope="col"on header cells for column association -
aria-sortindicates current sort direction (ascending/descending/none) -
aria-labelon checkboxes ("Select all rows", "Select row") -
aria-describedbylinks table to caption for context
Keyboard Navigation
- Tab navigates between interactive elements (sort headers, checkboxes, links)
- Enter or Space activates sort headers and checkboxes
- Focus states are clearly visible with ring styles for all interactive elements
Semantic HTML
-
Uses native
<table>,<thead>,<tbody>,<tfoot>elements -
Header cells use
<th>with scope, data cells use<td> -
Sortable headers use
<button>elements for proper interaction -
Caption slot renders as screen-reader-only
.sr-onlyelement
Accessible Table Example
# Collection mode — all ARIA attributes are automatic
<%= rui_table(@users,
sortable: true,
selectable: true,
sort_column: "name",
sort_direction: "asc"
) do |table| %>
<% table.with_caption { "List of team members with names, roles, and contact information" } %>
<% table.column(:name, sortable: true) %>
<% table.column(:role) %>
<% table.column(:email) %>
<% end %>
# Generated HTML includes:
# <table role="grid" aria-describedby="table-123-caption">
# <div id="table-123-caption" class="sr-only">...</div>
# <thead>
# <tr>
# <th scope="col">
# <input type="checkbox" aria-label="Select all rows">
# </th>
# <th scope="col" aria-sort="ascending">
# <button>Name <svg>...</svg></button>
# </th>
# </tr>
# </thead>
# </table>
JavaScript API
The Table component uses a Stimulus controller with these methods and targets.
| Method/Target | Description |
|---|---|
| sort(event) | Trigger sort on a column (client-side or navigates to sort_url) |
| toggleAll(event) | Toggle all row checkboxes via header checkbox |
| toggleRow(event) | Toggle individual row checkbox |
| getSelectedIds() | Returns array of selected row IDs |
| selectAllTarget | Header "select all" checkbox |
| rowCheckboxTargets | All row checkboxes |
| bulkActionsTarget | Bulk actions bar container |
| selectedCountTarget | Selected count display element |
# Get selected row IDs in a Stimulus controller
export default class extends Controller {
static outlets = ["table"]
deleteSelected() {
const ids = this.tableOutlet.getSelectedIds()
// ids = [1, 3, 5]
fetch("/users/bulk_delete", {
method: "DELETE",
body: JSON.stringify({ ids })
})
}
}
# Or use data-action directly:
<button data-action="my-controller#deleteSelected">
Delete Selected
</button>
API Reference
rui_table
Full-featured data table with sorting, selection, pagination, and responsive modes
| Parameter | Type | Default | Description |
|---|---|---|---|
| id | String |
auto-generated
|
Unique ID for the table |
| empty_message | String |
"No data found"
|
Message when table has no rows |
Caption
Visible caption options
| Parameter | Type | Default | Description |
|---|---|---|---|
| title | String | — | Visible title above the table |
| description | String | — | Visible description below the title |
Appearance
Visual styling options
| Parameter | Type | Default | Description |
|---|---|---|---|
| size | Symbol |
:base
|
Cell padding and text size
:xs
:sm
:base
:lg
:xl
|
| shape | Symbol |
:rounded
|
Table corner shape
:square
:rounded
|
| striped | Boolean |
false
|
Alternate row backgrounds |
| striped_columns | Boolean |
false
|
Alternate column backgrounds |
| hoverable | Boolean |
true
|
Highlight rows on hover |
| bordered | Boolean |
true
|
Add outer border and vertical borders between cells |
| headless | Boolean |
false
|
Hide the table header (thead) |
| sticky_header | Boolean |
false
|
Fix header when scrolling |
| sticky_max_height | String |
"24rem"
|
Max height for scrollable area (when sticky_header is true) |
Sorting
Column sorting configuration
| Parameter | Type | Default | Description |
|---|---|---|---|
| sortable | Boolean |
false
|
Enable sorting on columns |
| sort_column | String | — | Currently sorted column key |
| sort_direction | String | — | Current sort direction: asc or desc |
| sort_url | String | — | URL for server-side sorting |
Selection
Row selection options
| Parameter | Type | Default | Description |
|---|---|---|---|
| selectable | Boolean |
false
|
Show row checkboxes |
Responsive
Mobile layout options
| Parameter | Type | Default | Description |
|---|---|---|---|
| responsive | Boolean |
true
|
Enable responsive behavior |
| responsive_mode | Symbol |
:scroll
|
Mobile layout mode
:scroll
:cards
:stack
|
Pagination
Pagination integration
| Parameter | Type | Default | Description |
|---|---|---|---|
| pagy | PageInfo | — | PageInfo instance for pagination info (from rui_paginate) |
Turbo
Turbo Frame integration
| Parameter | Type | Default | Description |
|---|---|---|---|
| turbo_frame | String | — | Wrap table in Turbo Frame with this ID |
Loading
Loading state options
| Parameter | Type | Default | Description |
|---|---|---|---|
| loading | Boolean |
false
|
Show skeleton loading state |
| loading_rows | Integer |
5
|
Number of skeleton rows to show |
Column Formatters
Built-in formatters for common data types
| Parameter | Type | Default | Description |
|---|---|---|---|
| :date | Symbol | — | Format as date (e.g., Jan 15, 2024) |
| :datetime | Symbol | — | Format as date and time |
| :time | Symbol | — | Format as time only |
| :relative_time | Symbol | — | Time ago in words (e.g., 2 days ago) |
| :currency | Symbol | — | Format as currency ($1,234.56) |
| :number | Symbol | — | Format with thousands separator |
| :percentage | Symbol | — | Format as percentage (75.5%) |
| :boolean | Symbol | — | Format as Yes/No or custom text |
| :truncate | Symbol | — | Truncate long text with ellipsis |
| :badge | Symbol | — | Render as colored badge component |
Custom Styling
Override default styles
| Parameter | Type | Default | Description |
|---|---|---|---|
| class | String | — | Custom CSS classes for the wrapper element (merged with defaults) |
| id | String |
auto-generated
|
Custom ID for the wrapper element |
| data | Hash | — | Custom data attributes (data-controller, etc.) |
Slots
Content slots for customizing component parts
| Slot | Description |
|---|---|
| column | Define table columns: table.with_column(key: :name, label: 'Name', sortable: true, align: :left, width: '200px', formatter: :date) |
| row | Add data rows: table.with_row(id: 1, data: user, color: :danger) { |row| row.with_cell { 'value' } } |
| toolbar | Content above the table: table.with_toolbar { search_field + filter_button } |
| empty_state | Custom empty state content: table.with_empty_state { 'Custom message' } |
| bulk_actions | Bulk action buttons: table.with_bulk_actions { button_tag 'Delete Selected' } |
| footer | Table footer row: table.with_footer { 'Total: $1,000' } |
| caption | Accessible table caption: table.with_caption { 'List of users' } |