Featuring Design System
Columns

Grouped Headers

중첩 columnDef로 멀티레벨 헤더를 구성하고, 그룹 셀의 시맨틱 억제가 어떻게 동작하는지 설명합니다.

개요

그리드는 TanStack Table의 nested column defs를 그대로 씁니다. columns: [{ header, columns: [...] }]로 컬럼을 중첩하면 table.getHeaderGroups()가 헤더 행을 여러 개 돌려주고, <DataGrid.Header />가 모든 행을 자동으로 그립니다. 별도 API는 없습니다.

그룹 헤더에도 leaf와 같은 DataGrid.HeaderCell wrap만 사용합니다. 시스템이 책임지는 것은 다음과 같습니다.

  • 그룹 셀 폭 — 자식 leaf 폭의 합(header.getSize())
  • aria-colspan — 자식 leaf 수를 보조기술(AT)에 노출
  • placeholder — 그룹에 속하지 않는 컬럼의 상단 행 자리. 폭만 유지하는 chrome으로 렌더하고 시맨틱을 전부 억제해 같은 컬럼이 두 헤더 행에서 이중으로 안내되지 않도록 합니다.

멀티레벨 헤더를 쓰면 컬럼 가상화가 자동 비활성되고 전체 렌더로 fallback됩니다. 자세한 내용은 제약에서 설명합니다.


기본 사용법

컬럼 정의에 columns 배열을 중첩하면 그룹이 됩니다. <DataGrid.Header />에 children을 넘기지 않으면 자동 렌더 모드로 동작합니다.

import { useMemo } from 'react';
import { DataGrid, useDataGrid, getCoreRowModel, type ColumnDef } from '@featuring-corp/data-grid';

type Row = { id: string; name: string; email: string; followers: number; engagementRate: number };

const columns: ColumnDef<Row>[] = [
	{
		id: 'profile',
		// 그룹 헤더 — children이 있는 컬럼. leaf와 동일한 DataGrid.HeaderCell wrap.
		header: ({ header }) => (
			<DataGrid.HeaderCell header={header} $css={{ justifyContent: 'center', fontWeight: '$fontWeight-bold' }}>
				프로필
			</DataGrid.HeaderCell>
		),
		columns: [
			{
				accessorKey: 'name',
				size: 180,
				header: ({ header }) => <DataGrid.HeaderCell header={header}>이름</DataGrid.HeaderCell>,
				cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue() as string}</DataGrid.Cell>,
			},
			{
				accessorKey: 'email',
				size: 220,
				header: ({ header }) => <DataGrid.HeaderCell header={header}>이메일</DataGrid.HeaderCell>,
				cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue() as string}</DataGrid.Cell>,
			},
		],
	},
	{
		id: 'performance',
		header: ({ header }) => (
			<DataGrid.HeaderCell header={header} $css={{ justifyContent: 'center', fontWeight: '$fontWeight-bold' }}>
				성과
			</DataGrid.HeaderCell>
		),
		columns: [
			{
				accessorKey: 'followers',
				size: 130,
				header: ({ header }) => (
					<DataGrid.HeaderCell header={header} $css={{ justifyContent: 'flex-end' }}>
						팔로워
					</DataGrid.HeaderCell>
				),
				cell: (info) => (
					<DataGrid.Cell cell={info.cell} $css={{ justifyContent: 'flex-end' }}>
						{(info.getValue() as number).toLocaleString()}
					</DataGrid.Cell>
				),
			},
			{
				accessorKey: 'engagementRate',
				size: 110,
				header: ({ header }) => (
					<DataGrid.HeaderCell header={header} $css={{ justifyContent: 'flex-end' }}>
						참여율
					</DataGrid.HeaderCell>
				),
				cell: (info) => (
					<DataGrid.Cell cell={info.cell} $css={{ justifyContent: 'flex-end' }}>
						{`${((info.getValue() as number) * 100).toFixed(1)}%`}
					</DataGrid.Cell>
				),
			},
		],
	},
	{
		// 그룹 없는 컬럼 — 상단 헤더 행에 placeholder(폭 유지 빈 셀)가 생긴다.
		accessorKey: 'status',
		size: 120,
		header: ({ header }) => <DataGrid.HeaderCell header={header}>상태</DataGrid.HeaderCell>,
		cell: (info) => <DataGrid.Cell cell={info.cell}>{String(info.getValue() ?? '')}</DataGrid.Cell>,
	},
];

