Table Row Detail Panel — Master-Detail List with Instant Inspector (JS)
Table Row Detail Panel (Master-Detail) · Tables · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="rdp-wrap">
<div class="rdp-list">
<div class="rdp-bar"><h3>Inbox</h3></div>
<table class="rdp-table" id="rdpTable">
<thead><tr><th>From</th><th>Subject</th><th>Date</th></tr></thead>
<tbody id="rdpBody"></tbody>
</table>
</div>
<div class="rdp-detail" id="rdpDetail">
<div class="rdp-empty" id="rdpEmpty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 4h16v16H4z"/><path d="M4 4l8 8 8-8"/></svg>
<p>Select a row to see its details here.</p>
</div>
<div class="rdp-content" id="rdpContent" hidden></div>
</div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:flex-start;justify-content:center;padding:32px 20px}
.rdp-wrap{background:#fff;border-radius:14px;width:100%;max-width:820px;box-shadow:0 18px 44px rgba(15,23,42,.08);overflow:hidden;border:1px solid #e2e8f0;display:grid;grid-template-columns:1.1fr 1fr;min-height:420px}
@media (max-width:640px){.rdp-wrap{grid-template-columns:1fr}}
.rdp-list{border-right:1px solid #e2e8f0;display:flex;flex-direction:column;min-width:0}
.rdp-bar{padding:14px 16px;border-bottom:1px solid #f1f5f9}
.rdp-bar h3{font-size:14px;font-weight:800;color:#0f172a}
.rdp-table{width:100%;border-collapse:collapse;font-size:12.5px}
.rdp-table th{text-align:left;padding:9px 14px;background:#f8fafc;border-bottom:1px solid #e2e8f0;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#64748b}
.rdp-table td{padding:10px 14px;border-bottom:1px solid #f1f5f9;color:#334155;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0}
.rdp-table tbody tr:hover td{background:#f8fafc}
.rdp-table tbody tr.rdp-selected td{background:#eef2ff}
.rdp-table tbody tr.rdp-selected td:first-child{box-shadow:inset 3px 0 0 #4f46e5}
.rdp-unread td:first-child,.rdp-unread td:nth-child(2){font-weight:800;color:#0f172a}
.rdp-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#4f46e5;margin-right:6px;vertical-align:middle}
.rdp-detail{padding:20px;display:flex;flex-direction:column;min-width:0}
.rdp-empty{margin:auto;text-align:center;color:#cbd5e1;display:flex;flex-direction:column;align-items:center;gap:10px}
.rdp-empty p{font-size:12.5px;color:#94a3b8;max-width:220px}
.rdp-content[hidden]{display:none}
.rdp-content h4{font-size:16px;font-weight:800;color:#0f172a;margin-bottom:4px}
.rdp-meta{font-size:12px;color:#64748b;margin-bottom:14px;display:flex;flex-direction:column;gap:2px}
.rdp-meta b{color:#334155}
.rdp-body{font-size:13px;color:#334155;line-height:1.7;padding-top:14px;border-top:1px solid #f1f5f9}
.rdp-tags{display:flex;gap:6px;margin-top:14px}
.rdp-tag{font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:999px;background:#eef2ff;color:#4338ca}var MESSAGES = [
{ id: 1, from: 'Aisha Khan', email: '[email protected]', subject: 'Q3 roadmap review', date: 'Aug 21', unread: true,
body: 'Hi team — attaching the updated Q3 roadmap ahead of tomorrow\'s review. The main change is moving the billing migration up two weeks so it lands before the conference.',
tags: ['Product', 'Urgent'] },
{ id: 2, from: 'Marco Rossi', email: '[email protected]', subject: 'Design review notes', date: 'Aug 20', unread: true,
body: 'Notes from today\'s design review are in the shared doc. Biggest open question is whether the new table density should be the default for all users or opt-in.',
tags: ['Design'] },
{ id: 3, from: 'Lena Park', email: '[email protected]', subject: 'Re: API v3 migration', date: 'Aug 19', unread: false,
body: 'Migration is on track. All internal services are cut over; external partner notice goes out Friday with a 60-day deprecation window on v2.',
tags: ['Engineering'] },
{ id: 4, from: 'Tom Becker', email: '[email protected]', subject: 'Billing dashboard feedback', date: 'Aug 18', unread: false,
body: 'Support flagged that the billing dashboard\'s export button is easy to miss on mobile. Screenshots attached — might be worth a follow-up ticket.',
tags: ['Support', 'Mobile'] },
{ id: 5, from: 'Priya Nair', email: '[email protected]', subject: 'Onboarding revamp kickoff', date: 'Aug 17', unread: false,
body: 'Kicking off the onboarding revamp next Monday. Please review the current funnel drop-off data before then so we start from shared numbers.',
tags: ['Product'] },
];
var body = document.getElementById('rdpBody');
var contentEl = document.getElementById('rdpContent');
var emptyEl = document.getElementById('rdpEmpty');
var selectedId = null;
function render() {
body.innerHTML = MESSAGES.map(function (m) {
var cls = (m.id === selectedId ? 'rdp-selected ' : '') + (m.unread ? 'rdp-unread' : '');
return '<tr class="' + cls.trim() + '" data-id="' + m.id + '">' +
'<td>' + (m.unread ? '<span class="rdp-dot"></span>' : '') + m.from + '</td>' +
'<td>' + m.subject + '</td><td>' + m.date + '</td></tr>';
}).join('');
}
function showDetail(id) {
var m = MESSAGES.find(function (x) { return x.id === id; });
if (!m) return;
selectedId = id;
m.unread = false; // opening a message marks it read, like a real inbox
render();
emptyEl.hidden = true;
contentEl.hidden = false;
contentEl.innerHTML =
'<h4>' + m.subject + '</h4>' +
'<div class="rdp-meta"><span><b>' + m.from + '</b> <' + m.email + '></span><span>' + m.date + '</span></div>' +
'<div class="rdp-body">' + m.body + '</div>' +
'<div class="rdp-tags">' + m.tags.map(function (t) { return '<span class="rdp-tag">' + t + '</span>'; }).join('') + '</div>';
}
body.addEventListener('click', function (e) {
var tr = e.target.closest('tr[data-id]');
if (!tr) return;
showDetail(+tr.dataset.id);
});
render();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Table Row Detail Panel — A Master List with an Always-Visible Inspector

An email client's message list and reading pane is a specific, well-understood pattern: a compact list on one side, and a detail panel that updates instantly as you click different rows on the other — never expanding inline, never navigating away. This snippet builds that master-detail layout for a table in plain HTML, CSS, and vanilla JavaScript, distinct from an accordion-style expandable row that pushes content into the row itself.
Master-detail, not expand-in-place
An expandable table grows the clicked row taller to reveal its detail inline, pushing every row below it down the page. This pattern does the opposite: the list stays a fixed, compact grid, and a *separate* panel beside it shows the selected row's extended information. Clicking a different row doesn't grow anything — it swaps the panel's content instantly, exactly like clicking between emails in an inbox never resizes the message list.
One click, one source of truth
showDetail(id) looks up the clicked message by id, sets it as selectedId, and rebuilds both the list (to show the selection highlight and clear the unread dot) and the detail panel's content from that single message object. Because both halves of the UI render from the same lookup, the highlighted row in the list and the content in the panel can never disagree about which item is "current."
A real empty state before any selection
Before anything is clicked, the detail panel shows a dedicated empty state — an icon and "Select a row to see its details here" — rather than either a blank panel or a default-selected first row. This matches how a reading pane actually behaves the first time you open a mail client: there's a genuine prompt, not an implicit selection the user didn't make.
Read-state side effect, like a real inbox
Opening a message clears its unread flag and removes its bold styling and dot indicator on the very next render — a small but realistic detail that shows the detail panel isn't just a display surface, it's wired to actually mutate the underlying data the way clicking into an email marks it read.
Compact list columns, expanded detail content
The list intentionally shows only three columns (From, Subject, Date) with text-overflow: ellipsis truncation, while the detail panel shows the full sender email, full body text, and tags — information that would be far too dense to fit in the table itself. This division of "just enough to scan" versus "everything once selected" is the actual reason the master-detail pattern exists, as opposed to just making the table wider. Pair it with a selectable table if you also need multi-row bulk actions alongside single-row inspection, or a table row context menu for right-click actions on the same list.
Step by step
How to Use
- 1Paste HTML, CSS, and JSAn inbox-style list renders on the left with an empty detail panel on the right.
- 2Click a rowThe detail panel instantly fills with that message's full sender, body, and tags.
- 3Click a different rowThe panel content swaps immediately — the list itself never grows or shifts.
- 4Note the unread dotUnread rows are bold with a dot; opening one clears both, mirroring a real inbox.
- 5Check the selection highlightThe currently open row stays visually marked in the list.
- 6Swap in your dataReplace MESSAGES with your own records; showDetail() is generic over any object shape.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
An expandable row grows taller in place to reveal its detail, pushing every row beneath it down the page — the list's layout changes with every click. This pattern keeps the list a fixed grid and shows detail in a completely separate panel beside it, so clicking different rows never resizes or reflows the list itself — exactly like an email client's message list and reading pane.
Both. showDetail() looks up the clicked message by id and both re-renders the panel from it and sets that message's real unread property to false in the underlying MESSAGES array — so the bold styling and dot indicator that reflect "unread" are driven by an actual data mutation, the same way opening an email in a real inbox marks it read in the underlying mail store, not just visually.
The detail panel shows a dedicated empty state with an icon and a "Select a row to see its details here" message — there's no default-selected first row and no blank panel. This matches the real first-load behavior of a mail client's reading pane, which prompts for a selection rather than guessing one.
No — both are rendered from the same selectedId and the same MESSAGES.find() lookup inside showDetail(), so the highlighted row in the list and the content in the panel always refer to the same record. There's no separate state for "what's highlighted" versus "what's displayed."
Keep selectedId in component state, derive the selected record with a find() over your data array, and render the list (with a selected class check) and the detail panel (or its empty state) both from that same state and derived value — the click handler just needs to set the id, and both halves re-render consistently.