Row Detail Drawer

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.

Data TablesInspired by Stripe
Live PreviewInteractive
Stripe · Payments
IDCustomerAmountStatus
txn_001Sarah Chen$420.00Paid
txn_002Marcus Kim$89.00Paid
txn_003Lena Park$420.00Pending
txn_004Alex Liu$89.00Refunded
txn_005Priya Mehta$420.00Paid
txn_001

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

RowDetailDrawer.tsx
// 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