Table helper
The l_ui_table helper renders a fully styled, accessible data table from a collection and column configuration. It handles header cells, row scoping, custom rendering, actions, and empty states. Every table is wrapped in a horizontal scroll hint that fades the clipped edge when the table overflows, so it is obvious it can be scrolled.
Basic table
Pass a collection and an array of column hashes. The first column is automatically treated as the primary (<th scope="row">) column.
| Name | |
|---|---|
| Alice Johnson | alice@example.com |
| Bob Smith | bob@example.com |
| Carol Williams | carol@example.com |
| David Brown | david@example.com |
| Eve Davis | eve@example.com |
| Frank Miller | frank@example.com |
| Grace Lee | grace@example.com |
| Henry Wilson | henry@example.com |
| Ivy Chen | ivy@example.com |
| Jack Taylor | jack@example.com |
Custom cell rendering
Use render: procs to customise how each cell is displayed. You can also set label: to override the column header text. For date/time columns, the l_ui_format_datetime helper formats values as "15 Apr 2026, 10:30".
| Title | Summary | Created |
|---|---|---|
| Authentication with Devise | This is the body of "Authentication with Devise". It cove... | 11 Apr 2026, 10:28 |
| Background jobs with Solid Queue | This is the body of "Background jobs with Solid Queue". I... | 11 Apr 2026, 10:28 |
| CSS architecture patterns | This is the body of "CSS architecture patterns". It cover... | 11 Apr 2026, 10:28 |
| Deploying to production | This is the body of "Deploying to production". It covers ... | 11 Apr 2026, 10:28 |
| Getting started with Rails | This is the body of "Getting started with Rails". It cove... | 11 Apr 2026, 10:28 |
| Importmap and modern JS | This is the body of "Importmap and modern JS". It covers ... | 11 Apr 2026, 10:28 |
| Pagination with Pagy | This is the body of "Pagination with Pagy". It covers imp... | 11 Apr 2026, 10:28 |
| Search with Ransack | This is the body of "Search with Ransack". It covers impo... | 11 Apr 2026, 10:28 |
| Stimulus controllers | This is the body of "Stimulus controllers". It covers imp... | 11 Apr 2026, 10:28 |
| Testing best practices | This is the body of "Testing best practices". It covers i... | 11 Apr 2026, 10:28 |
Complex cell rendering with partials
For cells with complex markup, call render inside the render: proc to delegate to a partial. This keeps your table call clean while allowing rich cell layouts.
| Title | Created |
|---|---|
| Authentication with Devise Henry Wilson | 11 Apr 2026, 10:28 |
| Background jobs with Solid Queue Grace Lee | 11 Apr 2026, 10:28 |
| CSS architecture patterns Karen White | 11 Apr 2026, 10:28 |
| Deploying to production Eve Davis | 11 Apr 2026, 10:28 |
| Getting started with Rails Alice Johnson | 11 Apr 2026, 10:28 |
| Importmap and modern JS Leo Martinez | 11 Apr 2026, 10:28 |
| Pagination with Pagy Jack Taylor | 11 Apr 2026, 10:28 |
| Search with Ransack Ivy Chen | 11 Apr 2026, 10:28 |
| Stimulus controllers David Brown | 11 Apr 2026, 10:28 |
| Testing best practices Frank Miller | 11 Apr 2026, 10:28 |
Actions column
Pass an actions: proc to add a right-aligned actions column. The actions_label: option sets the header text (defaults to "Actions").
Popover menu actions
For several actions per row, return an l_ui_popover from the actions: proc instead of separate buttons. Use l-ui-popover__menu and l-ui-popover__menu-item for the list, and give the icon-only trigger an aria-label.
Floating actions column
Pass floating_actions: true to pin the actions column to the right-hand edge of the scroll container. As the table scrolls horizontally, the actions stay visible and the content passing underneath fades out under their leading edge. Scroll the example below to see it.
Hash data
Collections don't have to be ActiveRecord - arrays of hashes work too. Use render: procs to read hash keys (e.g. r[:key]) just as you would for object attributes.
| Shortcut | Action |
|---|---|
| Ctrl+S | Save |
| Ctrl+Z | Undo |
| Ctrl+Shift+P | Command palette |
Empty state
When the collection is empty, the table renders a single "No records found." row spanning all columns.
| Name | |
|---|---|
| No records found. | |
Turbo-targetable rows
Each <tr> is automatically given id="user_1"-style ids (via dom_id(record)) when records respond to to_key, so individual rows can be replaced from anywhere in the app with turbo_stream.replace dom_id(@user), .... Pass row_id: as a proc to override, or return nil to omit the id.
| Name | |
|---|---|
| Alice Johnson | alice@example.com |
| Bob Smith | bob@example.com |
| Carol Williams | carol@example.com |
Options
| Option | Type | Description |
|---|---|---|
records |
Collection | The collection to render - ActiveRecord relations, arrays of objects, or arrays of hashes (first positional argument) |
columns: |
Array | Array of column hashes (see column options below) |
caption: |
String | Accessible table caption (rendered as screen-reader-only text) |
actions: |
Proc | Receives each record, returns content for the actions cell |
actions_label: |
String | Header text for the actions column (defaults to "Actions") |
floating_actions: |
Boolean | Pins the actions column to the right-hand edge of the scroll container while the table scrolls horizontally (defaults to false) |
query: |
Ransack::Search | Ransack search object to enable sortable column headers |
turbo_frame: |
String | Turbo Frame ID for sort links |
row_id: |
Proc | Receives each record, returns the <tr> id. Defaults to dom_id(record) for ActiveRecord-like records |
Column options
| Option | Type | Description |
|---|---|---|
attribute: |
Symbol | Used for label generation and sort links |
label: |
String | Custom header text (defaults to humanised attribute name) |
primary: |
Boolean | Renders as <th scope="row"> (defaults to first column) |
sortable: |
Boolean | Show sort link when query: is provided (defaults to true) |
render: |
Proc | Required. Receives the record, returns cell content |