---
title: Progressive Disclosure for Dense Detail Pages
slug: progressive-disclosure-detail-page
category: Navigation
summary: A layout method for record pages that have too much to show — name the one job, cap the glance layer at five elements, put everything else behind tabs and collapsed rows, and spend colour only on "a human must act".
tags: [ux-default, layout, progressive-disclosure, cognitive-load, tabs, details, admin, design]
status: stable
layers: [view, controller]
related:
  - title: Filterable Index with Slide-Out Detail Drawer
    url: /cookbook/filterable-index-with-detail-drawer
    summary: The index-side sibling. That guide keeps a TABLE readable by hiding columns; this one keeps a DETAIL PAGE readable by layering it.
  - title: Cmd+K Global Search
    url: /cookbook/global-search-command-palette
    summary: When a page has grown past layering, search is the next escape hatch.
---

# Progressive Disclosure for Dense Detail Pages

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

A record page starts clean and rots by accretion. The model has twenty fields, so twenty
fields go on the page. Each one felt important while it was being built, so each got a
coloured badge. Nothing on the page is *wrong*, and the page is still unusable: with
everything shouting, the eye has no entry point, so a person has to read **everything**
to find **anything**.

This guide is the fix. It is a layout method plus the Rails/Tailwind code to implement
it: **name the one job the page serves, cap what's visible at a glance, and push the
rest behind one click.** The result reads in three seconds instead of thirty, and it
stops re-rotting, because the layer budget makes every future addition pay for itself.

> **When to use:** any show/detail page with more than ~8 facts — a record plus its
> stream of related items (messages, comments, events, line items, activity).
> **When not to:** a page with one job and five fields. Don't add tabs to a login form.

---

## The 80/20 in one breath

1. **Write the Job Sentence.** One sentence: *"A `<who>` doing `<what>` needs to decide
   `<one thing>`."* If it needs "and also", that's a second tab.
2. **Name the 3-Second Question** — what must be answerable with no scrolling and no
   clicking. Exactly one per page.
3. **Cap layer 1 at five elements.** Everything that doesn't answer the 3-Second
   Question drops to layer 2 or lower.
4. **Split the second job into a tab** (`?tab=`, server-rendered — no JS).
5. **Collapse the stream** to one line per item; expand only the newest.
6. **Spend colour on one meaning only:** amber = a human must act. Everything else grey.

The rule that makes it stick: **to promote a sixth thing to layer 1, you must demote
something.** A fixed budget is what stops the page from re-accumulating.

---

## Layer 1 — Write the layer map into the view

Put the map in the template as a comment. It is what a reviewer checks the design
against, and it's the first thing the next agent reads before adding a field.

```erb
<%# app/views/tickets/show.html.erb %>
<%# JOB:     an operator triaging a queue decides "does this need me, and what do I say?"
    3-SEC Q: does this need me right now, and what did they say?
    LAYERS:  1 = who · ONE state badge · newest message · the primary action
             2 = older messages, one line each
             3 = full bodies (click)
             4 = internal notes (separate TAB), related records (links out)

    COLOUR BUDGET: amber = a human must act. blue = interactive. slate = everything
    else. A hue never means two things. Don't add a fifth. %>
```

---

## Layer 2 — Tabs without JavaScript

Two jobs on one record = two tabs, not two stacked sections. Server-rendered via a query
param, so every tab is a linkable URL and there is no JS to load, break, or test.

```ruby
# app/controllers/tickets_controller.rb
def show
  @ticket  = Ticket.find(params[:id])
  @messages = @ticket.messages.chronological

  # Default to the tab that answers the 3-Second Question, not the one you built first.
  @tab = params[:tab].presence_in(%w[activity notes]) || "activity"
end
```

```erb
<%# app/views/tickets/show.html.erb %>
<div class="flex items-center gap-1 border-b border-slate-200 mb-6">
  <% [["activity", "Activity", @messages.size], ["notes", "Notes", nil]].each do |key, label, count| %>
    <%= link_to ticket_path(@ticket, tab: key),
          class: "px-4 py-2 text-sm font-medium border-b-2 -mb-px #{@tab == key ?
                  'border-slate-900 text-slate-900' :
                  'border-transparent text-slate-500 hover:text-slate-800'}" do %>
      <%= label %><% if count %> <span class="text-slate-400 font-normal"><%= count %></span><% end %>
    <% end %>
  <% end %>
</div>
```

The active tab is marked with **weight and a dark underline**, not a colour — colour is
reserved (see below).

