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();각 HeaderGroup은 headers: Header[]를 가집니다. Header 객체의 주요 프로퍼티는 다음과 같습니다.
| 프로퍼티 | 타입 | 의미 |
|---|---|---|
header.colSpan | number | 이 헤더가 가로로 덮는 leaf 컬럼 수. leaf는 1, 그룹은 자식 leaf 합계. |
header.getSize() | number | 렌더 폭(px). 그룹이면 자식 leaf 폭의 합. |
header.isPlaceholder | boolean | true면 그룹 없는 컬럼의 상단 자리 — 폭만 유지. |
header.depth | number | 헤더 행 인덱스(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} />로 그대로 렌더하면 HeaderCell이 isPlaceholder를 감지해
동일하게 처리합니다.
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} />로 그대로 넘기면
HeaderCell이 isPlaceholder를 감지해 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;flatHeaders가 false면 enabled가 false가 되어 virtualizer는 count: 0으로 호출되고 null을
반환합니다. 개발 모드에서는 다음 경고가 1회 출력됩니다(셀 병합으로 비활성된 경우 원인 문구는 colSpan/rowSpan(셀 병합)으로 표시됩니다):
[DataGrid] virtualization.columns가 비활성됐습니다 — 멀티레벨(그룹) 헤더과 병용 불가(윈도우 경계 절단). 전체 렌더로 fallback합니다.그룹 헤더에서 컬럼이 매우 많다면 virtualization.columns를 설정하지 않거나, 가상화 대신 필요한
컬럼만 보이도록 컬럼 숨김(column.toggleVisibility)을 활용하세요.
행 가상화(
virtualization.rows)는 그룹 헤더에서도 정상 동작합니다. 비활성되는 것은 컬럼 가상화(horizontal windowing) 만입니다.
다음 단계
- Columns —
ColumnDef전체 옵션, 폭·크기 조절, 핀 고정 - Pinning — 그룹 헤더와 열 고정(left/right pin) 조합
- Accessibility —
aria-sort정직 노출,aria-colspan좌표 모델, 로케일 중립 정책 - 라이브 스토리: Storybook — Grouped Headers