Featuring Design System
Layout & Scale

Cell merging

colSpan / rowSpan — anchor가 N칸을 점유하되 좌표 공간은 dense하게 유지하는 Excel 시맨틱.

개요

셀 병합은 column.meta의 두 콜백으로 표현합니다. 둘 다 anchor 셀이 차지할 칸 수를 반환합니다(1 = 병합 없음, 기본).

declare module '@tanstack/react-table' {
	interface ColumnMeta<TData, TValue> {
		/** 가로 병합 — 이 body 셀이 차지할 컬럼 수. */
		colSpan?: (info: CellContext<TData, TValue>) => number;
		/** 세로 병합 — 이 body 셀이 차지할 행 수. */
		rowSpan?: (info: CellContext<TData, TValue>) => number;
	}
}
  • colSpan — anchor 셀이 N개 컬럼 너비를 합쳐 차지하고, 가려지는 N−1개 셀은 렌더되지 않습니다.
  • rowSpan — anchor 셀이 N개 행 높이를 absolute 오버레이로 차지하고, 아래 N−1개 셀 자리에는 정렬 유지용 빈 placeholder만 남습니다.

핵심 원칙 — 좌표 공간은 dense

병합의 가장 중요한 결정은 논리 좌표 공간을 dense하게 유지한다는 것입니다. 화면에서 두 셀이 하나로 합쳐 보여도, 선택·복사·키보드 내비게이션의 좌표는 합쳐지지 않습니다. 가려진 좌표는 anchor가 흡수합니다(Excel 시맨틱).

시각적 표현            논리 좌표(dense)
┌───────────┬─────┐    (r0,c0)=anchor  (r0,c1)=anchor가 흡수  (r0,c2)
│  병합 셀   │  A  │    (r1,c0)         (r1,c1)               (r1,c2)
├─────┬─────┼─────┤
│  B  │  C  │  D  │    클릭/내비/복사는 모두 anchor로 resolve —
└─────┴─────┴─────┘    선택 모델은 병합을 "모르고" 정상 동작

이 설계 덕분에 병합과 셀 선택·클립보드·자동 채우기가 서로를 몰라도 충돌 없이 함께 동작합니다. 병합은 순수하게 렌더 레이어의 관심사이고, 상호작용은 dense 좌표 위에서 돌아갑니다.

colSpan — 가로 병합

아래 그리드에서 📌 헤드라인 행의 첫 셀이 meta.colSpan으로 전체 너비를 차지합니다. 가려지는 칸은 렌더되지 않습니다.

const FEED_COLS = 3; // name, platform, cost

const data = [
{ id: 'h1', kind: 'headline', name: '📌 6월 신규 섭외 — 협상 진행 중', platform: '', cost: '' },
{ id: 'c1', kind: 'creator', name: '뷰티by지은', platform: 'Instagram', cost: '₩3,200,000' },
{ id: 'c2', kind: 'creator', name: '데일리룩 수아', platform: 'Instagram', cost: '₩1,400,000' },
{ id: 'h2', kind: 'headline', name: '📌 재계약 검토 — 단가 인상 요청', platform: '', cost: '' },
{ id: 'c3', kind: 'creator', name: '준호의 먹방로그', platform: 'YouTube', cost: '₩5,800,000' },
{ id: 'c4', kind: 'creator', name: '여행하는 서연', platform: 'YouTube', cost: '₩720,000' },
];

const columns = [
{
  accessorKey: 'name',
  size: 280,
  // headline 행이면 첫 셀이 전체(3칸)를 가로 병합.
  meta: { colSpan: (info) => (info.row.original.kind === 'headline' ? FEED_COLS : 1) },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>크리에이터</DataGrid.HeaderCell>,
  cell: (info) => {
    const isHeadline = info.row.original.kind === 'headline';
    return (
      <DataGrid.Cell
        cell={info.cell}
        $css={isHeadline ? { fontWeight: '$fontWeight-bold', backgroundColor: '$background-3' } : undefined}
      >
        {info.getValue()}
      </DataGrid.Cell>
    );
  },
},
{
  accessorKey: 'platform',
  size: 160,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>플랫폼</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'cost',
  size: 160,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>단가</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
];

function HeadlineGrid() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
});
return (
  <DataGrid.Root table={table} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<HeadlineGrid />);

