This is an article from the series about creating of an advanced Data table component using React, TanStack Table 8, Tailwind CSS and Headless UI.
In the previous article, we tackled optimizing data table rendering for large datasets using virtualization.
Now, when we can operate with large chunks of data, we’re going to enhance the user experience even further by introducing column pinning.
Column pinning allows users to "freeze" specific columns on the left or right side of the viewport. These pinned columns remain visible regardless of horizontal scrolling, similar to how the table header behaves vertically. This functionality improves user experience by ensuring important data points (like row identifiers or inputs) are always in view.
Here is the demo of the column pinning feature.
Create column menu
We have to provide a user a convenient way to use our table features, while keeping design clean and organized with functionalities hidden until needed. Our choice is a context menu attached to each column header, providing easy access to relevant table features through context-specific menu actions. We will use @headlessui/react to build this interface.
Here is the implementation using Headless UI Menu component. We added transition (origin-top transition duration-200 ease-out data-[closed]:scale-95 data-[closed]:opacity-0
) and shadow (shadow-lg shadow-stone-600/50
) classes to the Menu container.
import {
Menu,
MenuButton,
MenuItem,
MenuItems
} from '@headlessui/react';
// Here we set desired attachment position and gap for menu
const ANCHOR_PROP = { to: 'bottom' as const, gap: '12px' };
export const HeaderCell: FC<Props> = ({ title, columnWidth }) => {
return (
<div className="flex p-1.5" style={{ width: columnWidth }}>
<div className="mr-1.5 font-semibold">{title}</div>
<Menu>
<MenuButton as={Fragment}>
{({ hover, open }) => (
<button
className={classNames('ml-auto cursor-pointer', {
'text-gray-100': hover || open,
'text-gray-400': !hover && !open,
})}
>
<List weight="bold" size={18} />
</button>
)}
</MenuButton>
<MenuItems
anchor={ANCHOR_PROP}
transition
className={classNames(
// general styles
'overflow-hidden rounded text-xs text-slate-100 z-30',
// shadow styles
'shadow-lg shadow-stone-600/50',
// transition styles
'origin-top transition duration-200 ease-out data-[closed]:scale-95 data-[closed]:opacity-0',
)}
>
<MenuItem as={Fragment}>
{() => (
<button
className={classNames(
// general styles
'flex w-full items-center gap-1.5 whitespace-nowrap',
// background styles
'bg-stone-600 p-2 hover:bg-stone-500',
// add border between items
'border-stone-500 [&:not(:last-child)]:border-b',
)}
>
<Icon />
<div>Label</div>
</button>
)}
</MenuItem>
</MenuItems>
</Menu>
</div>
);
};
Column pinning logic implementation
We are going to use TanStack table column pinning API . The logic will be contained in src/DataTable/features/useColumnActions.tsx
hook.
ColumnAction
type has three properties. label
: A string that displays the name of the action in the dropdown menu; icon
: A React element (component, string, fragment, etc.) that renders to the left of the label; onClick
: A callback function that executes when a user clicks the action
import { ReactNode } from 'react';
type ColumnAction = {
label: string;
icon: ReactNode;
onClick: () => void;
}
This is how we implement the column pinning action in the hook.
React hook src/DataTable/features/useColumnActions.tsx
creates and returns a set of column manipulation actions for a TanStack Table. It takes a TanStack Table header column context as input parameter. The current pinning state of the column (isPinned
) is retrieved through the TanStack Context API.
useColumnActions
returns a memoized array of two ColumnAction
objects, each responsible for pinning column to the corresponding side. Each action contains:
Conditionally rendered
label
that changes based on pinning state;An appropriate
icon
that visually indicates the action;onClick
handler that uses the TanStack Table API to toggle column pinning;
useMemo
prevents unnecessary recreation of the actions array on re-renders for better performance.
import { HeaderContext } from '@tanstack/react-table';
import { Row } from '../types.ts';
import { Icon } from '../../Icon.tsx';
export const useColumnActions = (
context: HeaderContext<Row, unknown>,
): ColumnAction[] => {
const isPinned = context.column.getIsPinned();
return useMemo<ColumnAction[]>(
() => [
{
label: isPinned !== 'left' ? 'Pin left' : 'Unpin left',
icon:
isPinned !== 'left' ? (
<Icon name="push-pin" className="text-lg" />
) : (
<Icon name="push-pin-simple-slash" className="text-lg" />
),
onClick: () => {
if (isPinned !== 'left') {
context.column.pin('left');
} else {
context.column.pin(false);
}
},
},
{
label: isPinned !== 'right' ? 'Pin right' : 'Unpin right',
icon:
isPinned !== 'right' ? (
<Icon name="push-pin" className="text-lg scale-x-[-1]" />
) : (
<Icon name="push-pin-simple-slash" className="text-lg" />
),
onClick: () => {
if (isPinned !== 'right') {
context.column.pin('right');
} else {
context.column.pin(false);
}
},
},
],
[context, isPinned],
);
};
Icon implementation
Due to StackBlitz problems with SVG bundling, we can't use Phosphor Icons React library. We will use Phosphor Icons Web instead. Though, I recommend using React in your final implementation for a more streamlined approach.
But in our case, we have to add Phosphor Icons CSS import (import "@phosphor-icons/web/bold"
) to the root file src/main.tsx
and create our own icon component src/Icon.tsx
. Which will try to pick corresponding icon from Phosphor web library using CSS classes based on component name
property.
import { FC } from 'react';
import classNames from 'classnames';
export type Props = {
name: string;
className?: string;
};
export const Icon: FC<Props> = ({ name, className }) => {
return (
<i className={classNames(`ph-bold ph-${name} leading-none`, className)} />
);
};
Rendering pinned columns
Rendering pinned columns can be challenging. We need to apply position: sticky
to each pinned column while considering other columns pinned on the same side.
To contain this logic, we create a helper function src/DataTable/features/createPinnedCellStyle.ts
. It's not a hook because it will be invoked inside the render part of a React Component.
Border width fix
We also have to provide a fix for the cell border width, which we've set in the previous chapter.
Basically, it adds 1 extra pixel width for each new cell in case of left pinning. And we don't need to apply the fix for the first cell. Here is the formula.
const bordersLeft = index !== 0 ? index + 1 : 0;
In the case of the right pinning, we subtract the same number of pixels from each new pinned column, excluding the last one.
Here is the src/DataTable/features/createPinnedCellStyle.ts
explanation. This is a utility function that generates CSS positioning styles for pinned columns in a TanStack Table. It ensures that pinned columns stay fixed in their correct positions while the rest of the table scrolls horizontally, accounting for borders between cells.
It accepts the following parameters. index
: position of the cell in the row; rowLength
: total number of cells in the row; context
: TanStack Table column context (either Header or Cell).
import { Header, Cell } from '@tanstack/react-table';
import { Row } from '../types.ts';
export type Props = {
index: number;
rowLength: number;
context: Header<Row, unknown> | Cell<Row, unknown>;
};
createPinnedCellStyle
gets the column's pinning position (left, right, or false) from TanStack Table column context.
For left-pinned columns: sets the left
property based on column position. For right-pinned columns: sets the right
property based on column position. Returns undefined
for unpinned columns.
import { CSSProperties } from 'react';
export const createPinnedCellStyle = ({
index,
rowLength,
context,
}: Props): CSSProperties | undefined => {
const pinPosition = context.column.getIsPinned();
const bordersLeft = index !== 0 ? index + 1 : 0;
const bordersRight = index === rowLength ? 0 : rowLength - (index + 1);
const leftStyle = {
left: context.column.getStart('left') + bordersLeft,
};
const rightStyle = {
right: context.column.getAfter('right') + bordersRight,
};
switch (pinPosition) {
case 'left': {
return leftStyle;
}
case 'right': {
return rightStyle;
}
default: {
return undefined;
}
}
};
Apply pinning styles to table cells
Now we need to apply this style to the actual data table cells. We do it the same way both to header and body cells. We also change pinned cells color to cyan-800
only for the header cells.
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, index, headerCells) => {
// Call the helper to get CSS properties object
const cellStyle = createPinnedCellStyle({
index,
rowLength: headerCells.length,
context: header,
});
return (
<th
key={header.id}
className={classNames(
//...
// sticky column styles
{
'sticky z-20 bg-cyan-800 border-t-cyan-800 border-b-cyan-800':
Boolean(header.column.getIsPinned()),
'bg-stone-600': !header.column.getIsPinned(),
},
)}
style={cellStyle}
>
{/*...*/}
</th>
);
})}
</tr>
))}
Working demo
Here is a working demo of the table with column pinning.
Top comments (0)