Skip to content
KONIGI

Dashboards / Page layout / Two-pane list and detail

5 of 5

Two-pane list and detail

The viewer works through a queue and needs the list and the item at once.

Updated September 10, 2026

Problem

Forty alerts need triaging. Opening each one as a page means forty navigations and forty returns, and after the third the person has lost their place in the list and started over.

Solution

List on one side, selected item on the other, both visible at once. Selecting a row updates the detail pane and nothing else moves.

The pattern is old—mail clients, file browsers, IDEs—and it survives because it fits a specific shape of work exactly: a queue of similar items where the decision about each one is quick and the next one matters as much as the current one. Keeping the list on screen means position is never lost and the remaining volume is always visible, which is itself information when the queue is the thing being managed.

Three properties separate a good implementation from a pane that happens to be split.

Keyboard traversal. Up and down through the list with the detail following is the entire productivity argument. If it takes a mouse, the pattern is worth much less than the space it costs.

Stable list. If the list re-sorts or refreshes while someone is working through it, they lose their place and may skip items entirely. A live queue needs to hold its order until the viewer asks for new items, usually via a “3 new” affordance rather than an automatic insert.

Detail that fits. The right pane is narrower than a full page. Content designed for full width—wide tables, waterfalls, long stack traces—gets cramped, and the escape hatch is usually a full-screen view, which is where this pattern hands over to detail on demand.

Use when

The work is a queue of comparable items, decisions per item are fast, and the viewer’s place in the list matters. Alert triage, error inboxes, code review, moderation.

Don’t use when

Items are rare and consequential enough to deserve a full page each, or the detail genuinely needs the full width. And it fits badly on narrow screens, where the two panes become two screens and the pattern’s whole advantage disappears.

Trade-offs

The split costs horizontal space permanently and both panes are compromised: the list shows fewer columns, the detail shows less. Deep-linking is awkward, because a URL has to encode both the list state and the selection. It encourages processing items in list order rather than by importance, which is fine for a sorted queue and bad for an unsorted one. And on touch, hit targets in a narrow list are hard to size well while keeping density.

Checklist

  • Can the viewer move through the list entirely by keyboard?
  • Does the list hold its order while someone is working, and how do new items arrive?
  • Is the current selection obvious in the list, not only in the detail pane?
  • Does a URL restore both the list state and the selected item?
  • Does the detail pane have enough width for its widest content?
  • Is there a full-screen escape for content that does not fit?
  • Are actions available from the list, or only after selecting?
  • What happens on a narrow screen?
  • Do list hit targets meet minimum sizes without destroying density?
  • Does the viewer always know how many items remain?

Compare

Sentry is the clearest current example in this space: the issue stream on the left, the selected issue on the right, keyboard traversal, and per-row actions so common decisions never need the detail pane at all. Email clients established every convention this pattern uses, and remain the reference for keyboard traversal and for how to handle new items arriving mid-triage. Datadog uses the split for logs and traces, where the detail is a structured record and fits a narrow pane well. Grafana does not offer it as a dashboard layout, because a dashboard is a grid of independent panels rather than a queue, which is a fair statement of where this pattern does and does not apply.

Data table is the list half, and covers sorting and column decisions. Detail on demand is the escape hatch when the pane is too narrow. Ranked list is what the list should be if order is meant to imply priority. Filter bar is how the queue gets narrowed before triage starts. Drill-down is the alternative when the detail deserves its own page.

Two-pane list and detail anatomy A queue on the left with one row selected and a "3 new" affordance holding incoming items out of the list, and the selected item's detail on the right. Arrow keys move down the list and the detail follows. Anatomy 3 new TimeoutError · checkout-api full page 1 2 3 4 1 THE LIST HOLDS STILL New items wait behind an affordance instead of inserting. A queue that re-sorts under someone makes them skip items. 2 SELECTED ROW Selecting updates the right pane and nothing else moves. Position is never lost, and the volume left is always visible. 3 KEYBOARD TRAVERSAL Up and down, detail following. If it takes a mouse, the split isn't worth the width. 4 THE ESCAPE HATCH A waterfall or a long trace gets cramped, and hands over to a full-width view.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Working through a queue with the list and the item visible at once. Keyboard movement through the list is what makes it a queue rather than a pair of panels.

shadcn
npx shadcn@latest add button
Tokens
--card--card-foreground--muted--muted-foreground--border--ring--chart-1
  • ConnectionReset · payments-worker

    8 events · 11m ago

  • TimeoutError · checkout-api

    142 events · 2m ago

  • NullPointerException · catalog-svc

    3 events · 26m ago

  • RateLimitExceeded · search-api

    51 events · 40m ago

  • DeadlineExceeded · notifications

    12 events · 1h ago

TimeoutError · checkout-api

events, last hour

first seen
3d ago
last seen
2m ago
users
412

TimeoutError: request to inventory-svc exceeded 3000ms at fetchStock (checkout/cart.ts:214), retried 2 times, gave up

