Sticky Data Table in React: Scrollable, Sortable, Performant
Build a sticky header data table in React with sortable columns, horizontal scroll, and smooth performance — no bloated library required. Real code included.
Why Most React Table Solutions Are Overkill
Honestly, you don't need TanStack Table for a sticky, sortable data table in most projects. Don't get me wrong — TanStack Table v8 is excellent engineering. But if you're shipping a dashboard with a few hundred rows and four sortable columns, pulling in a 14 kB dependency plus writing three pages of column definitions is a lot of ceremony for what's ultimately a <table> element with some CSS tricks.
The sticky header pattern specifically is one of those things that looks complicated but really comes down to position: sticky, top: 0, and a z-index that you'll spend 20 minutes debugging. That's it. The sorting logic is just array comparisons. You can write this yourself in under 100 lines and own every pixel of the result.
This guide builds a sticky, horizontally scrollable, column-sortable React table from scratch using Tailwind v4.0.2. We'll keep the component reusable, support custom cell renderers, and handle the edge cases that trip people up — like sticky columns clashing with overflow containers.
HTML Structure: The Scroll Container Pattern
Here's the thing: the single biggest mistake people make with sticky table headers is wrapping the whole thing in overflow-auto on the wrong element. If you put overflow: auto on the <table> itself or on <thead>, position: sticky stops working. The scroll container has to be a separate wrapper <div> around the <table>.
The pattern looks like this — a <div> with overflow-auto and a fixed max-height, then a <table> inside it with border-collapse: separate (not collapse, because collapsed borders interact badly with sticky in Safari). The <thead> rows get position: sticky; top: 0. That's the foundation everything else builds on.
For horizontal scroll with frozen first columns, you'll stack two sticky contexts: the <thead> row is sticky vertically, and the first <td>/<th> in each row is sticky horizontally with a fixed left: 0. You need to assign explicit z-index values — the sticky corner cell (top-left) needs the highest index so it stays above both directions of scroll.
Building the StickyTable Component in TypeScript
Let's write the component. We'll type it generically so it works with any row shape, accept a columns definition array, and handle ascending/descending sort state internally. The external API stays minimal — you pass data and columns, you get a table.
import { useState, useMemo } from 'react';
type SortDir = 'asc' | 'desc' | null;
interface ColumnDef<T> {
key: keyof T;
label: string;
sortable?: boolean;
render?: (value: T[keyof T], row: T) => React.ReactNode;
width?: string; // e.g. 'w-48'
}
interface StickyTableProps<T extends object> {
data: T[];
columns: ColumnDef<T>[];
maxHeight?: string; // e.g. 'max-h-[480px]'
frozenFirstCol?: boolean;
}
export function StickyTable<T extends object>({
data,
columns,
maxHeight = 'max-h-[520px]',
frozenFirstCol = false,
}: StickyTableProps<T>) {
const [sortKey, setSortKey] = useState<keyof T | null>(null);
const [sortDir, setSortDir] = useState<SortDir>(null);
const sorted = useMemo(() => {
if (!sortKey || !sortDir) return data;
return [...data].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
function handleSort(key: keyof T) {
if (sortKey !== key) {
setSortKey(key);
setSortDir('asc');
} else if (sortDir === 'asc') {
setSortDir('desc');
} else {
setSortKey(null);
setSortDir(null);
}
}
const thBase =
'sticky top-0 z-20 bg-zinc-900 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-zinc-400 whitespace-nowrap select-none border-b border-zinc-700';
const tdBase =
'px-4 py-3 text-sm text-zinc-200 whitespace-nowrap border-b border-zinc-800';
return (
<div className={`relative overflow-auto rounded-xl ${maxHeight}`}>
<table className="w-full border-separate border-spacing-0">
<thead>
<tr>
{columns.map((col, i) => (
<th
key={String(col.key)}
className={[
thBase,
col.width ?? '',
frozenFirstCol && i === 0
? 'left-0 z-30 after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-zinc-700'
: '',
col.sortable ? 'cursor-pointer hover:text-white' : '',
].join(' ')}
onClick={() => col.sortable && handleSort(col.key)}
>
<span className="inline-flex items-center gap-1.5">
{col.label}
{col.sortable && (
<span className="text-zinc-600">
{sortKey === col.key
? sortDir === 'asc' ? '↑' : '↓'
: '↕'}
</span>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((row, ri) => (
<tr
key={ri}
className="group transition-colors duration-100 hover:bg-zinc-800/60"
>
{columns.map((col, ci) => (
<td
key={String(col.key)}
className={[
tdBase,
frozenFirstCol && ci === 0
? 'sticky left-0 z-10 bg-zinc-900 group-hover:bg-zinc-800/60 after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-zinc-800'
: '',
].join(' ')}
>
{col.render
? col.render(row[col.key], row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}A few things worth calling out. We use border-separate border-spacing-0 on the table — that's mandatory for sticky cells to work correctly across browsers. The after: pseudo-element trick on the frozen column draws a hairline divider without adding an extra DOM node. The group + group-hover pattern on <tr> and the frozen <td> keeps the row highlight synchronized even when the left column has its own background color.
Styling the Sticky Header with Tailwind v4.0.2
Tailwind v4's new CSS-first config makes the sticky header background much cleaner to manage. Instead of hardcoding bg-zinc-900 everywhere, you can define a --table-header-bg custom property in your base layer and reference it. That way, theme toggling just works — swap the variable in your dark/light selectors and the header follows automatically.
The header needs a solid background to cover scrolling rows beneath it. rgba(255,255,255,0.15) backdrop blur looks nice but it's a trap — semi-transparent headers show text bleeding through on scroll, which looks broken. Stick to fully opaque backgrounds for sticky elements unless you're wrapping in a backdrop-filter: blur(12px) container and you've tested it in Safari.
For row zebra striping, Tailwind's odd:bg-zinc-800/30 on <tr> works well. The 8px gap between sections (if you're grouping rows) can be faked with a spacer <tr> that has h-2. Avoid border-spacing-y for this — it interacts oddly with sticky in Firefox 128+.
Sorting Logic and Sort State UX
The three-state sort cycle — none → asc → desc → none — is what users expect from spreadsheet-native tools. First click sorts ascending, second flips it, third clears the sort and restores original order. That's why we keep both sortKey and sortDir as separate state rather than a single combined object.
What about multi-column sort? It's genuinely tricky to communicate in a UI without dedicated sort chips or a sort panel. For most dashboards, single-column sort is sufficient and far easier to understand at a glance. If you need multi-column, consider an explicit "Sort by" dropdown instead of click-on-header — it's clearer and easier to reset.
One thing the component above doesn't handle: sorting of mixed-type columns where values might be null or undefined. You'll want to push nulls to the bottom regardless of sort direction. Add if (av == null) return 1; if (bv == null) return -1; before the comparison and you're covered. Animated tabs use a similar selection-state pattern if you want to see that approach applied to a different UI context.
Performance: Virtualization Isn't Always the Answer
Everyone jumps to virtualization the moment a table has more than 200 rows. And yes, react-virtual or TanStack Virtual is the right call for 10,000+ rows. But for most real-world dashboards? You're rendering 100-500 rows. A clean DOM with simple cells renders in under 2ms on any modern machine. Don't add complexity you don't need yet.
Where performance actually matters is in sorting. Our useMemo on the sorted array prevents re-sorting on every render — only fires when data, sortKey, or sortDir changes. If your data mutates frequently (live WebSocket updates), make sure you're not creating new array references on every tick. Stabilize your data source first.
The render prop on columns is a nice escape hatch, but watch out for inline functions there. Defining render: (val) => <Badge>{val}</Badge> inside the columns prop on every render will cause React to think the column definition changed, which invalidates the useMemo. Define your columns array outside the component or wrap it in useMemo with stable deps.
Horizontal Scroll and Frozen Columns in Practice
Frozen columns are genuinely one of the harder CSS patterns in the browser. The short version: every frozen cell in a column needs the same explicit left value (e.g. left-0 for the first column, left-[192px] for the second if the first is w-48). There's no automatic calculation — you have to know the widths.
Can you compute these widths in JavaScript at runtime? You can, using getBoundingClientRect() on column headers after mount and storing offsets in state. But that adds a layout read on every column resize. For tables with fixed column widths (which is most of them), hardcoding the offsets in your column definition is simpler and avoids the flash of unstyled positioning.
The shadow on the frozen column is a detail that pays off. A box-shadow: inset -8px 0 8px -8px rgba(0,0,0,0.4) on the frozen cell gives a depth cue that tells users there's more content to the right. You can do this with Tailwind's arbitrary value support: shadow-[inset_-8px_0_8px_-8px_rgba(0,0,0,0.4)]. Pair it with a subtle scrollbar style using scrollbar-thin scrollbar-thumb-zinc-700 for a finished look. If you're building a full data-heavy page, combining this table with a bento grid layout for metric cards above it is a solid dashboard pattern.
Putting It Together: Usage Example
Here's a concrete usage example with typed data. This renders a user table with a frozen name column, sortable columns, and a custom badge renderer for the status field:
import { StickyTable } from '@/components/StickyTable';
type User = {
id: number;
name: string;
email: string;
role: string;
status: 'active' | 'inactive' | 'pending';
joined: string;
};
const STATUS_COLORS = {
active: 'bg-emerald-500/20 text-emerald-400',
inactive: 'bg-zinc-600/30 text-zinc-400',
pending: 'bg-amber-500/20 text-amber-400',
};
const columns: ColumnDef<User>[] = [
{ key: 'name', label: 'Name', sortable: true, width: 'w-48' },
{ key: 'email', label: 'Email', sortable: true, width: 'w-64' },
{ key: 'role', label: 'Role', sortable: true, width: 'w-36' },
{
key: 'status',
label: 'Status',
sortable: true,
width: 'w-28',
render: (val) => (
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${
STATUS_COLORS[val as User['status']]
}`}
>
{String(val)}
</span>
),
},
{ key: 'joined', label: 'Joined', sortable: true, width: 'w-36' },
];
export default function UsersPage({ users }: { users: User[] }) {
return (
<div className="p-6">
<h1 className="mb-4 text-xl font-semibold text-white">Users</h1>
<StickyTable
data={users}
columns={columns}
maxHeight="max-h-[600px]"
frozenFirstCol
/>
</div>
);
}That's a production-ready starting point. The column definitions are stable (defined outside the component), the sort state is internal, and the frozen first column with a shadow gives it a polished feel without any third-party table library. Add pagination below the scroll container if you're dealing with server-side data, and you've got a solid data UI.
FAQ
Sticky positioning only works relative to the nearest scrolling ancestor. When you put overflow: auto on the same element as the sticky child (or a parent closer than the viewport), the browser uses that element as the scroll container and sticky snaps to its boundaries — which may be zero height. The scroll container must be a separate wrapper div above the table, not on the table or thead itself.
Each frozen column needs a specific left value equal to the sum of all frozen column widths to its left. If column 1 is 192px wide and column 2 is 128px wide, column 2's frozen cell needs left: 192px and column 3 needs left: 320px. You can compute these programmatically using a running sum over your column definitions, then apply them as inline styles since Tailwind's JIT can't compute dynamic values at build time.
Use border-separate with border-spacing-0 (not border-collapse: collapse). Collapsed borders conflict with sticky positioning in Safari and older Chrome — the header borders don't render correctly when you scroll. border-separate with zero spacing gives you visually identical output but keeps the borders independent, which sticky needs.
Your comparison function receives the raw values from the data array. If numbers are stored as strings (e.g. '1,234' from a formatted API response), you'll need to parse them before comparing: const parse = (v: unknown) => parseFloat(String(v).replace(/,/g, '')) || 0. Run both values through that before the < > === comparison. It's cleaner to fix the data at the source, but the render function approach works too.
Yes. The StickyTable component as written sorts client-side, which is fine when you pass a single page of data (e.g. 50 rows per page from your API). For server-side sorting, remove the internal sortKey/sortDir state and switch to controlled props — lift sort state to the parent, fire an onSortChange callback, and let the parent re-fetch with updated query params. The table just renders whatever data prop it receives.
Add a checkbox column as the first entry in your columns array with a fixed width like w-12. The render function returns a controlled <input type='checkbox'>. Since it's column index 0, it becomes the frozen column when frozenFirstCol is true. You'll manage selection state (a Set<id>) in the parent and pass down checked state + onChange handler via the render closure.