anchor가 차지할 너비는 인접 컬럼들의 getSize() 합으로 계산되며, 고정(pinned)·control 경계에서 clamp됩니다 (clampColSpan이 단일 진실). 경계를 넘는 span은 잘려서 경계까지만 차지하고, 개발 모드에서 경고합니다.

rowSpan — 세로 병합

정렬된 데이터에서 같은 카테고리의 첫 행만 anchor로 잡고, runLength로 그룹 크기만큼 세로 병합합니다. 나머지 행은 1을 반환해 가려집니다.

// 같은 그룹의 첫 행이면 그룹 길이(run-length)를 반환, 아니면 1(가려짐).
function runLength(rows, index, key) {
if (index > 0 && key(rows[index - 1]) === key(rows[index])) return 1;
let n = 1;
while (index + n < rows.length && key(rows[index + n]) === key(rows[index])) n++;
return n;
}

const data = [
{ id: '1', category: '뷰티', name: '김민준', platform: 'Instagram' },
{ id: '2', category: '뷰티', name: '이서연', platform: 'Instagram' },
{ id: '3', category: '뷰티', name: '박도윤', platform: 'YouTube' },
{ id: '4', category: '테크', name: '최하은', platform: 'YouTube' },
{ id: '5', category: '테크', name: '정시우', platform: 'YouTube' },
{ id: '6', category: '푸드', name: '강지우', platform: 'Instagram' },
{ id: '7', category: '푸드', name: '윤서준', platform: 'TikTok' },
{ id: '8', category: '푸드', name: '임하준', platform: 'YouTube' },
];

const columns = [
{
  accessorKey: 'category',
  size: 120,
  // 카테고리 컬럼 — 연속 같은 카테고리를 run-length로 세로 병합. 그룹 첫 행만 anchor.
  meta: { rowSpan: (info) => runLength(data, info.row.index, (r) => r.category) },
  header: ({ header }) => <DataGrid.HeaderCell header={header}>카테고리</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell
      cell={info.cell}
      $css={{ fontWeight: '$fontWeight-semiBold', backgroundColor: '$background-2', display: 'flex', alignItems: 'center' }}
    >
      {info.getValue()}
    </DataGrid.Cell>
  ),
},
{
  accessorKey: 'name',
  size: 200,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'platform',
  size: 140,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>플랫폼</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
];

function GroupByGrid() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
});
return (
  <DataGrid.Root table={table} size="md">
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<GroupByGrid />);

anchor는 N개 행 높이를 합친 absolute 오버레이로 렌더되고, 가려지는 아래 셀 자리에는 가로 정렬 유지를 위한 빈 placeholder가 남습니다.

v1 제약 (의도된 PoC 경계)

병합은 의도적으로 좁은 보장 범위로 시작합니다. 경계를 벗어나면 clamp(또는 가상화 자동 비활성) + 개발 경고로 "보이고 무해"하게 강등합니다.

colSpanrowSpan
적용 대상body 셀(헤더 미적용)body 셀
고정(pinned) 컬럼경계에서 clamp + dev 경고미지원 → clamp to 1 + dev 경고
행 높이고정 행 높이 가정
가상화열 가상화 자동 비활성(전체 렌더) + dev 경고행·열 가상화 자동 비활성(전체 렌더) + dev 경고
자동 채우기fill × merge는 의도적으로 미변경(현행 동작 유지)동일

병합과 고정/가상화는 모두 "어느 셀을 렌더하느냐"를 다르게 계산하므로 v1에서 함께 보장하지 않습니다. 셀 병합이 정의되면 열 가상화는 자동 비활성되고, rowSpan이 있으면 행 가상화도 자동 비활성되어 전체 렌더로 fallback합니다(span은 그대로 유지). 경계를 넘는 span은 silent failure 대신 clamp 또는 자동 비활성 + 경고로 드러납니다.

TypeTable

Prop

Type

라이브 예제는 Storybook DataGrid/Cell Merging 스토리에서 확인하세요.