Pinned columns, header and totals whose edges shade only while something hides beneath.
Tablesno dependencies
01Preview
Revenue by account, 2026
Scroll sideways| Account | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | Year |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Northwind AnalyticsEnterprise | $18,032 | $19,521 | $19,716 | $19,905 | $20,088 | $21,757 | $21,971 | $22,177 | $24,008 | $24,248 | $24,481 | $24,706 | $260,610 |
| Halcyon LabsGrowth | $6,138 | $6,640 | $6,699 | $7,249 | $7,310 | $7,913 | $8,558 | $8,637 | $9,343 | $9,426 | $10,200 | $10,286 | $98,399 |
| Arcadia FreightGrowth | $4,896 | $4,847 | $5,144 | $5,094 | $5,040 | $5,353 | $5,297 | $5,624 | $5,568 | $5,909 | $5,851 | $5,789 | $64,412 |
| Sable HealthEnterprise | $21,437 | $21,651 | $21,868 | $22,087 | $22,307 | $22,531 | $22,756 | $22,983 | $23,213 | $23,445 | $23,680 | $23,917 | $271,875 |
| Oakline StudioStarter | $917 | $925 | $1,000 | $1,081 | $1,090 | $1,179 | $1,275 | $1,378 | $1,390 | $1,504 | $1,626 | $1,639 | $15,004 |
| Meridian Robotics InternationalEnterprise | $15,759 | $15,594 | $16,548 | $17,555 | $17,362 | $18,429 | $19,553 | $20,738 | $20,520 | $21,777 | $23,101 | $22,847 | $229,783 |
| Cobalt MutualGrowth | $7,497 | $7,204 | $7,420 | $7,132 | $6,849 | $7,060 | $6,781 | $6,988 | $6,714 | $6,916 | $6,647 | $6,383 | $83,591 |
| Juniper & ValeStarter | $1,252 | $1,339 | $1,432 | $1,531 | $1,636 | $1,877 | $2,007 | $2,146 | $2,295 | $2,454 | $2,624 | $2,804 | $23,397 |
| Total | $75,928 | $77,721 | $79,827 | $81,634 | $81,682 | $86,099 | $88,198 | $90,671 | $93,051 | $95,679 | $98,210 | $98,371 | $1,047,071 |
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.
03Usage
import { StickyColumns } from "@/components/ui/sticky-columns";
<StickyColumns
caption="Revenue by account"
rows={accounts}
getRowId={(a) => a.id}
className="max-h-96"
columns={[
{ key: "name", header: "Account", pin: "start", width: 184 },
...months.map((m, i) => ({ key: m, header: m, numeric: true, width: 88, cell: (a) => usd(a.months[i]) })),
{ key: "total", header: "Year", pin: "end", width: 104, numeric: true, cell: (a) => usd(sum(a.months)) },
]}
/>04Source
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cn";
export type StickyColumn<T> = {
key: string;
header: React.ReactNode;
cell?: (row: T) => React.ReactNode;
/** Right-aligned tabular figures. */
numeric?: boolean;
/** Pixel width. Required for pinned columns, since their offsets are sums of widths. */
width?: number;
/** Keep this column in view while the rest scroll sideways. */
pin?: "start" | "end";
/** A cell in the sticky footer row, e.g. a total. */
footer?: (rows: T[]) => React.ReactNode;
className?: string;
};
export type StickyColumnsProps<T> = Omit<React.ComponentProps<"div">, "children"> & {
columns: StickyColumn<T>[];
rows: T[];
getRowId: (row: T) => string;
/** Names the table and its scroll region for assistive tech. */
caption: string;
size?: "sm" | "md";
empty?: React.ReactNode;
};
type Edges = { start: boolean; end: boolean; top: boolean; bottom: boolean };
/**
* Where the scroller is, so pinned edges only cast a shadow when something is actually
* hidden beneath them. At rest a pinned column reads as part of the table.
*/
export function useScrollEdges<E extends HTMLElement>() {
const ref = useRef<E>(null);
const [edges, setEdges] = useState<Edges>({ start: false, end: false, top: false, bottom: false });
const [width, setWidth] = useState<number>();
const measure = (el: HTMLElement) => {
setWidth(el.clientWidth);
// scrollLeft is negative in right-to-left layouts; the magnitude is what matters.
const x = Math.abs(el.scrollLeft);
const next = {
// A pixel of slack: fractional widths leave the scroll position a hair short of its end.
start: x > 1,
end: x + el.clientWidth < el.scrollWidth - 1,
top: el.scrollTop > 1,
bottom: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
};
setEdges((e) => (e.start === next.start && e.end === next.end && e.top === next.top && e.bottom === next.bottom ? e : next));
};
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(() => measure(el));
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
return () => ro.disconnect();
}, []);
return { ref, edges, width, onScroll: (e: React.UIEvent<E>) => measure(e.currentTarget) };
}
export function StickyColumns<T>({ columns, rows, getRowId, caption, size = "md", empty = "Nothing here yet", className, style, onScroll, ...rest }: StickyColumnsProps<T>) {
const { ref, edges, width, onScroll: track } = useScrollEdges<HTMLDivElement>();
const captionId = useId();
// Pinned columns must leave room to scroll. When they'd take more than 60% of the visible width
// (a phone), end pins let go first, then every start pin after the first.
const effective = useMemo(() => {
if (!width) return columns;
const room = width * 0.6;
const pinnedWidth = (cols: StickyColumn<T>[]) => cols.reduce((sum, c) => sum + (c.pin ? (c.width ?? 0) : 0), 0);
let cols = columns;
if (pinnedWidth(cols) > room) cols = cols.map((c) => (c.pin === "end" ? { ...c, pin: undefined } : c));
if (pinnedWidth(cols) > room) {
const first = cols.find((c) => c.pin === "start")?.key;
cols = cols.map((c) => (c.pin === "start" && c.key !== first ? { ...c, pin: undefined } : c));
}
return cols;
}, [columns, width]);
// Offsets for pinned columns: each start column sits after the ones pinned before it,
// each end column before the ones pinned after it.
const layout = useMemo(() => {
const offsets = new Map<string, number>();
let start = 0;
for (const c of effective) if (c.pin === "start") offsets.set(c.key, (start += c.width ?? 0) - (c.width ?? 0));
let end = 0;
for (const c of [...effective].reverse()) if (c.pin === "end") offsets.set(c.key, (end += c.width ?? 0) - (c.width ?? 0));
const lastStart = [...effective].reverse().find((c) => c.pin === "start")?.key;
const firstEnd = effective.find((c) => c.pin === "end")?.key;
const total = effective.reduce((sum, c) => sum + (c.width ?? 120), 0);
return { offsets, lastStart, firstEnd, start, end, total };
}, [effective]);
const hasFooter = effective.some((c) => c.footer);
const h = size === "sm" ? "h-8" : "h-10";
// Shared classes for a cell in a pinned column, whichever row it sits in.
const pinned = (col: StickyColumn<T>) => {
if (!col.pin) return "";
const isEdge = col.key === layout.lastStart || col.key === layout.firstEnd;
return cn(
"sticky",
isEdge &&
cn(
// The edge: a hairline and a soft shadow, one pseudo-element, present only while columns are hidden
// beneath it. Drawn outside the cell so the row dividers never get notched at the corner.
"after:pointer-events-none after:absolute after:inset-y-0 after:w-3 after:opacity-0 after:transition-opacity after:duration-200 after:content-['']",
col.pin === "start"
? "after:left-[calc(100%-1px)] after:border-l after:border-line after:bg-linear-to-r after:from-overlay/60 after:to-transparent in-data-scroll-start:after:opacity-100"
: "after:right-[calc(100%-1px)] after:border-r after:border-line after:bg-linear-to-l after:from-overlay/60 after:to-transparent in-data-scroll-end:after:opacity-100",
),
);
};
const pinStyle = (col: StickyColumn<T>): React.CSSProperties | undefined =>
col.pin === "start" ? { left: layout.offsets.get(col.key) } : col.pin === "end" ? { right: layout.offsets.get(col.key) } : undefined;
return (
<div
ref={ref}
role="region"
aria-labelledby={captionId}
// Focusable so the table can be scrolled sideways from the keyboard.
tabIndex={0}
data-scroll-start={edges.start || undefined}
data-scroll-end={edges.end || undefined}
data-scroll-top={edges.top || undefined}
data-scroll-bottom={edges.bottom || undefined}
onScroll={(e) => {
onScroll?.(e);
track(e);
}}
style={{
// Anything scrolled into view (a focused link, a found word) lands clear of the pinned columns.
scrollPaddingInlineStart: layout.start,
scrollPaddingInlineEnd: layout.end,
...style,
}}
className={cn(
"relative overflow-auto overscroll-contain rounded-xl border border-line bg-frame outline-none",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
className,
)}
{...rest}
>
<table style={{ minWidth: layout.total }} className="w-full table-fixed border-separate border-spacing-0 text-[13px]">
<caption id={captionId} className="sr-only">
{caption}
</caption>
<colgroup>
{effective.map((c) => (
<col key={c.key} style={c.width != null ? { width: c.width } : undefined} />
))}
</colgroup>
<thead>
<tr>
{effective.map((col) => (
<th
key={col.key}
scope="col"
style={pinStyle(col)}
className={cn(
"sticky top-0 whitespace-nowrap border-b border-line bg-frame px-3 text-[12px] font-medium text-fg-3",
size === "sm" ? "h-8" : "h-9",
col.numeric ? "text-right" : "text-left",
// Corners sit above both the header row and the pinned body cells.
col.pin ? "z-[calc(var(--z-sticky)+1)]" : "z-(--z-sticky)",
pinned(col),
// Rows scrolled beneath the header: a short shade under it, drawn per cell so the seams meet cleanly.
"before:pointer-events-none before:absolute before:inset-x-0 before:top-full before:h-2 before:bg-linear-to-b before:from-overlay before:to-transparent before:opacity-0 before:transition-opacity before:duration-200 before:content-[''] in-data-scroll-top:before:opacity-40",
)}
>
<span className="block truncate">{col.header}</span>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={getRowId(row)} className="group/row">
{effective.map((col, i) => {
// The first column names the row, so it is the row's header for screen readers.
const Cell = i === 0 ? "th" : "td";
return (
<Cell
key={col.key}
scope={i === 0 ? "row" : undefined}
style={pinStyle(col)}
className={cn(
"border-b border-line bg-frame px-3 font-normal text-fg transition-colors duration-150 group-hover/row:bg-hover",
!hasFooter && "group-last/row:border-b-0",
h,
col.numeric ? "whitespace-nowrap text-right tabular" : "text-left",
col.pin && "z-[1]",
pinned(col),
col.className,
)}
>
{col.cell ? col.cell(row) : String((row as Record<string, unknown>)[col.key] ?? "")}
</Cell>
);
})}
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={effective.length} className="h-32 p-0">
{/* The empty message stays in view however far the empty table is scrolled. */}
<span className="sticky left-0 flex h-full w-max items-center px-4 text-[13px] text-fg-3">
{empty}
</span>
</td>
</tr>
)}
</tbody>
{hasFooter && rows.length > 0 && (
<tfoot>
<tr>
{effective.map((col) => (
<td
key={col.key}
style={pinStyle(col)}
className={cn(
"sticky bottom-0 border-t border-line bg-frame px-3 font-medium text-fg",
h,
col.numeric ? "whitespace-nowrap text-right tabular" : "text-left",
col.pin ? "z-[calc(var(--z-sticky)+1)]" : "z-(--z-sticky)",
pinned(col),
"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-2 before:bg-linear-to-t before:from-overlay before:to-transparent before:opacity-0 before:transition-opacity before:duration-200 before:content-[''] in-data-scroll-bottom:before:opacity-40",
)}
>
{col.footer?.(rows)}
</td>
))}
</tr>
</tfoot>
)}
</table>
</div>
);
}05Props
StickyColumns
| Prop | Type | Default | Description |
|---|---|---|---|
| columns* | StickyColumn<T>[] | — | key, header, cell, numeric, width, pin, footer and className per column. |
| rows* | T[] | — | The rows. |
| getRowId* | (row: T) => string | — | A stable id per row. |
| caption* | string | — | Names the table and its scroll region. |
| size | "sm" | "md" | "md" | 32px or 40px rows. |
| empty | ReactNode | "Nothing here yet" | Shown, pinned to the left, when there are no rows. |
StickyColumn
| Prop | Type | Default | Description |
|---|---|---|---|
| pin | "start" | "end" | — | Keep the column in view. Several can pin to each side; they stack in order. |
| width | number | — | Pixel width. Required for pinned columns; unpinned columns default to 120 for the table's minimum width. |
| footer | (rows: T[]) => ReactNode | — | A cell in the sticky footer row. The row appears when any column has one. |
| numeric | boolean | false | Right-aligned tabular figures. |
useScrollEdges
| Prop | Type | Default | Description |
|---|---|---|---|
| () | () => { ref, edges, width, onScroll } | — | Which sides of a scroll container have hidden content (start, end, top, bottom), for shading any edge you like. |
06Notes
Behavior
- At rest a pinned column reads as part of the table. Its edge gains a hairline and a shade only while columns are actually hidden beneath it, so the start edge appears once you scroll and the end edge disappears when you reach the last column.
- The header, the first column and a totals footer all stay put; the corners sit above both so nothing slides over them.
- Scroll padding matches the pinned widths, so a focused link or a found word scrolls into view clear of the pinned columns instead of behind them.
- When pins would take more than 60% of the visible width, as on a phone, end pins let go first, then every start pin after the first.
Motion
- Edge shades fade in and out over 200ms as scrolling crosses a boundary; nothing moves but opacity, so it costs nothing while scrolling.
- The header and footer shades are drawn per cell as short gradients, so the seams between cells meet cleanly. Reduced motion makes the fades instant.
Accessibility
- The scroll container is a labeled region with a tab stop, so the table scrolls sideways with the arrow keys; it shows a focus ring when reached by keyboard.
- The first column is a row header, so screen readers name the account as they read each figure. Column headers are real th cells with scope.