---

## Layer 3 — One state badge, resolved in the model

The most common way a page turns to soup is a badge per fact. Six badges is the same as
none. Give the record **one** headline state, resolved in priority order, and put the
rest on the secondary tab.

```ruby
# app/models/ticket.rb
# THE state of this record, as one label — deliberately singular.
# `tone` is :attention (amber — a human must act) or :calm (grey). Nothing else.
def state_summary
  if closed?
    { label: "Closed", tone: :calm }
  elsif draft_reply?
    { label: "Draft not sent", tone: :attention }
  elsif awaiting_us?
    { label: "Waiting #{ActionController::Base.helpers.time_ago_in_words(last_inbound_at)}",
      tone: :attention }
  else
    { label: "Answered", tone: :calm }
  end
end
```

```erb
<%# app/views/tickets/show.html.erb — sticky context bar, the ONLY place state appears %>
<% state = @ticket.state_summary %>
<div class="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
  <div class="max-w-5xl mx-auto px-4 py-2.5 flex items-center gap-3">
    <%= link_to tickets_path, class: "text-slate-400 hover:text-slate-700 shrink-0" do %>
      <i class="fas fa-arrow-left"></i>
    <% end %>
    <span class="font-semibold text-slate-900 truncate"><%= @ticket.title %></span>
    <span class="ml-auto shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold
                 <%= state[:tone] == :attention ? "bg-amber-500 text-white" : "bg-slate-100 text-slate-600" %>">
      <%= state[:label] %>
    </span>
  </div>
</div>
```

A sticky bar matters more than it looks: on a long stream the title scrolls away in one
flick, and "which record am I in, and does it need me?" is exactly what gets lost.

---

## Layer 4 — Collapse the stream, expand only what's needed

Every item is a `<details>`. Open on load **only** what the reader came for: the newest
item, plus anything needing action. Pure HTML — no Stimulus controller.

```erb
<%# app/views/tickets/_message.html.erb %>
<% newest = @messages.last %>
<% expand_all = @messages.size <= 2 %>

<style>
  .msg > summary { list-style: none; cursor: pointer; }
  .msg > summary::-webkit-details-marker { display: none; }
  .msg[open] > summary .msg-preview { display: none; }
  .msg[open] > summary .msg-chevron { transform: rotate(90deg); }
  .msg-chevron { transition: transform .15s ease; }
</style>

<% @messages.each do |message| %>
  <%# OURS is indented with a left rule; THEIRS is flush. That indentation IS the role
      cue — see the colour rule below. %>
  <details id="msg-<%= message.id %>"
           class="msg scroll-mt-28 rounded-lg border border-slate-200 bg-white
                  <%= "ml-8 border-l-2 border-l-slate-300" if message.from_us? %>"
           <%= "open" if expand_all || message == newest %>>
    <summary class="px-4 py-2.5 hover:bg-slate-50/70 rounded-lg">
      <div class="flex items-baseline gap-2">
        <i class="fas fa-chevron-right msg-chevron text-[10px] text-slate-300 shrink-0"></i>
        <span class="text-sm font-medium text-slate-900 shrink-0"><%= message.author_name %></span>
        <p class="msg-preview text-xs text-slate-400 truncate min-w-0">
          <%= truncate(message.body.to_s.squish, length: 90) %>
        </p>
        <span class="ml-auto text-xs text-slate-400 whitespace-nowrap shrink-0">
          <%= message.created_at.strftime("%b %-d") %>
        </span>
      </div>
    </summary>

    <div class="px-4 pb-4 pl-10">
      <div class="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed break-words">
        <%= message.body %>
      </div>
    </div>
  </details>
<% end %>
```

**Every collapsed row must show a preview.** A row that says only "Message" is a locked
door; a row showing the first 90 characters is a door with a window. Hiding content with
no visible cue is the classic way progressive disclosure goes wrong.

### Jumping to a collapsed row

An anchor link to a closed `<details>` lands on the summary and looks broken. CSS cannot
force `open`, so this is the one place JS earns its keep:

```erb
<%# app/views/tickets/show.html.erb %>
<script>
  (function () {
    function openTarget() {
      var hash = window.location.hash;
      if (!hash || hash.length < 2) return;
      var el = document.getElementById(hash.slice(1));
      if (el && el.tagName === "DETAILS") { el.open = true; el.scrollIntoView({ block: "start" }); }
    }
    window.addEventListener("hashchange", openTarget);
    document.addEventListener("turbo:load", openTarget);   // Turbo swaps the body...
    document.addEventListener("DOMContentLoaded", openTarget); // ...so this alone isn't enough
  })();
</script>
```

