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의 기준이 됩니다.

기본 사용법

useDataGrid에 state.columnPinning과 enableColumnPinning: true를 전달합니다. columnPinning.left와 columnPinning.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();

컬럼 순서 — 고정은 컬럼을 옮깁니다

고정하면 그 컬럼은 화면에서 자리를 옮깁니다. 좌측 고정은 맨 앞으로, 우측 고정은 맨 뒤로 갑니다. 정의 순서 한가운데 있던 컬럼을 고정하면 정의 순서와 화면 순서가 갈라집니다.

정의 순서   a  b  c  d  e
c를 좌측 고정 →  c │ a  b  d  e
b를 우측 고정 →  c │ a  d  e │ b

그리드의 좌표계는 화면에 보이는 순서를 따릅니다. 선택 범위·복사·붙여넣기·채우기·aria-colindex가 전부 이 순서 하나를 씁니다. 그래서 어떤 고정 상태에서도:

  • 선택 범위는 화면에서 끊긴 곳 없는 직사각형입니다. 좌측 고정 컬럼에서 우측 고정 컬럼까지 드래그하면 그 사이에 보이는 컬럼이 전부 들어갑니다.
  • 복사한 TSV의 컬럼 순서가 화면 왼→오와 같습니다. 붙여넣은 결과가 보던 것과 같습니다.
  • aria-colindex는 시각 위치입니다(ARIA grid 요구사항).

고정을 해제하면 컬럼은 원래 정의 위치로 돌아갑니다. 그리드는 순서를 파생만 하고 columnOrder 상태를 변경하지 않습니다 — 컬럼 순서는 소비자가 소유합니다.

컬럼 재정렬(columnOrder)과 함께 쓰면 둘이 합성됩니다. 재정렬이 먼저 적용된 순서에서 고정 컬럼이 양 끝으로 뽑혀 나가고, 나머지는 재정렬된 순서를 유지합니다.

정의 순서                a  b  c  d  e
columnOrder: e d c b a → e  d  c  b  a
+ c를 좌측 고정        → c │ e  d  b  a

커스텀 좌표 계산 — displayLeafColumns

직접 좌표를 계산한다면(커스텀 클립보드, 컬럼 헤더 클릭 선택 등) 그리드와 같은 순서를 써야 합니다.

import { displayLeafColumns } from '@featuring-corp/data-grid';

const colIds = displayLeafColumns(table).map((c) => c.id);
const sC = colIds.indexOf(range.start.columnId);
const eC = colIds.indexOf(range.end.columnId);
const inRange = colIds.slice(sC, eC + 1); // 화면 왼→오

TanStack의 table.getVisibleLeafColumns()는 정의 순서(고정을 반영하지 않음)라, 가운데 컬럼을 고정한 구성에서 내장 동작과 답이 갈립니다. row.getVisibleCells()와 table.getHeaderGroups()는 표시 순서이므로 displayLeafColumns와 일치합니다.

오프셋과 경계 — 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입니다.

겹침 — 고정 컬럼이 덮는 것

가로로 스크롤하면 일반 컬럼의 셀이 고정 컬럼 밑으로 들어갑니다. 시트의 frozen pane과 같은 규약입니다.

대상결과
활성 셀 테두리 · 세로병합 셀고정 셀이 덮습니다
복사 점선 · 채우기 미리보기 · 채우기 핸들 · 터치 선택 핸들고정 셀이 덮습니다. 셀이 직접 그리거나 셀의 자식이라 셀과 운명을 같이합니다
일반 컬럼에서 편집 중인 셀고정 셀이 덮습니다. 편집기가 고정 컬럼 쪽으로 뻗은 만큼 잘립니다
고정 컬럼에서 편집 중인 셀반대로 최상단으로 올라옵니다

이 규약은 고정 셀이 불투명할 때 성립합니다. 자세한 층 구조는 Architecture — 겹침 순서를 참고하세요.

가상화와 함께

컬럼 가상화(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되고 개발 모드에서 경고합니다(sticky와 position: absolute 오버레이가 양립할 수 없기 때문입니다). 자세한 내용은 Cell merging — v1 제약 표를 참고하세요.

TypeTable

Prop

Type

다음 단계