Skip to main content

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.

Basic table

Pass a collection and an array of column hashes. The first column is automatically treated as the primary (<th scope="row">) column.

Users
NameEmail
Alice Johnsonalice@example.com
Bob Smithbob@example.com
Carol Williamscarol@example.com
David Browndavid@example.com
Eve Daviseve@example.com
Frank Millerfrank@example.com
Grace Leegrace@example.com
Henry Wilsonhenry@example.com
Ivy Chenivy@example.com
Jack Taylorjack@example.com
<%= l_ui_table(@users, columns: [
      { attribute: :name, render: ->(r) { r.name } },
      { attribute: :email, render: ->(r) { r.email } },
    ],
    caption: "Users") %>

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".

Posts
TitleSummaryCreated
Authentication with DeviseThis is the body of "Authentication with Devise". It cove...11 Apr 2026, 10:28
Background jobs with Solid QueueThis is the body of "Background jobs with Solid Queue". I...11 Apr 2026, 10:28
CSS architecture patternsThis is the body of "CSS architecture patterns". It cover...11 Apr 2026, 10:28
Deploying to productionThis is the body of "Deploying to production". It covers ...11 Apr 2026, 10:28
Getting started with RailsThis is the body of "Getting started with Rails". It cove...11 Apr 2026, 10:28
Importmap and modern JSThis is the body of "Importmap and modern JS". It covers ...11 Apr 2026, 10:28
Pagination with PagyThis is the body of "Pagination with Pagy". It covers imp...11 Apr 2026, 10:28
Search with RansackThis is the body of "Search with Ransack". It covers impo...11 Apr 2026, 10:28
Stimulus controllersThis is the body of "Stimulus controllers". It covers imp...11 Apr 2026, 10:28
Testing best practicesThis is the body of "Testing best practices". It covers i...11 Apr 2026, 10:28
<%= l_ui_table(@posts, columns: [
      { attribute: :title,
        render: ->(r) { link_to r.title, post_path(r) } },
      { attribute: :body, label: "Summary",
        render: ->(r) { truncate(r.body, length: 60) } },
      { attribute: :created_at, label: "Created",
        render: ->(r) { l_ui_format_datetime(r.created_at) } },
    ],
    caption: "Posts") %>

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.

Posts with partial rendering
TitleCreated
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
<%= l_ui_table(@posts, columns: [
      { attribute: :title,
        render: ->(r) { render("posts/title_cell", post: r) } },
      { attribute: :created_at, label: "Created",
        render: ->(r) { l_ui_format_datetime(r.created_at) } },
    ],
    caption: "Posts with partial rendering") %>

posts/_title_cell.html.erb

<%= link_to post.title, "#" %>
<span class="text-sm text-foreground-muted"><%= post.user.name %></span>

Actions column

Pass an actions: proc to add a right-aligned actions column. The actions_label: option sets the header text (defaults to "Actions").

Users with actions
NameEmailActions
Alice Johnsonalice@example.comEdit
Bob Smithbob@example.comEdit
Carol Williamscarol@example.comEdit
David Browndavid@example.comEdit
Eve Daviseve@example.comEdit
<%= l_ui_table(@users, columns: [
      { attribute: :name, render: ->(r) { r.name } },
      { attribute: :email, render: ->(r) { r.email } },
    ],
    actions: ->(r) { link_to("Edit", edit_user_path(r), class: "l-ui-button l-ui-button--outline l-ui-button--small") },
    caption: "Users with actions") %>

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.

Keyboard shortcuts
ShortcutAction
Ctrl+SSave
Ctrl+ZUndo
Ctrl+Shift+PCommand palette
<% shortcuts = [
  { key: "Ctrl+S", action: "Save" },
  { key: "Ctrl+Z", action: "Undo" },
] %>

<%= l_ui_table(shortcuts, columns: [
      { attribute: :key, label: "Shortcut",
        render: ->(r) { tag.kbd(r[:key]) } },
      { attribute: :action,
        render: ->(r) { r[:action] } },
    ],
    caption: "Keyboard shortcuts") %>

Empty state

When the collection is empty, the table renders a single "No records found." row spanning all columns.

Empty table
NameEmail
No records found.
<%= l_ui_table([], columns: [
      { attribute: :name, render: ->(r) { r.name } },
      { attribute: :email, render: ->(r) { r.email } },
    ],
    caption: "Empty table") %>

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.

Users with turbo-targetable rows
NameEmail
Alice Johnsonalice@example.com
Bob Smithbob@example.com
Carol Williamscarol@example.com
<%# Each <tr> gets id="user_42" automatically. %>
<%= l_ui_table(@users, columns: [
      { attribute: :name, render: ->(r) { r.name } },
      { attribute: :email, render: ->(r) { r.email } },
    ]) %>

<%# Replace one row from a controller: %>
turbo_stream.replace dom_id(@user),
  partial: "shared/user_row", locals: { user: @user }

<%# Custom row id: %>
<%= l_ui_table(@users, columns: [...],
    row_id: ->(r) { "user-row-#{r.id}" }) %>

Options

l_ui_table 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")
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

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