A data table where clicking a row opens a slide-over detail drawer from the right. The selected row highlights with an indigo left border. The drawer shows full row details with a header, scrollable field list, and close button. Backdrop click dismisses.
| ID | Customer | Amount | Status | |
|---|---|---|---|---|
| txn_001 | Sarah Chen | $420.00 | Paid | |
| txn_002 | Marcus Kim | $89.00 | Paid | |
| txn_003 | Lena Park | $420.00 | Pending | |
| txn_004 | Alex Liu | $89.00 | Refunded | |
| txn_005 | Priya Mehta | $420.00 | Paid |
Transaction ID
txn_001
Customer
Sarah Chen
Amount
$420.00
Payment Method
Visa ···4242
Status
Paid
Date
Jun 20, 2026
Description
Pro plan · annual subscription
Click any row to open the detail drawer · Backdrop click or × dismisses · Reset closes the drawer
// Dependencies: react ^18, lucide-react
import React, { useState } from 'react';
import { X, ChevronRight } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type Column = { key: string; label: string };
export type Row = Record<string, string | number>;
type Props = {
columns: Column[];
detailColumns?: Column[];
rows: Row[];
initialSelectedId?: string;
renderCell?: (key: string, value: string | number) => React.ReactNode;
};
export function RowDetailDrawer({ columns, detailColumns, rows, initialSelectedId, renderCell }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId ?? null);
const [open, setOpen] = useState(Boolean(initialSelectedId));
const detail = detailColumns ?? columns;
const selectedRow = rows.find((r) => String(r.id) === selectedId);
const openRow = (id: string) => {
setSelectedId(id);
setOpen(true);
};
const close = () => setOpen(false);
return (
<div className="relative w-full flex flex-col h-full">
{/* Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10">
<tr className="border-b border-white/[0.08] bg-[#111]">
{columns.map((col) => (
<th key={col.key} className="px-3 py-2.5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wide whitespace-nowrap">
{col.label}
</th>
))}
<th className="w-8 px-3 py-2.5" />
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isSelected = String(row.id) === selectedId && open;
return (
<tr
key={String(row.id)}
onClick={() => openRow(String(row.id))}
className={cn(
'border-b border-white/[0.04] last:border-0 cursor-pointer transition-colors group',
isSelected ? 'bg-indigo-500/[0.08]' : 'hover:bg-white/[0.02]',
)}
>
{columns.map((col) => (
<td key={col.key} className={cn('px-3 py-2.5 whitespace-nowrap', isSelected && col === columns[0] ? 'border-l-2 border-l-indigo-500' : '')}>
{renderCell ? renderCell(col.key, row[col.key]) : (
<span className="text-[13px] text-zinc-300">{String(row[col.key] ?? '—')}</span>
)}
</td>
))}
<td className="px-3 py-2.5">
<ChevronRight className={cn('w-3.5 h-3.5 transition-colors', isSelected ? 'text-indigo-400' : 'text-zinc-700 group-hover:text-zinc-500')} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Backdrop */}
{open && (
<div
className="fixed inset-0 bg-black/40 z-40"
onClick={close}
/>
)}
{/* Drawer */}
<div
className={cn(
'fixed inset-y-0 right-0 w-80 bg-[#0a0a0a] border-l border-[#1a1a1a] z-50 flex flex-col transition-transform duration-300 ease-in-out',
open ? 'translate-x-0' : 'translate-x-full',
)}
>
{selectedRow && (
<>
<div className="flex items-center justify-between px-4 py-3 border-b border-[#1a1a1a] shrink-0">
<span className="text-sm font-semibold text-white truncate">
{String(selectedRow.id)}
</span>
<button
onClick={close}
className="text-zinc-500 hover:text-zinc-300 transition-colors ml-2 shrink-0"
aria-label="Close drawer"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5">
{detail.map((col) => (
<div key={col.key}>
<p className="text-[10px] font-semibold tracking-widest text-zinc-500 uppercase mb-1">
{col.label}
</p>
<p className="text-sm text-white break-words">
{String(selectedRow[col.key] ?? '—')}
</p>
</div>
))}
</div>
</>
)}
</div>
</div>
);
}
// Usage:
// const COLUMNS = [
// { key: 'customer', label: 'Customer' },
// { key: 'amount', label: 'Amount' },
// { key: 'status', label: 'Status' },
// ];
// const ROWS = [
// { id: 'txn_001', customer: 'Sarah Chen', amount: '$420.00', status: 'Paid' },
// { id: 'txn_002', customer: 'Marcus Kim', amount: '$89.00', status: 'Pending' },
// ];
// <RowDetailDrawer columns={COLUMNS} rows={ROWS} initialSelectedId="txn_001" />Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Sparkline Trend Cards
Linear
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Notification Center
Linear
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
File Upload Dropzone
Vercel
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Row Detail Drawer
Stripe
Status Badge System
Linear
Segmented Control
Notion