move the selectionfull page

ListDetail.tsxArrow keys move the selection and the detail follows. New items wait behind a counted chip instead of inserting, and the detail carries a link to the full-width view.

import { useState, type KeyboardEvent } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Sparkline } from "../sparkline/Sparkline";

export type Item = {
  id: string;
  title: string;
  /** The second line in the list: count and age. */
  meta: string;
  /** Events over the window, drawn in the detail pane. */
  events: number[];
  stats: [label: string, value: string][];
  body: string;
  /** The full-width view this pane hands over to when the content is wider
   *  than the pane. */
  href: string;
};

type Props = {
  items: Item[];
  /** Arrived since the list was drawn. Held behind the "n new" chip until
   *  asked for, because a queue that inserts under someone makes them skip. */
  incoming: Item[];
  onReveal: () => void;
  defaultSelectedId?: string;
  onSelect?: (id: string) => void;
};

export function ListDetail({ items, incoming, onReveal, defaultSelectedId, onSelect }: Props) {
  const [selectedId, setSelectedId] = useState(defaultSelectedId ?? items[0]?.id);
  const index = Math.max(0, items.findIndex((i) => i.id === selectedId));
  const item = items[index];
  const select = (i: number) => {
    const next = items[Math.min(items.length - 1, Math.max(0, i))];
    if (!next) return;
    setSelectedId(next.id);
    onSelect?.(next.id);
  };
  // Up and down through the list, detail following. This is the whole
  // productivity argument, so it lives on the list rather than on a shortcut.
  const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
    if (e.key === "ArrowDown") { e.preventDefault(); select(index + 1); }
    if (e.key === "ArrowUp") { e.preventDefault(); select(index - 1); }
  };

  return (
    <div className="grid grid-cols-[0.8fr_1.4fr] gap-4">
      <div className="overflow-hidden rounded-lg border bg-card">
        {incoming.length > 0 && (
          <Button variant="ghost" onClick={onReveal} className="h-auto w-full justify-start gap-2 rounded-none border-b bg-chart-1/10 px-3 py-1.5 text-[11px] text-chart-1 hover:bg-chart-1/20 hover:text-chart-1">
            <span className="size-1.5 rounded-full bg-chart-1" />
            {incoming.length} new
          </Button>
        )}
        <ul className="divide-y outline-none focus-visible:ring-1 focus-visible:ring-ring" tabIndex={0} role="listbox" aria-activedescendant={item?.id} onKeyDown={onKeyDown}>
          {items.map((i, n) => (
            <li
              key={i.id}
              id={i.id}
              role="option"
              aria-selected={n === index}
              onClick={() => select(n)}
              className={cn("cursor-pointer px-3 py-2 text-[11px]", n === index ? "bg-muted/60 text-card-foreground" : "text-muted-foreground")}
            >
              <p className="truncate">{i.title}</p>
              <p className="truncate text-[10px] text-muted-foreground">{i.meta}</p>
            </li>
          ))}
        </ul>
      </div>

      {item && (
        <div className="rounded-lg border bg-muted p-4">
          <p className="border-b pb-2 text-sm text-card-foreground">{item.title}</p>
          <div className="mt-3 grid grid-cols-2 gap-3">
            <div className="rounded border bg-card p-2 text-muted-foreground">
              <p className="text-[10px]">events, last hour</p>
              <Sparkline values={item.events} width={150} height={24} />
            </div>
            <dl className="rounded border bg-card p-2 text-[10px]">
              {item.stats.map(([label, value]) => (
                <div key={label} className="flex justify-between gap-2">
                  <dt className="text-muted-foreground">{label}</dt>
                  <dd className="tabular-nums text-card-foreground">{value}</dd>
                </div>
              ))}
            </dl>
          </div>
          <p className="mt-3 font-mono text-[10px] leading-relaxed text-card-foreground">{item.body}</p>
          <div className="mt-4 flex items-center gap-3 border-t pt-2.5 text-[10px] text-muted-foreground">
            <kbd className="rounded border px-1.5 py-0.5">↑</kbd><kbd className="rounded border px-1.5 py-0.5">↓</kbd>
            <span>move the selection</span>
            <a className="ml-auto rounded border px-2 py-0.5 text-card-foreground" href={item.href}>full page</a>
          </div>
        </div>
      )}
    </div>
  );
}

demo.tsxHow it is called: five issues, the second selected, three more held behind the chip.

import { useState } from "react";
import { ListDetail, type Item } from "./ListDetail";

const alert = (id: string, title: string, meta: string, events: number[], users: number, body: string): Item => ({
  id, title, meta, events, body, href: `/issues/${id}`,
  stats: [["first seen", "3d ago"], ["last seen", meta.split(" · ")[1]], ["users", String(users)]],
});

