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

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

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.

Users with a popover action menu
NameEmailActions
Alice Johnsonalice@example.com
Edit
Bob Smithbob@example.com
Edit
Carol Williamscarol@example.com
Edit
<%= l_ui_table(@users, columns: [
      { attribute: :name, render: ->(r) { r.name } },
      { attribute: :email, render: ->(r) { r.email } },
    ],
    actions: ->(r) {
      l_ui_popover(align: :end) do |p|
        p.trigger(class: "l-ui-button l-ui-button--outline l-ui-button--icon l-ui-button--small",
                  "aria-label" => "Actions for #{r.name}") do
          image_tag "layered_ui/icon_more.svg", alt: "",
            class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true }
        end
        tag.div(class: "l-ui-popover__menu") do
          safe_join([
            link_to("Edit", edit_user_path(r), class: "l-ui-popover__menu-item"),
            tag.hr(class: "l-ui-popover__menu-divider"),
            button_to("Delete", user_path(r), method: :delete,
              class: "l-ui-popover__menu-item l-ui-popover__menu-item--danger")
          ])
        end
      end
    },
    caption: "Users with a popover action menu") %>

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.

Users with a floating actions column
NameEmailCreatedUpdatedActions
Alice Johnsonalice@example.com11 Apr 2026, 10:2811 Apr 2026, 10:28
Edit
Bob Smithbob@example.com11 Apr 2026, 10:2811 Apr 2026, 10:28
Edit
Carol Williamscarol@example.com11 Apr 2026, 10:2811 Apr 2026, 10:28
Edit
<%= l_ui_table(@users, columns: [
      { attribute: :name, render: ->(r) { r.name } },
      { attribute: :email, render: ->(r) { r.email } },
      { attribute: :created_at, label: "Created", render: ->(r) { l_ui_format_datetime(r.created_at) } },
      { attribute: :updated_at, label: "Updated", render: ->(r) { l_ui_format_datetime(r.updated_at) } },
    ],
    actions: ->(r) { ... },
    floating_actions: true,
    caption: "Users with a floating actions column") %>

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

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