DEV Community

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

Posted on • Edited on

6 1 1

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

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.

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.

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

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],
  );
};
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 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)} />
  );
};
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.

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

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

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;
    }
  }
};
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.

Next: Cell sorting and localization

Top comments (0)

11 Tips That Make You a Better Typescript Programmer

typescript

1 Think in {Set}

Type is an everyday concept to programmers, but it’s surprisingly difficult to define it succinctly. I find it helpful to use Set as a conceptual model instead.

#2 Understand declared type and narrowed type

One extremely powerful typescript feature is automatic type narrowing based on control flow. This means a variable has two types associated with it at any specific point of code location: a declaration type and a narrowed type.

#3 Use discriminated union instead of optional fields

...

Read the whole post now!

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay