Featuring Design System
Layout & Scale

Column Pinning

TanStack columnPinning state 위의 헤드리스 고정 — sticky 오프셋 자동 계산, 경계 마커, 가상화·병합과의 상호작용.

개요

컬럼 고정은 TanStack Table의 columnPinning state({ left, right }) 위에서 동작하는 헤드리스 기능입니다. 고정된 컬럼에는 position: sticky와 오프셋이 자동으로 계산·부착되어 가로 스크롤 시 제자리에 남습니다.

  • 좌측 고정left: column.getStart('left') 오프셋, data-pinned="left" 마커.
  • 우측 고정right: column.getAfter('right') 오프셋, data-pinned="right" 마커.
  • 경계 마커 — 고정 그룹과 비고정 콘텐츠가 맞닿는 컬럼에 data-pinned-last가 부착되어 분리선(box-shadow) CSS의 기준이 됩니다.

기본 사용법

useDataGridstate.columnPinningenableColumnPinning: true를 전달합니다. columnPinning.leftcolumnPinning.right는 각각 고정할 컬럼 id 배열입니다.

본문이 화면보다 넓어 가로 스크롤이 생기며, 고정 컬럼(좌측 채널·우측 액션)만 제자리에 남고 경계에 분리선이 나타납니다.

const data = [
{ id: '1', name: '김민준', email: 'minjun@example.com', platform: 'YouTube', category: '게임', costPerPost: 3200000 },
{ id: '2', name: '이서연', email: 'seoyeon@example.com', platform: 'Instagram', category: '뷰티', costPerPost: 1400000 },
{ id: '3', name: '박도윤', email: 'doyoon@example.com', platform: 'YouTube', category: '테크', costPerPost: 5800000 },
{ id: '4', name: '최하은', email: 'haeun@example.com', platform: 'TikTok', category: '푸드', costPerPost: 720000 },
{ id: '5', name: '정시우', email: 'siwoo@example.com', platform: 'Instagram', category: '여행', costPerPost: 980000 },
{ id: '6', name: '강지우', email: 'jiwoo@example.com', platform: 'YouTube', category: '피트니스', costPerPost: 2600000 },
];

const columns = [
{
  accessorKey: 'name',
  size: 200,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'email',
  size: 230,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>이메일</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'platform',
  size: 130,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>플랫폼</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'category',
  size: 140,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>카테고리</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'costPerPost',
  size: 140,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>단가</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
{
  id: 'actions',
  size: 100,
  meta: { columnRole: 'control' },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>액션</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell}>
      <Button.Root size="sm" variant="tertiary">
        <Button.Text>편집</Button.Text>
      </Button.Root>
    </DataGrid.Cell>
  ),
},
];

function PinnedGrid() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  state: {
    columnPinning: { left: ['name'], right: ['actions'] },
  },
  enableColumnPinning: true,
});
return (
  <DataGrid.Root table={table} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<PinnedGrid />);

툴바로 고정 토글 — column.pin

런타임에 사용자가 고정을 변경할 수 있도록 controlled 모드로 연결합니다. column.pin('left' | 'right' | false)onColumnPinningChange를 통해 state를 갱신합니다. column.getIsPinned()로 현재 상태를 읽고, column.getCanPin()으로 고정 가능 여부를 게이팅합니다.

const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
  left: ['_select', 'name'],
  right: ['_actions'],
});

const table = useDataGrid({
  data,
  columns,
  state: { columnPinning },
  onColumnPinningChange: setColumnPinning,
  enableColumnPinning: true,
});

// 컬럼 메뉴에서 고정 토글
column.pin('left');   // 좌측 고정
column.pin('right');  // 우측 고정
column.pin(false);    // 고정 해제

// 전체 해제
table.resetColumnPinning();

오프셋과 경계 — data-pinned / data-pinned-last

getPinAttrs(column) 유틸이 셀/헤더 셀 양쪽에서 호출되어 sticky 위치와 data 속성을 한 곳에서 계산합니다.

// getPinAttrs.ts (요지)
export function getPinAttrs<TData, TValue>(column) {
  const pinned = column.getIsPinned();
  const isBoundary =
    !!pinned && (pinned === 'left'
      ? column.getIsLastColumn('left')
      : column.getIsFirstColumn('right'));
  return {
    'data-pinned': pinned || undefined,
    'data-pinned-last': isBoundary ? '' : undefined,
    style: {
      ...(pinned === 'left' && { left: column.getStart('left') }),
      ...(pinned === 'right' && { right: column.getAfter('right') }),
    },
  };
}

boundary 비대칭

data-pinned-last가 붙는 컬럼은 좌/우가 비대칭입니다.

방향boundary 컬럼TanStack API
left그룹의 가장 오른쪽getIsLastColumn('left')
right그룹의 가장 왼쪽getIsFirstColumn('right')

