DEV Community

Cover image for Let's create Data Table. Part 4: Column pinning
Dima Vyshniakov
Dima Vyshniakov

Posted on • Edited on

Let's create Data Table. Part 4: Column pinning

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.

Column pinning demo

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.

Column pinning 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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],
  );
};
Enter fullscreen mode Exit fullscreen mode

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)} />
  );
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
))}

Enter fullscreen mode Exit fullscreen mode

Working demo

Here is a working demo of the table with column pinning.

To be continued

Top comments (0)