Featuring Design System
Layout & Scale

Virtualization

수천 행·열을 windowing으로 렌더 — 보이는 범위만 DOM에 두고 나머지는 spacer로 오프셋합니다.

개요

가상화(windowing) 는 보이는 행/컬럼과 overscan만 DOM에 렌더하고, 그 위아래(또는 좌우)를 [data-virtual-spacer] 엘리먼트로 오프셋하는 방식입니다. 행은 normal flow를 유지하므로 sticky 헤더, pinned 컬럼, 컬럼 리사이즈가 그대로 작동합니다.

행 가상화(virtualization.rows)와 열 가상화(virtualization.columns)는 독립적으로 켤 수 있습니다. 1,000행 × 1,000열(100만 셀)도 부드럽게 스크롤됩니다.

가상화는 useDataGrid 옵션의 virtualization 필드 한 곳에서만 제어합니다. View state 페이지에 기본 예시가 있으며, 이 페이지는 각 옵션과 동작 세부사항을 다룹니다.

라이브 예시는 Storybook에서 확인할 수 있습니다.


행 가상화

virtualization: { rows: true } 한 줄이면 됩니다. DataGrid.RootmaxHeight를 주면 내부 스크롤 컨테이너가 켜지고, Body가 row virtualizer를 통해 보이는 행만 렌더합니다.

const data = Array.from({ length: 1000 }, (_, i) => ({
id: String(i + 1),
name: '채널 ' + (i + 1),
followers: 30000 + ((i * 137) % 220) * 1000,
}));

const columns = [
{
  accessorKey: 'idx',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>#</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.row.index + 1}</DataGrid.Cell>,
  size: 64,
},
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'followers',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function VirtualizedRows() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  virtualization: { rows: true },
});
return (
  <DataGrid.Root table={table} size="md" $css={{ maxHeight: '420px' }}>
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<VirtualizedRows />);

estimateRowHeight

행 높이 추정치(px)입니다. 미지정 시 size prop을 기반으로 자동 결정됩니다(sm: 36, md: 44, lg: 52).

추정치는 spacer 높이 계산에 사용되어 스크롤 막대 비율을 결정합니다. auto-render 모드(children 없는 <DataGrid.Body>)에서는 각 행에 measureElement를 붙여 실측 높이로 보정하므로 추정치가 정확하지 않아도 스크롤이 결국 맞아갑니다. render-prop 모드의 주의사항은 아래를 참고하세요.

overscan

뷰포트 밖에 추가로 렌더할 행 수입니다. 기본값은 8입니다. overscan을 키우면 빠른 스크롤 시 빈 칸이 덜 보이는 대신 DOM 노드가 늘어납니다.

virtualization: {
	rows: true,
	estimateRowHeight: 44,
	overscan: 8,
}

spacer 메커니즘

Body는 렌더된 행 앞뒤에 [data-virtual-spacer="top"]·[data-virtual-spacer="bottom"] div를 삽입합니다. 이 spacer가 렌더되지 않은 행들의 높이를 대신해 전체 스크롤 높이를 유지합니다. 행 자체는 normal flow이므로 table 레이아웃(sticky/pinned/resize)이 깨지지 않습니다.

<!-- Body 내부 DOM 구조 (개략) -->
<div aria-hidden data-virtual-spacer="top" style="height: Xpx"></div>
<!-- 보이는 행들 -->
<div aria-hidden data-virtual-spacer="bottom" style="height: Ypx"></div>

열 가상화

virtualization: { columns: true }를 추가하면 center(비-pinned) 컬럼도 windowing됩니다. 수백~수천 열에 유용합니다.

const data = Array.from({ length: 1000 }, (_, r) => {
const row = { id: String(r + 1) };
for (let c = 0; c < 40; c++) row['m' + c] = ((r + c) * 7) % 100;
return row;
});

const columns = [
{
  id: 'idx',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>#</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.row.index + 1}</DataGrid.Cell>,
  size: 64,
},
...Array.from({ length: 40 }, (_, c) => ({
  accessorKey: 'm' + c,
  header: ({ header }) => <DataGrid.HeaderCell header={header}>{'지표 ' + (c + 1)}</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
  size: 96,
})),
];

function VirtualizedColumns() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  initialState: { columnPinning: { left: ['idx'] } },
  virtualization: { rows: true, columns: true },
});
return (
  <DataGrid.Root table={table} size="md" $css={{ maxHeight: '420px' }}>
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<VirtualizedColumns />);

pinned 컬럼은 항상 렌더