function GroupedGrid({ data }: { data: Row[] }) {
	const cols = useMemo(() => columns, []);
	const table = useDataGrid<Row>({
		data,
		columns: cols,
		getCoreRowModel: getCoreRowModel(),
		getRowId: (r) => r.id,
	});

	return (
		<DataGrid.Root table={table}>
			<DataGrid.Header />
			<DataGrid.Body />
		</DataGrid.Root>
	);
}

아래는 살아 있는 예제입니다 — 코드를 직접 고치면 즉시 반영됩니다. 프로필·성과는 2단계 그룹 헤더이고, 상태는 그룹에 속하지 않아 상단 헤더 행에 placeholder(폭만 유지하는 빈 셀)가 생깁니다.

const data = [
{ id: '1', name: '김민준', email: 'minjun@example.com', followers: 128000, engagementRate: 0.043, status: 'active' },
{ id: '2', name: '이서연', email: 'seoyeon@example.com', followers: 84500, engagementRate: 0.051, status: 'active' },
{ id: '3', name: '박도윤', email: 'doyun@example.com', followers: 233000, engagementRate: 0.038, status: 'paused' },
{ id: '4', name: '최하은', email: 'haeun@example.com', followers: 45200, engagementRate: 0.062, status: 'active' },
{ id: '5', name: '정시우', email: 'siwoo@example.com', followers: 67800, engagementRate: 0.047, status: 'paused' },
{ id: '6', name: '강지우', email: 'jiwoo@example.com', followers: 152000, engagementRate: 0.041, status: 'active' },
];

const columns = [
{
  id: 'profile',
  header: ({ header }) => (
    <DataGrid.HeaderCell header={header} $css={{ justifyContent: 'center', fontWeight: '$fontWeight-bold' }}>
      프로필
    </DataGrid.HeaderCell>
  ),
  columns: [
    {
      accessorKey: 'name',
      size: 140,
      header: ({ header }) => <DataGrid.HeaderCell header={header}>이름</DataGrid.HeaderCell>,
      cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
    },
    {
      accessorKey: 'email',
      size: 200,
      header: ({ header }) => <DataGrid.HeaderCell header={header}>이메일</DataGrid.HeaderCell>,
      cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
    },
  ],
},
{
  id: 'performance',
  header: ({ header }) => (
    <DataGrid.HeaderCell header={header} $css={{ justifyContent: 'center', fontWeight: '$fontWeight-bold' }}>
      성과
    </DataGrid.HeaderCell>
  ),
  columns: [
    {
      accessorKey: 'followers',
      size: 120,
      header: ({ header }) => (
        <DataGrid.HeaderCell header={header} $css={{ justifyContent: 'flex-end' }}>팔로워</DataGrid.HeaderCell>
      ),
      cell: (info) => (
        <DataGrid.Cell cell={info.cell} $css={{ justifyContent: 'flex-end' }}>
          {info.getValue().toLocaleString()}
        </DataGrid.Cell>
      ),
    },
    {
      accessorKey: 'engagementRate',
      size: 110,
      header: ({ header }) => (
        <DataGrid.HeaderCell header={header} $css={{ justifyContent: 'flex-end' }}>참여율</DataGrid.HeaderCell>
      ),
      cell: (info) => (
        <DataGrid.Cell cell={info.cell} $css={{ justifyContent: 'flex-end' }}>
          {(info.getValue() * 100).toFixed(1) + '%'}
        </DataGrid.Cell>
      ),
    },
  ],
},
{
  accessorKey: 'status',
  size: 120,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>상태</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{String(info.getValue() ?? '')}</DataGrid.Cell>,
},
];

