People need to inspect items in a queue without losing their place in the list.
Updated September 10, 2026
Problem
People reviewing a queue lose time and their place when every item opens a separate page.
Solution
Keep the list visible beside the selected item’s details. Clearly mark the selection and let people move through items with the keyboard.
Keep list order stable while someone is working. Offer new arrivals through a clear update control rather than moving the current row unexpectedly.
Fit the detail view to its available width and offer expansion when needed. Preserve both selection and list settings in navigation and shared links where supported.
Use when
People work through comparable items and need to keep track of their position in the list.
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
Both panes have less width than a full page. The list’s order strongly influences what people handle first. Narrow screens need a way to move between list and detail without losing state.
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.
Related
Data table provides the list structure. Detail on demand expands content that needs more room. Ranked list supports priority ordering.
Filter bar narrows the queue. Drill-down opens a separate page when the detail warrants it.
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.
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.
Screenshots are grouped by product and include the capture date.
Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.
Discover / filebeat logs September 10, 2026Elastic demo environment (guest session; no version string exposed)dense · light · desktop-web
The view places a searchable field list beside a large table of documents, with a volume chart above. Search is useful for navigating the hundreds of available fields. The default Summary column combines many fields into each row, making quick comparisons difficult until people choose more focused columns. Pagination and sorting are visible, but the scope of each operation needs to be clear.