columnPinning.left·columnPinning.right로 고정한 컬럼은 열 가상화에서 제외되어 항상 렌더됩니다. 가로 스크롤 중에도 pinned 컬럼은 화면에 고정되어 있어야 하기 때문입니다. 열 폭은 column.getSize()로 정확히 계산되므로 별도 추정치가 필요 없습니다.

자동 비활성 — 그룹 헤더 / 셀 병합

컬럼 가상화는 단일 레벨(flat) 헤더를 가정합니다. 아래 두 경우에는 window 경계에서 렌더가 깨지므로, 열 가상화가 자동으로 비활성되어 전체 렌더로 fallback합니다.

  • 그룹(다단계) 헤더table.getHeaderGroups().length > 1
  • colSpan/rowSpan 셀 병합 — 어느 컬럼이든 meta.colSpan 또는 meta.rowSpan 함수가 정의된 경우

개발 모드에서는 비활성 사유(둘 중 해당하는 하나)를 담아 아래 형태로 1회 경고합니다.

[DataGrid] virtualization.columns가 비활성됐습니다 —
colSpan/rowSpan(셀 병합)과 병용 불가
(윈도우 경계 절단). 전체 렌더로 fallback합니다.

가변 높이 caveat — render-prop 모드

<DataGrid.Body>auto-render 모드(children 없음)일 때는 각 행에 measureElement를 붙여 실측 높이로 보정합니다. function-children(render-prop) 모드에서는 소비자가 만든 노드라 measureElement를 붙일 수 없어, estimateRowHeight고정값으로 사용합니다.

const data = Array.from({ length: 1000 }, (_, i) => ({
id: String(i + 1),
name: '채널 ' + (i + 1),
note: '행 ' + (i + 1) + ' 의 상세 설명입니다. 멀티라인으로 줄바꿈되어 행 높이가 가변적으로 늘어납니다.',
}));

const columns = [
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
  size: 120,
},
{
  accessorKey: 'note',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>메모</DataGrid.HeaderCell>,
  cell: (info) => (
    <DataGrid.Cell cell={info.cell} style={{ whiteSpace: 'normal' }}>
      {info.getValue()}
    </DataGrid.Cell>
  ),
  size: 280,
},
];

function RenderPropVirtualized() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  // render-prop + 가변 높이 + 가상화 → estimateRowHeight를 실제 높이에 맞춰 지정
  virtualization: { rows: true, estimateRowHeight: 64 },
});
return (
  <DataGrid.Root table={table} size="md" $css={{ maxHeight: '420px' }}>
    <DataGrid.Header />
    <DataGrid.Body>
      {(row, rowIndex) => (
        <DataGrid.Row row={row} rowIndex={rowIndex}>
          {row.getVisibleCells().map((cell) => (
            <React.Fragment key={cell.id}>
              {flexRender(cell.column.columnDef.cell, cell.getContext())}
            </React.Fragment>
          ))}
        </DataGrid.Row>
      )}
    </DataGrid.Body>
  </DataGrid.Root>
);
}

render(<RenderPropVirtualized />);

추정치가 실제 높이와 어긋나면 스크롤 위치가 밀립니다. 멀티라인 셀 등 가변 높이 콘텐츠를 render-prop으로 그릴 때는 실제 행 높이에 맞는 estimateRowHeight를 반드시 지정하세요.


외부 스크롤 가상화

그리드를 자체 스크롤 박스가 아니라 바깥 컨테이너(페이지·ancestor) 의 스크롤에 얹어 행을 windowing합니다. 그리드가 페이지 흐름에 들어가는 "무한 피드"형 레이아웃에 씁니다.

virtualization.getScrollElement로 스크롤 주체가 될 element를 돌려주고, DataGrid.Root$css={{ overflow: 'visible' }}로 자체 스크롤을 풉니다. 그러면 헤더 sticky가 바깥 박스에 붙고, 그리드의 컨테이너 내 offset은 scrollMargin으로 자동 보정돼 첫 행이 헤더 바로 아래에 정렬됩니다.

function ExternalScrollGrid({ data, columns }) {
	const [scrollBox, setScrollBox] = useState(null);
	const table = useDataGrid({
		data,
		columns,
		getCoreRowModel: getCoreRowModel(),
		getRowId: (row) => row.id,
		// 행 가상화의 스크롤 소스 = 바깥 Box. 안정적인 element를 반환한다.
		virtualization: { rows: true, getScrollElement: () => scrollBox },
	});

	return (
		<Box ref={setScrollBox} $css={{ maxHeight: '480px', overflow: 'auto' }}>
			{/* Root는 자체 스크롤을 풀어 헤더 sticky가 바깥 Box에 붙는다. */}
			<DataGrid.Root table={table} size="md" $css={{ overflow: 'visible' }}>
				<DataGrid.Header />
				<DataGrid.Body />
			</DataGrid.Root>
		</Box>
	);
}
  • 미지정(기본) 이면 그리드 내부 스크롤 박스를 그대로 씁니다 — 기존 동작과 동일합니다.
  • 상단 offset(페이지 nav 등)은 소비자 CSS 책임입니다.
  • 열 가상화와 pinned 컬럼은 내부 박스 기준을 유지해 영향받지 않습니다.

