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.
Here is column action type definition:
import { ReactNode } from 'react';
type ColumnAction = {
/** Name of the action to display in the dropdown menu */
label: string;
/** Will be shown on the left from the action label */
icon: ReactNode;
/** Callback when a user clicks an action button */
onClick: () => void;
}
This is how we implement the column pinning action in the hook.
import { HeaderContext } from '@tanstack/react-table';
import { Row } from '../types.ts';
import { Icon } from '../../Icon.tsx';
/**
* React hook which returns an array of table column actions config objects
*/
export const useColumnActions = (
context: HeaderContext<Row, unknown>,
): ColumnAction[] => {
// Get pinning position using TanStack table API
const isPinned = context.column.getIsPinned();
// Memoization is required to preserve referential equality of the resulting array
return useMemo<ColumnAction[]>(
() => [
{
// Use ternary expression to decide which label text or icon to render, according to the pinning state
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: () => {
// Conditionally set or unset column pinning state using TanStack table API
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.
import { FC } from 'react';
import classNames from 'classnames';
export type Props = {
/** Provide an icon name from a Phosphor library */
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.
In order 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 render part of 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 case of the right pinning, we subtract the same amount of pixels from each new pinned column, excluding the last one.
Here is the complete code for the createPinnedCellStyle
function:
import { CSSProperties } from 'react';
import { Header, Cell } from '@tanstack/react-table';
import { Row } from '../types.ts';
export type Props = {
/** Index of the cell in the Row array */
index: number;
/** Length of the Row array */
rowLength: number;
/** Column context for the Cell or Header */
context: Header<Row, unknown> | Cell<Row, unknown>;
};
/**
* Style helper function creates CSS Properties object with the left of right property
* calculated according to the pinning position
*/
export const createPinnedCellStyle = ({
index,
rowLength,
context,
}: Props): CSSProperties | undefined => {
// Get column pinning position using TanStack table API
const pinPosition = context.column.getIsPinned();
// Calculate fixes for table border size
const bordersLeft = index !== 0 ? index + 1 : 0;
const bordersRight = index === rowLength ? 0 : rowLength - (index + 1);
// Create left and right CSS style objects
const leftStyle = {
left: context.column.getStart('left') + bordersLeft,
};
const rightStyle = {
right: context.column.getAfter('right') + bordersRight,
};
// Decide which object to return according to the column pin position
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.
To be continued
Top comments (0)