---

## The colour budget

Pick at most **three semantic colours plus neutrals**, write down what each means, then
never use it for anything else.

| Colour | Means | Use for |
|---|---|---|
| **Amber** | a human must act | overdue, waiting, unsent, needs review |
| **Red** | broken / destructive | errors, delete |
| **Blue** | interactive | links and buttons **only** — never status |
| **Slate** | everything informational | all other text, chips, borders |

Two rules that do the heavy lifting:

- **Roles are structural; colour is exceptional.** "Theirs vs ours", "parent vs child",
  "sent vs received" are *roles*, not alerts. Show them with **indentation, position, or
  a subtle left border** — like a chat transcript. Tinting one side blue and another
  purple is what turns a page into confetti, and it burns the colours you need for real
  alerts.
- **One saturated element per screen** — the primary action. If two things are the
  brightest thing on the page, neither is.

```erb
<%# The single saturated element: the one thing the operator came here to do. %>
<%= link_to "Reply", reply_ticket_path(@ticket),
      class: "inline-flex items-center px-4 py-2 rounded-md bg-blue-600 text-white
              text-sm font-medium hover:bg-blue-700" %>
```

---

## Ship checks

Run these before calling it done:

1. **Squint test.** Blur your eyes, or zoom the browser to 25%. Can you still tell where
   to look first? Uniform grey mush = no hierarchy. Confetti = too many colours.
2. **5-second test.** Show it to someone for five seconds, hide it, ask what the page is
   for and what needs doing. If they can't say, layer 1 is wrong.
3. **Subtraction pass.** Name what you *removed or demoted* this round. If that list is
   empty, you didn't design — you accumulated.

---

## Gotchas

- **Empty fields must be omitted, not rendered as "—".** A column of em-dashes is pure
  noise that costs a read each. `sections.select { |_, v| v.present? }` before rendering.
- **Demote, don't duplicate.** After moving a block to a tab, assert it is *gone* from
  the first tab. It's easy to "move" something and leave the original behind, which
  doubles the page instead of halving it.
- **Don't repeat the state badge.** Once it's in the sticky bar, delete it from the
  sidebar, the section header, and the row. Duplicated status is the sneakiest source of
  clutter, because each copy looks reasonable on its own.
- **`<details>` needs its marker killed in CSS**, and `::-webkit-details-marker` is a
  separate rule from `list-style: none`. Skip either and you get a stray triangle.
- **A raw-SQL `order` makes a relation irreversible.** If a stream scope uses
  `order(Arel.sql("sent_at ASC NULLS LAST"))`, then `.last` raises
  `ActiveRecord::IrreversibleOrderError` anywhere in the app. Postgres already sorts
  NULLs last on `ASC`, so prefer plain `order(:sent_at, :id)` for a `has_many` default
  scope.
- **Tabs default to the wrong side by habit.** Default to the tab that answers the
  3-Second Question — usually the customer-facing stream, not the internal notes you
  spent the most time building.
- **Don't invent a new card/badge/header style.** Copy the nearest existing page in your
  app. A familiar layout costs nothing to parse; consistency is itself a cognitive-load
  reducer.
- **Font Awesome isn't guaranteed** on every instance. If `fa-chevron-right` doesn't
  render, swap in an inline SVG or a plain `›`.

---

## Files this pattern touches

```
app/controllers/tickets_controller.rb     # @tab, scoped queries per tab
app/models/ticket.rb                      # state_summary — ONE badge
app/views/tickets/show.html.erb           # layer map comment, sticky bar, tabs
app/views/tickets/_message.html.erb       # collapsed <details> rows
```

## How to adapt to your schema

1. Replace `Ticket`/`Message` with your record and its stream (order, comment, event,
   revision). The pattern needs only "a record + a chronological list of related items".
2. Write the Job Sentence for **your** operator first. Don't copy the ticket one — the
   layer map is the design, and it changes per screen.
3. Rewrite `state_summary` in your own priority order. Keep it returning **one** label
   and a `:attention`/`:calm` tone. Resist adding a third tone.
4. If your record has no second job, **drop the tabs entirely** — a single-purpose page
   shouldn't grow navigation it doesn't need.
5. If your stream is short (≤2 items), the `expand_all` branch already renders everything
   open; the pattern degrades to a plain list on its own.