function GroupedGrid() {
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(<GroupedGrid />);

라이브 예제: Storybook — Grouped Headers


헤더 그룹 구조

table.getHeaderGroups() — 헤더 행 목록

getHeaderGroups()HeaderGroup[]을 반환합니다. 2-level 헤더라면 두 개의 HeaderGroup이 나옵니다.

// depth 0 — 그룹 행 (프로필, 성과, 그리고 status의 placeholder)
// depth 1 — leaf 행 (이름, 이메일, 팔로워, 참여율, 상태)
const headerGroups = table.getHeaderGroups();

HeaderGroupheaders: Header[]를 가집니다. Header 객체의 주요 프로퍼티는 다음과 같습니다.

프로퍼티타입의미
header.colSpannumber이 헤더가 가로로 덮는 leaf 컬럼 수. leaf는 1, 그룹은 자식 leaf 합계.
header.getSize()number렌더 폭(px). 그룹이면 자식 leaf 폭의 합.
header.isPlaceholderbooleantrue면 그룹 없는 컬럼의 상단 자리 — 폭만 유지.
header.depthnumber헤더 행 인덱스(0부터). 0이 최상단 그룹 행.

leaf 헤더 vs 그룹 헤더

그룹 헤더(header.colSpan > 1)도 leaf 헤더와 동일하게 DataGrid.HeaderCell로 wrap합니다. 폭과 aria-colspan 계산은 HeaderCell 내부에서 처리하므로 소비자는 레이아웃을 신경 쓸 필요가 없습니다.

// leaf 헤더와 그룹 헤더가 동일한 DataGrid.HeaderCell wrap을 씁니다.
header: ({ header }) => (
	<DataGrid.HeaderCell header={header} $css={{ justifyContent: 'center' }}>
		프로필
	</DataGrid.HeaderCell>
),

시맨틱 억제 — placeholder / 그룹 헤더

placeholder란

그룹에 속하지 않는 컬럼(예: 위의 status)이 있으면, 그룹 헤더 행(depth 0)에 그 컬럼 자리에 해당하는 placeholder 셀이 생깁니다. placeholder는 flex 행에서 폭을 확보해 아래 leaf 행과 정렬을 맞추는 역할만 합니다.

header.isPlaceholder === true인 헤더를 렌더할 때 HeaderCell은 다음과 같이 동작합니다:

// HeaderCell.tsx (요지)
const isPlaceholder = header?.isPlaceholder === true;

// …
role: isPlaceholder ? 'presentation' : 'columnheader',
'aria-hidden': isPlaceholder || undefined,
'data-header-cell': isPlaceholder ? undefined : '',
'data-column-id': isPlaceholder ? undefined : column.id,
'aria-colindex': isPlaceholder ? undefined : ariaColIndex,
'aria-colspan': !isPlaceholder && headerColSpan > 1 ? headerColSpan : undefined,
'aria-sort': !isPlaceholder && state.isSorted ?: isSortInteractive ? 'none' : undefined,

요약하면:

  • role="presentation" — AT가 이 셀을 의미 있는 헤더로 취급하지 않음
  • aria-hidden — AT가 셀 자체를 읽지 않음
  • data-column-id, aria-colindex, aria-sort시맨틱 속성 전체를 미부착 — leaf 헤더와 중복 안내 방지

auto-render(<DataGrid.Header />) 모드에서는 이 억제가 자동으로 적용됩니다. render-prop 모드에서도 <DataGrid.HeaderCell header={header} />로 그대로 렌더하면 HeaderCellisPlaceholder를 감지해 동일하게 처리합니다.

auto-render의 placeholder 처리:

// Header.tsx (auto-render 경로)
const renderHeader = (header: Header<T, unknown>) => (
	<Fragment key={header.id}>
		{header.isPlaceholder ? (
			<DataGridHeaderCell header={header} data-header-placeholder="" />
		) : (
			flexRender(header.column.columnDef.header, header.getContext())
		)}
	</Fragment>
);

data-header-placeholder="" 속성이 부착되므로 CSS나 테스트에서 placeholder 셀을 선택자로 잡을 수 있습니다.

그룹 헤더의 aria-colspan

그룹 헤더(isPlaceholder가 아닌 colSpan > 1 셀)에는 aria-colspan이 자식 leaf 수로 붙습니다:

'aria-colspan': !isPlaceholder && headerColSpan > 1 ? headerColSpan : undefined,

예: "프로필" 그룹이 이름·이메일 두 컬럼을 덮으면 aria-colspan="2". AT는 이 값으로 그룹이 몇 개 컬럼을 차지하는지 파악합니다.


render-prop으로 헤더 직접 구성

<DataGrid.Header>에 함수 children을 넘기면 render-prop 모드로 전환됩니다. headerGroup이 콜백 인자로 들어오고, 소비자가 headerGroup.headers를 순회해 DataGrid.HeaderRow + DataGrid.HeaderCell을 구성합니다.

<DataGrid.Root table={table}>
	<DataGrid.Header>
		{(headerGroup) => {
			const isGroupRow = headerGroup.depth === 0;
			return (
				<DataGrid.HeaderRow $css={isGroupRow ? { backgroundColor: '$background-3' } : undefined}>
					{headerGroup.headers.map((header) =>
						header.isPlaceholder ? (
							// placeholder도 DataGrid.HeaderCell로 그대로 렌더 — HeaderCell이 시맨틱 억제를 처리합니다.
							<DataGrid.HeaderCell key={header.id} header={header} />
						) : (
							<Fragment key={header.id}>
								{flexRender(header.column.columnDef.header, header.getContext())}
							</Fragment>
						),
					)}
				</DataGrid.HeaderRow>
			);
		}}
	</DataGrid.Header>
	<DataGrid.Body />
</DataGrid.Root>

render-prop에서도 placeholder 셀을 <DataGrid.HeaderCell header={header} />로 그대로 넘기면 HeaderCellisPlaceholder를 감지해 role="presentation" + aria-hidden을 자동으로 처리합니다.


그룹 헤더 + 정렬

정렬 배선은 leaf 헤더에만 부착합니다. 그룹 헤더에는 onClick을 넘기지 않아 sort affordance (data-sortable, tabIndex, aria-sort="none")가 붙지 않습니다.

// leaf 헤더에만 sort onClick + SortArrow.
header: ({ header }) => (
	<DataGrid.HeaderCell
		header={header}
		onClick={header.column.getToggleSortingHandler()}
		$css={{ justifyContent: 'flex-end' }}
	>
		팔로워
		<DataGrid.SortArrow
			direction={header.column.getIsSorted()}
			nextDirection={header.column.getNextSortingOrder()}
		/>
	</DataGrid.HeaderCell>
),

정렬의 자세한 배선과 SortArrow 사용법은 Sorting을 참고하세요.


제약

컬럼 가상화 자동 비활성

멀티레벨 헤더(헤더 그룹이 2개 이상)에서 virtualization.columns를 활성하면 컬럼 가상화가 자동으로 비활성되고 전체 렌더로 fallback됩니다:

const flatHeaders = table.getHeaderGroups().length <= 1;
// …
const enabled = !!config?.columns && flatHeaders && !hasSpan;

flatHeadersfalseenabledfalse가 되어 virtualizer는 count: 0으로 호출되고 null을 반환합니다. 개발 모드에서는 다음 경고가 1회 출력됩니다(셀 병합으로 비활성된 경우 원인 문구는 colSpan/rowSpan(셀 병합)으로 표시됩니다):

[DataGrid] virtualization.columns가 비활성됐습니다 — 멀티레벨(그룹) 헤더과 병용 불가(윈도우 경계 절단). 전체 렌더로 fallback합니다.

그룹 헤더에서 컬럼이 매우 많다면 virtualization.columns를 설정하지 않거나, 가상화 대신 필요한 컬럼만 보이도록 컬럼 숨김(column.toggleVisibility)을 활용하세요.

행 가상화(virtualization.rows)는 그룹 헤더에서도 정상 동작합니다. 비활성되는 것은 컬럼 가상화(horizontal windowing) 만입니다.


다음 단계