Modern Data Table UX: Virtualization, Column Resizing, and Multi-Level Sorting
Enterprise software lives and dies by its tables. Master the UX and frontend engineering of dense data grids with DOM virtualization, pinned columns, accessible keyboard navigation, and responsive card transformations.
The Enterprise Reality: Why Tables Are Still the Workhorse of B2B SaaS
While consumer applications favor visual cards and feed-like scrolling, B2B SaaS users—finance analysts, logistics dispatchers, CRM administrators, and DevOps engineers—demand high-density data tables. They need to scan hundreds of records, compare metrics across adjacent columns, perform batch actions, and filter datasets with surgical precision.
Yet, building a high-performance data table is notoriously difficult. Rendering 10,000 DOM nodes simultaneously causes browser frames to drop below 10 FPS, while sloppy column layouts create horizontal scroll fatigue and unreadable truncated text.
Creating an elite data table requires harmonizing ergonomics (predictable sorting, column pinning, intuitive resizing) with performance engineering (DOM windowing, virtual scrolling, and memory disposal).
In this guide, we explore the interaction design principles and technical architecture needed for modern enterprise data tables.
Virtual Scrolling Architecture with TanStack Virtual
Virtualization calculates the viewport height, determines which rows are currently visible within the scroll window, and renders only those rows (plus a small overscan buffer of 3-5 rows).
This ensures that whether your dataset contains 100 rows or 1,000,000 rows, the browser DOM remains lightweight, memory usage stays constant at under 25MB, and scrolling runs at a smooth 60 FPS.
import React, { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
interface TableRow {
id: string;
customer: string;
status: "active" | "pending" | "canceled";
revenue: number;
}
export function VirtualTable({ data }: { data: TableRow[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // 48px row height
overscan: 5,
});
return (
<div ref={parentRef} className="h-[500px] overflow-auto border border-border rounded-xl">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const item = data[virtualRow.index];
return (
<div
key={item.id}
className="absolute left-0 top-0 flex w-full items-center border-b border-border/40 px-4 hover:bg-surface-2 transition-colors"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="w-1/4 font-mono text-xs text-muted-foreground">{item.id}</div>
<div className="w-1/2 text-sm font-medium text-foreground">{item.customer}</div>
<div className="w-1/4 text-right font-mono text-sm">
${item.revenue.toLocaleString()}
</div>
</div>
);
})}
</div>
</div>
);
}Column Resizing and Horizontal Pinning (Sticky Columns)
Users frequently work with wide tables containing 20+ columns. In these scenarios, identifying keys (such as Customer Name or Transaction ID) must remain visible while users scroll horizontally across supplementary metrics.
Implement sticky column pinning using CSS position: sticky and calculate left offsets based on the cumulative width of preceding pinned columns.
Provide visible column resize handles that update widths via pointer capture events, saving user layout preferences to localStorage or server profiles.
- Pin key identifiers to the left with sticky positioning and subtle right-edge shadow borders
- Pin action buttons (Edit, Delete, More) to the far right with a sticky left shadow
- Double-click resize handles to automatically expand columns to fit their longest visible text value
- Set sensible min-width constraints (e.g., min-width: 100px) to prevent columns from collapsing into zero pixels
Multi-Level Sorting and Visual Indicators
Simple sorting toggles between Ascending and Descending. Enterprise workflows often require multi-column sorting: sorting first by 'Region', then by 'Customer Tier', and finally by 'Revenue'.
Indicate sort priority clearly with numbered badges next to sort arrows (e.g., '1 ▲', '2 ▼'). Allow users to hold the Shift key while clicking headers to chain multiple sorting criteria without resetting existing filters.
import { ArrowUpIcon, ArrowDownIcon } from "lucide-react";
interface SortState {
columnId: string;
direction: "asc" | "desc";
}
export function SortableHeader({
title,
columnId,
sortIndex, // 0-based priority index if active in multi-sort
direction,
onSort,
}: {
title: string;
columnId: string;
sortIndex: number | null;
direction: "asc" | "desc" | null;
onSort: (e: React.MouseEvent) => void;
}) {
return (
<button
onClick={onSort}
className="flex items-center gap-1.5 py-3 text-xs font-semibold text-muted-foreground hover:text-foreground group"
>
<span>{title}</span>
{direction && (
<span className="inline-flex items-center text-signal font-mono text-[10px]">
{direction === "asc" ? <ArrowUpIcon className="size-3" /> : <ArrowDownIcon className="size-3" />}
{sortIndex !== null && sortIndex > 0 && <span className="ml-0.5">{sortIndex + 1}</span>}
</span>
)}
</button>
);
}Accessible Keyboard Navigation (WAI-ARIA Data Grid Pattern)
A compliant data grid should function like a spreadsheet. Users should be able to navigate cells using Arrow keys, press Enter to enter edit mode, press Escape to cancel edits, and press Home/End to jump across rows.
Use role='grid', role='row', and role='gridcell' with proper aria-colindex and aria-rowindex attributes so screen readers announce row counts accurately during virtualized scroll events.
Responsive Transformations: Graceful Card Fallbacks for Mobile
Wide data tables cannot fit onto 375px smartphone screens without aggressive horizontal scrolling. For mobile viewports, transform table rows into structured card summaries.
Use CSS container queries or media queries to toggle between <table> and stacked card layouts, prioritizing primary indicators on the top card row and tucking secondary metrics into expandable disclosure drawers.
How does virtual scrolling handle dynamic variable row heights?
Modern virtualizers (like TanStack Virtual) support dynamic measurement. They estimate initial heights and measure actual DOM heights via ResizeObserver after rendering, adjusting scroll offsets automatically.
What is the best way to handle text truncation in table cells?
Use text-overflow: ellipsis with a max-width, but always provide a tooltip on hover displaying the complete unabbreviated string, and support double-clicking the column header to auto-fit width.
Should tables use pagination or infinite scrolling?
For audit trails, financial ledgers, and reporting tables, pagination is preferred because it gives users deterministic bookmarkable pages. For exploratory feeds and activity logs, virtualized infinite scrolling is ideal.
How should bulk actions (e.g., selecting 100 rows) be presented?
When rows are selected, render a floating action bar at the bottom center of the screen displaying the count ('24 items selected') with primary batch action buttons (Export, Tag, Delete).
How can table preferences (column order and visibility) be persisted across sessions?
Store the column configuration object in localStorage with fallback to user account profile settings in the database for cross-device consistency.