right 고정 배열은 TanStack이 left→right 순서로 렌더합니다. 따라서 :last-child(가장 바깥)는 비고정 콘텐츠와 맞닿지 않아 boundary가 아닙니다. getIsFirstColumn('right')로 그룹의 내측 끝을 정확히 잡습니다.

경계 엣지 커스터마이즈

기본 분리선보다 강한 스타일이 필요하면 style / className state 콜백으로 isLastPinned를 읽습니다. $css(정적 atomic)로는 상태를 읽을 수 없으므로 콜백을 사용해야 합니다. 콜백 내부의 토큰은 CSS custom property(var(--global-colors-primary-60))로 참조합니다.

import type { DataGridCellState, DataGridHeaderCellState } from '@featuring-corp/data-grid';
import type { CSSProperties } from 'react';

const cellEdge = (state: DataGridCellState): CSSProperties => {
  if (!state.isLastPinned) return {};
  const side = state.isPinned === 'right' ? 'borderLeft' : 'borderRight';
  return { [side]: '2px solid var(--global-colors-primary-60)' };
};

const headerEdge = (state: DataGridHeaderCellState): CSSProperties => {
  if (!state.isLastPinned) return {};
  const side = state.isPinned === 'right' ? 'borderLeft' : 'borderRight';
  return {
    [side]: '2px solid var(--global-colors-primary-60)',
    background: 'var(--global-colors-primary-10)',
  };
};

// 셀/헤더에 콜백으로 전달
<DataGrid.Cell cell={info.cell} style={cellEdge}>…</DataGrid.Cell>
<DataGrid.HeaderCell header={header} style={headerEdge}>…</DataGrid.HeaderCell>

state.isPinned'left' | 'right' | false, state.isLastPinned는 boolean입니다.

가상화와 함께

컬럼 가상화(virtualization: { columns: true })를 활성화해도 고정 컬럼은 항상 렌더됩니다. center 컬럼만 좌우 spacer로 윈도잉되고, 좌(getLeftVisibleCells) · 우(getRightVisibleCells) 고정 컬럼은 스크롤 내내 DOM에 유지됩니다. 행 가상화(virtualization: { rows: true })와도 독립적으로 공존합니다.

const table = useDataGrid({
  data,
  columns,          // center 컬럼이 24개여도 고정 컬럼은 항상 렌더
  state: { columnPinning: { left: ['name'], right: ['_actions'] } },
  enableColumnPinning: true,
  virtualization: { columns: true },
});

병합과 함께 — colSpan clamp

가로 병합(column.meta.colSpan)은 고정 경계에서 자동으로 clamp됩니다. clampColSpan이 단일 진실로, Cell 너비 흡수·Body covered-skip·SpanResolver 세 곳 모두 같은 값을 씁니다.

// spanMap.ts — clampColSpan (요지)
export function clampColSpan<T>(
  cells: Cell<T, unknown>[],
  anchorIndex: number,
  want: number,
): number {
  if (want <= 1) return 1;
  const anchor = cells[anchorIndex];
  const anchorPinned = anchor.column.getIsPinned?.() ?? false;
  let end = anchorIndex;
  for (let j = anchorIndex + 1; j < cells.length && j - anchorIndex < want; j++) {
    if (isControlCol(cells[j].column)) break;
    if ((cells[j].column.getIsPinned?.() ?? false) !== anchorPinned) {
      warnPinCrossMerge(anchor.column.id);  // dev 1회 경고
      break;
    }
    end = j;
  }
  return end - anchorIndex + 1;
}

anchor와 pin 상태(left / right / false)가 다른 컬럼을 넘으려 하면 경계에서 멈추고, 개발 모드에서 컬럼당 1회 콘솔 경고를 남깁니다.

[DataGrid] 가로 병합(colSpan)이 고정(pin) 경계에서 잘렸습니다 (컬럼 "name").
고정 열은 병합을 가로지를 수 없습니다(Google Sheets와 동일) — 고정 범위를 병합 경계에 맞추세요.

안전한 병합: anchor와 대상 컬럼이 같은 pin 그룹 안에 있어야 합니다.

// 안전 — name과 email이 모두 left 고정
{
  accessorKey: 'name',
  meta: { colSpan: (info) => (info.row.index === 0 ? 2 : 1) },
}
// state: { columnPinning: { left: ['name', 'email'] } }

rowSpan과 고정의 관계도 동일하게 제약됩니다. 고정 컬럼에서 rowSpan을 시도하면 1로 clamp되고 개발 모드에서 경고합니다(stickyposition: absolute 오버레이가 양립할 수 없기 때문입니다). 자세한 내용은 Cell merging — v1 제약 표를 참고하세요.

TypeTable

Prop

Type

다음 단계