const QUEUE: Item[] = [
  alert("i-4182", "ConnectionReset · payments-worker", "8 events · 11m ago", [1, 0, 2, 1, 3, 1], 6, "ConnectionResetError: [Errno 104] Connection reset by peer at psycopg2.connect (worker.py:88)"),
  alert("i-4183", "TimeoutError · checkout-api", "142 events · 2m ago", [6, 9, 8, 19, 24, 31, 45], 412, "TimeoutError: request to inventory-svc exceeded 3000ms at fetchStock (checkout/cart.ts:214), retried 2 times, gave up"),
  alert("i-4179", "NullPointerException · catalog-svc", "3 events · 26m ago", [1, 1, 0, 0, 1, 0], 3, "java.lang.NullPointerException: product.variants is null at CatalogMapper.toDto (CatalogMapper.java:57)"),
  alert("i-4175", "RateLimitExceeded · search-api", "51 events · 40m ago", [12, 14, 9, 8, 5, 3], 51, "RateLimitExceeded: 429 from provider after 1,000 req/min at SearchClient.query (search.ts:41)"),
  alert("i-4171", "DeadlineExceeded · notifications", "12 events · 1h ago", [4, 3, 2, 2, 1, 0], 12, "DeadlineExceeded: push delivery took 12.4s, budget 10s at Dispatcher.send (dispatch.go:133)"),
];

const INCOMING: Item[] = [
  alert("i-4184", "TimeoutError · checkout-api", "9 events · 1m ago", [2, 3, 4], 9, "TimeoutError: request to pricing-svc exceeded 3000ms at fetchQuote (checkout/quote.ts:72)"),
  alert("i-4185", "ECONNREFUSED · image-resizer", "2 events · 1m ago", [1, 1], 2, "Error: connect ECONNREFUSED 10.0.4.12:9000 at TCPConnectWrap.afterConnect (net.js:1148)"),
  alert("i-4186", "ValidationError · signup-api", "1 event · now", [1], 1, "ValidationError: email must be a valid address at SignupSchema.parse (signup.ts:19)"),
];

/**
 * Five issues with the second selected and three more waiting behind the
 * chip. Arrow keys walk the list once it has focus; the chip merges the
 * three in at the top, and only then.
 */
export default function Demo() {
  const [items, setItems] = useState(QUEUE);
  const [incoming, setIncoming] = useState(INCOMING);
  const reveal = () => {
    setItems([...incoming, ...items]);
    setIncoming([]);
  };
  return <ListDetail items={items} incoming={incoming} onReveal={reveal} defaultSelectedId="i-4183" />;
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Kibana

Query-first rather than panel-first: the search bar is the primary control and the charts are downstream of it, which inverts Grafana's arrangement.

Kibana — Discover / filebeat logs
Two-pane list and detail. Fields on the left, records on the right, both on screen at once. The oldest working shape for a queue there is. Data table. One column called Summary holding every field on the record. Complete, and unreadable at a glance. Histogram and distribution. Volume over the window at a 30-second auto interval. The final bar is the bucket still filling, and it always reads low. Filter bar. A query language in the bar rather than chips. Powerful, and it hides what's applied from anyone who didn't type it. Detail on demand. An expander on every row. Inline rather than a side panel, so it pushes the rest of the list down. Time-range picker. Relative by default, with the refresh control beside it rather than buried in a settings menu. Search across panels. 315 fields, so the sidebar opens with a filter box. Past a certain count a tree is a filing system nobody browses.
Discover / filebeat logs September 10, 2026 Elastic demo environment (guest session; no version string exposed) dense · light · desktop-web
Two panes: 315 fields down the left, 13,637 documents on the right, and a volume histogram over both. The left pane is the good half—it opens with a search box rather than a tree, which is the only sane way to navigate that many fields. The right pane is where it falls over. The documents table ships with two columns, a timestamp and "Summary", and Summary is every field on the record concatenated into one cell: agent.ephemeral_id, agent.id, agent.name, agent.type, agent.version, cloud.account.id, cloud.availability_zone, and on for three wrapped lines per row. It is technically complete and it cannot be scanned, so the first thing anyone does here is pick columns—which is to say the default view's job is to make you configure it. Underneath, the pager reads 100 rows per page across 137 pages, and the sort control sits above a table showing the first of them.
  • Two-pane list and detail Fields on the left, records on the right, both on screen at once. The oldest working shape for a queue there is.
  • Detail on demand An expander on every row. Inline rather than a side panel, so it pushes the rest of the list down.
  • Data table One column called Summary holding every field on the record. Complete, and unreadable at a glance.
  • Search across panels 315 fields, so the sidebar opens with a filter box. Past a certain count a tree is a filing system nobody browses.
  • Filter bar A query language in the bar rather than chips. Powerful, and it hides what's applied from anyone who didn't type it.
  • Time-range picker Relative by default, with the refresh control beside it rather than buried in a settings menu.
  • Histogram and distribution Volume over the window at a 30-second auto interval. The final bar is the bucket still filling, and it always reads low.