getScrollElement(행 windowing의 스크롤 소스)와 아래 getScrollContainer(활성 셀 추적의 스크롤 대상)는 다른 옵션입니다. 전자는 useDataGrid({ virtualization })에, 후자는 useDataGridScrollToActive에 전달합니다.


활성 셀 추적 — useDataGridScrollToActive

가상화가 켜진 상태에서 키보드로 화면 밖 셀로 이동하면, 해당 행이 아직 DOM에 없어 포커스가 사라진 것처럼 보일 수 있습니다. useDataGridScrollToActive를 추가하면 selection.focused가 바뀔 때마다 해당 셀을 뷰포트로 자동으로 끌어옵니다(Excel/Google Sheets 패턴).

const data = Array.from({ length: 1000 }, (_, i) => ({
id: String(i + 1),
name: '채널 ' + (i + 1),
followers: 30000 + ((i * 137) % 220) * 1000,
}));

const columns = [
{
  id: 'idx',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>#</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.row.index + 1}</DataGrid.Cell>,
  size: 64,
},
{
  accessorKey: 'name',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>채널</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue()}</DataGrid.Cell>,
},
{
  accessorKey: 'followers',
  header: ({ header }) => <DataGrid.HeaderCell header={header}>팔로워</DataGrid.HeaderCell>,
  cell: (info) => <DataGrid.Cell cell={info.cell}>{info.getValue().toLocaleString()}</DataGrid.Cell>,
},
];

function ScrollToActiveGrid() {
const table = useDataGrid({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  virtualization: { rows: true },
});
const selection = useDataGridSelection(table);
useDataGridScrollToActive(table, { selection }); // opt-in 한 줄

return (
  <DataGrid.Root table={table} selection={selection} size="md" $css={{ maxHeight: '420px' }}>
    <DataGrid.Header />
    <DataGrid.Body />
  </DataGrid.Root>
);
}

render(<ScrollToActiveGrid />);

화면 밖 행/컬럼이 아직 DOM에 없으면, row/column virtualizer의 scrollToIndex로 먼저 렌더시킨 뒤, 다음 프레임에 pinned 컬럼·sticky 헤더 오프셋을 미세 보정합니다.

selection 주입은 필수입니다. 미전달 시 개발 모드에서 경고가 출력되고 훅은 no-op가 됩니다.

외부 스크롤 컨테이너

여기 getScrollContainer활성 셀 추적(scroll-to-active)이 스크롤할 대상을 찾는 옵션입니다. 행 가상화의 windowing 스크롤 소스를 외부로 옮기는 getScrollElement와는 다른 옵션입니다.

그리드가 자체 overflow 대신 외부 박스 안에 임베드된 경우 getScrollContainer로 스크롤할 엘리먼트를 명시합니다. 미지정 시 auto-discovery 순서는 다음과 같습니다.

  1. cellEl.closest('[data-scroll-container]')
  2. cellEl.closest('[data-grid]') — overflow가 scroll/auto일 때만
  3. overflow: auto|scroll|overlay인 첫 번째 ancestor
  4. document.scrollingElement (fallback)
useDataGridScrollToActive(table, {
	selection,
	getScrollContainer: (cellEl) => cellEl.closest('[data-scroll-container]'),
});

<Box data-scroll-container="" $css={{ maxHeight: '420px', overflow: 'auto' }}>
	<DataGrid.Root table={table} selection={selection} size="md">
		<DataGrid.Header />
		<DataGrid.Body />
	</DataGrid.Root>
</Box>

UseDataGridScrollToActiveOptions

Prop

Type


DataGridVirtualization 옵션

useDataGrid({ virtualization: { ... } })에 전달하는 옵션입니다.

Prop

Type


다음 단계

  • View state — skeleton·loading·error 뷰 상태와 가상화 기본 예시.
  • Pinning — pinned 컬럼 설정. 열 가상화와 함께 쓸 때 항상 렌더되는 방식.
  • Grouped headers — 그룹(다단) 헤더. 열 가상화와 병용 불가한 이유.
  • Storybook 예시 — Overview, HeightAndOverscanTuning, ColumnVirtualization, ScrollToActive, RenderPropEstimate, EditableVirtualized, ExternalScrollVirtualization.