Table
Full-featured data table with sorting, row selection, pagination, and responsive layouts.
Key Features
- 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 - Pagy integration 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
Basic Usage
Create a simple table with columns and rows using the slot-based API.
| 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(
title: "Team Members",
description: "A list of all team members including their name, title, and email.",
bordered: true,
shape: :rounded
) do |table| %>
<% table.with_column(key: :name, label: "Name") %>
<% table.with_column(key: :title, label: "Title") %>
<% table.with_column(key: :email, label: "Email") %>
...
<% end %>
# Title only
<%= rui_table(title: "Recent Orders") do |table| %>
...
<% end %>
# Description only
<%= rui_table(description: "Showing orders from the last 30 days") do |table| %>
...
<% 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 |
<% 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:
# - key: Symbol - Column identifier (used for sorting)
# - 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%")
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 |
# Highlight rows with semantic colors
<% 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 %>
<% table.with_row(id: 3, color: :danger) do |row| %>
<% row.with_cell { rui_badge("Cancelled", color: :danger) } %>
<% row.with_cell { "#ORD-003" } %>
<% end %>
# Row options:
# - id: Any - Row identifier (required for selection)
# - data: Hash/Object - Attach data to row
# - color: Symbol - Row highlight color
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
# Simple formatter (symbol)
<% table.with_column(key: :price, label: "Price", formatter: :currency) %>
<% table.with_column(key: :created_at, label: "Created", formatter: :date) %>
<% table.with_column(key: :active, label: "Active", formatter: :boolean) %>
# Formatter with options (hash)
<% table.with_column(
key: :discount,
label: "Discount",
formatter: { type: :percentage, precision: 1 }
) %>
<% table.with_column(
key: :status,
label: "Status",
formatter: { type: :boolean, true_text: "Active", false_text: "Inactive" }
) %>
<% table.with_column(
key: :description,
label: "Description",
formatter: { type: :truncate, length: 50 }
) %>
# Badge formatter with color mapping
<% table.with_column(
key: :status,
label: "Status",
formatter: {
type: :badge,
colors: { active: :success, pending: :warning, cancelled: :danger }
}
) %>
# Custom formatter with Proc
<% table.with_column(
key: :email,
label: "Email",
formatter: ->(value) { mail_to(value, value, class: "text-blue-600") }
) %>
Date & Time Formatters
| Event | Date (:date) | Relative (:relative_time) | Time (:time) |
|---|---|---|---|
| Meeting | Jan 09, 2026 | 2 days ago | 02:30 PM |
| Deadline | Jan 18, 2026 | 7 days ago | 05:00 PM |
| Review | Jan 11, 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
Data-driven rows 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 |
# Define columns with keys that match your data object
<%= rui_table do |table| %>
<% table.with_column(key: :name, label: "Name") %>
<% table.with_column(key: :email, label: "Email") %>
<% table.with_column(key: :balance, label: "Balance", formatter: :currency) %>
<% table.with_column(key: :verified, label: "Verified", formatter: :boolean) %>
<% table.with_column(key: :created_at, label: "Joined", formatter: :relative_time) %>
<%# Cells are auto-generated from data object %>
<% @users.each do |user| %>
<% table.with_row(id: user.id, data: user) %>
<% end %>
<% end %>
# Works with:
# - ActiveRecord models
# - Struct objects
# - Plain Ruby objects with methods
# - Hashes (with symbol or string keys)
Manual vs Data-Driven Comparison
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) } %>
<% row.with_cell { user.verified ? "Yes" : "No" } %>
<% row.with_cell { time_ago_in_words(user.created_at) } %>
<% end %>
<% end %>
Data-Driven (clean)
<% @users.each do |user| %>
<% table.with_row(id: user.id, data: user) %>
<% end %>
<%# Columns define the formatters once %>
<% table.with_column(key: :balance, formatter: :currency) %>
<% table.with_column(key: :verified, formatter: :boolean) %>
<% table.with_column(key: :created_at, formatter: :relative_time) %>
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 |
<%= rui_table do |table| %>
<%# Toolbar slot - renders above the 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(:magnifying_glass, 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(:arrow_down_tray) %>
<% end %>
<%= rui_button("Add User", color: :primary, size: :sm) do |btn| %>
<% btn.with_icon(:plus) %>
<% end %>
</div>
</div>
<% end %>
<% table.with_column(key: :name, label: "Name") %>
...
<% end %>
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.
# In your view:
<%= rui_table(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...",
class: "your-input-classes",
data: { action: "input->debounce#perform" } %>
<%= f.select :status, options_for_select([["All", ""], ["Active", "active"]], params[:status]) %>
<%= f.submit "Search" %>
</div>
<% end %>
<% end %>
<!-- columns and rows... -->
<% 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 |
# Enable sorting with current state
<%= rui_table(
sortable: true,
sort_column: "name",
sort_direction: "asc"
) do |table| %>
<% table.with_column(key: :name, label: "Name", sortable: true) %>
<% table.with_column(key: :email, label: "Email", sortable: true) %>
<% table.with_column(key: :created_at, label: "Created", sortable: true) %>
<% end %>
# For server-side sorting, provide a sort_url:
<%= rui_table(
sortable: true,
sort_column: params[:sort],
sort_direction: params[:dir],
sort_url: users_path
) do |table| %>
...
<% 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 |
<%= rui_table(selectable: true) do |table| %>
<% table.with_column(key: :name, label: "Name") %>
<% table.with_column(key: :email, label: "Email") %>
<% table.with_row(id: 1) do |row| %>
<% row.with_cell { "John Doe" } %>
<% row.with_cell { "john@example.com" } %>
<% end %>
<% 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 |
<%= rui_table(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.with_column(key: :name, label: "Name") %>
...
<% 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(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 %>
...
<% 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 | Jan 11, 2026 | Pending | $366.31 |
| Customer 2 | Jan 10, 2026 | Pending | $542.97 |
| Customer 3 | Jan 09, 2026 | Paid | $222.73 |
| Customer 4 | Jan 08, 2026 | Pending | $189.44 |
| Customer 5 | Jan 07, 2026 | Paid | $97.57 |
| Customer 6 | Jan 06, 2026 | Refunded | $476.31 |
| Customer 7 | Jan 05, 2026 | Refunded | $468.7 |
| Customer 8 | Jan 04, 2026 | Refunded | $501.76 |
| Customer 9 | Jan 03, 2026 | Paid | $357.94 |
| Customer 10 | Jan 02, 2026 | Pending | $125.2 |
| Customer 11 | Jan 01, 2026 | Paid | $504.74 |
| Customer 12 | Dec 31, 2025 | Refunded | $390.76 |
# Striped rows (alternating backgrounds)
<%= rui_table(striped: true) do |table| %>
...
<% end %>
# Bordered cells
<%= rui_table(bordered: true) do |table| %>
...
<% end %>
# Sticky header with custom max height (default: 24rem)
<%= rui_table(sticky_header: true, sticky_max_height: "16rem") do |table| %>
...
<% end %>
# Disable hover effect (enabled by default)
<%= rui_table(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)
<%= rui_table(shape: :rounded, bordered: true) do |table| %>
...
<% end %>
# Square corners (default)
<%= rui_table(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 only
<%= rui_table(striped_columns: true) do |table| %>
...
<% end %>
# Combined: striped rows + striped columns
<%= rui_table(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 |
# Extra small
<%= rui_table(size: :xs) do |table| %>
# Small
<%= rui_table(size: :sm) do |table| %>
# Base (default)
<%= rui_table(size: :base) do |table| %>
# Large
<%= rui_table(size: :lg) do |table| %>
# Extra large
<%= rui_table(size: :xl) do |table| %>
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.
# Scroll mode (default) - horizontal scroll on mobile
<%= rui_table(responsive_mode: :scroll) do |table| %>
...
<% end %>
# Cards mode - each row becomes a card on mobile
<%= rui_table(responsive_mode: :cards) do |table| %>
...
<% end %>
# Stack mode - cells stack vertically on mobile
<%= rui_table(responsive_mode: :stack) do |table| %>
...
<% end %>
# Disable responsive behavior
<%= rui_table(responsive: false) do |table| %>
...
<% end %>
Pagination
Integrate with Pagy for pagination info display. The table 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 2 of 6 (26 total posts)
| Title | Category | Author | Status |
|---|---|---|---|
| Animation Principles for UI | Design | Emma Wilson | Published |
| Building Reusable Table Components | Tutorials | Sarah Chen | Published |
| Rails 8 New Features Overview | Engineering | Ahmed Ibrahim | Published |
| Responsive Design Patterns | Design | Mike Johnson | Published |
| Modal Dialogs Done Right | Tutorials | Emma Wilson | Published |
Standalone Pagination
You can also use rui_pagination outside the table for custom layouts.
# In your controller:
def index
@pagy, @posts = pagy(Post.all, limit: 5)
end
# In your view:
<%= rui_table(
pagy: @pagy,
title: "Blog Posts",
description: "Page #{@pagy.page} of #{@pagy.last}"
) do |table| %>
<% table.with_column(key: :title, label: "Title") %>
<% table.with_column(key: :category, label: "Category") %>
<% table.with_column(key: :status, label: "Status") %>
<% @posts.each do |post| %>
<% table.with_row(id: post.id) do |row| %>
<% row.with_cell { post.title } %>
<% row.with_cell { post.category } %>
<% row.with_cell { rui_badge(post.status, color: :success) } %>
<% end %>
<% end %>
<% end %>
# Add Pagy navigation below the table
<%== pagy_nav(@pagy) %>
# The table footer 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. | |
# Custom message
<%= rui_table(empty_message: "No users found") do |table| %>
...
<% end %>
# Custom empty state slot
<%= rui_table do |table| %>
<% 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 | |
|---|---|---|
# Show 5 skeleton rows (default)
<%= rui_table(loading: true) do |table| %>
<% table.with_column(key: :name, label: "Name") %>
<% table.with_column(key: :email, label: "Email") %>
<% end %>
# Customize number of skeleton rows
<%= rui_table(loading: true, loading_rows: 10) do |table| %>
...
<% 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:
# Option 1: Built-in turbo_frame param
<%= rui_table(turbo_frame: "users_table") do |table| %>
<% table.with_column(key: :name, label: "Name") %>
...
<% end %>
# Option 2: Manual Turbo Frame wrapper (more control)
<turbo-frame id="users_table">
<%= rui_table do |table| %>
...
<% end %>
<!-- Pagination links target the frame -->
<%= link_to "Next", users_path(page: 2),
data: { turbo_frame: "users_table" } %>
</turbo-frame>
# Sorting with Turbo
<%= link_to "Sort by Name", users_path(sort: :name, dir: :asc),
data: { turbo_frame: "users_table" } %>
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 |
# Wide table with horizontal scroll (default behavior)
<%= rui_table(responsive_mode: :scroll, bordered: true, shape: :rounded) do |table| %>
<% table.with_column(key: :id, label: "ID", width: "60px") %>
<% table.with_column(key: :name, label: "Full Name", width: "180px") %>
<% table.with_column(key: :email, label: "Email Address", width: "220px") %>
<% table.with_column(key: :department, label: "Department", width: "150px") %>
<% table.with_column(key: :role, label: "Job Title", width: "180px") %>
<% table.with_column(key: :location, label: "Office Location", width: "150px") %>
<% table.with_column(key: :hire_date, label: "Hire Date", width: "120px") %>
<% table.with_column(key: :status, label: "Status", width: "100px") %>
...
<% end %>
# responsive_mode options:
# - :scroll (default) - Horizontal scroll when table is too wide
# - :cards - Transform to card layout on mobile
# - :stack - Transform to stacked layout on mobile
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, validation: false) %>
</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 %>
<%= rui_dropdown(text: "Role", size: :sm, variant: :outline, chevron: true) do |d| %>
<% d.with_item(text: "All Roles") %>
<% d.with_item(text: "Admin") %>
<% d.with_item(text: "Editor") %>
<% end %>
</div>
</div>
<%# Results Table - wrap in Turbo Frame for live filtering %>
<%= turbo_frame_tag "users_table" do %>
<%= rui_table(striped: true, hoverable: true) do |table| %>
...
<% 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 |
# Table without header row
<%= 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 %>
# Note: 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 |
<%# Add shadow and ring to the wrapper %>
<%= rui_table(
class: "shadow-lg ring-1 ring-zinc-900/5",
bordered: true,
shape: :rounded
) do |table| %>
...
<% end %>
<%# Add custom data attributes %>
<%= rui_table(
id: "my-table",
data: { controller: "my-custom-table" }
) do |table| %>
...
<% 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
<%= rui_table(
sortable: true,
selectable: true,
sort_column: "name",
sort_direction: "asc"
) do |table| %>
<!-- Screen-reader only caption -->
<% table.with_caption { "List of team members with names, roles, and contact information" } %>
<% table.with_column(key: :name, label: "Name", sortable: true) %>
<% table.with_column(key: :role, label: "Role") %>
<% table.with_column(key: :email, label: "Email") %>
<% @users.each do |user| %>
<% table.with_row(id: user.id) do |row| %>
<% row.with_cell { user.name } %>
<% row.with_cell { user.role } %>
<% row.with_cell { user.email } %>
<% end %>
<% end %>
<% 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
Pagy integration
| Parameter | Type | Default | Description |
|---|---|---|---|
| pagy | Pagy | — | Pagy instance for pagination info |
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' } |