Column Groups

Create group headers with three or more levels from a tree of column IDs, and use them safely across frozen-column boundaries.

#columnGroups#BGridColumnGroupNode#nested-header#frozenColumnIndex#headerHeight
Last reviewed: 2026-08-19
GitHub
import * as React from 'react';
import { BGrid } from 'beautiful-grid';
import type { BGridColumn, BGridColumnGroupNode } from 'beautiful-grid';
import { Select } from 'antd';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
import './ColumnsGroupExample.css';

interface Order {
  orderNo: string;
  customerName: string;
  region: string;
  productName: string;
  category: string;
  quantity: number;
  unitPrice: number;
  total: number;
}

const data = Array.from({ length: 100 }, (_, index) => {
  const quantity = (index % 8) + 1;
  const unitPrice = 12000 + (index % 5) * 3500;
  return {
    values: {
      orderNo: `ORD-${String(2401 + index).padStart(4, '0')}`,
      customerName: ['서울상사', '한빛물산', 'Northwind'][index % 3],
      region: ['서울', '부산', '대전'][index % 3],
      productName: ['Workspace Pro', 'Analytics Seat', 'Automation Pack'][index % 3],
      category: ['Software', 'License', 'Service'][index % 3],
      quantity,
      unitPrice,
      total: quantity * unitPrice,
    },
  };
});

const columnGroups: BGridColumnGroupNode[] = [
  {
    id: 'order-overview',
    label: '주문 현황',
    className: 'column-groups-header-overview',
    children: [
      {
        id: 'order-customer',
        label: '주문·고객 정보',
        children: [
          'orderNo',
          {
            id: 'customer-detail',
            label: '고객 상세',
            className: 'column-groups-header-customer',
            children: ['customerName', 'region'],
          },
        ],
      },
      {
        id: 'product-sales',
        label: '상품·매출 정보',
        children: [
          {
            id: 'product-detail',
            label: '상품 상세',
            children: ['productName', 'category'],
          },
          {
            id: 'sales-detail',
            label: '매출 상세',
            headerStyle: {
              backgroundColor: '#ffedd5',
              color: '#9a3412',
            },
            children: ['quantity', 'unitPrice', 'total'],
          },
        ],
      },
    ],
  },
];

const initialColumns: BGridColumn<Order>[] = [
  { id: 'orderNo', key: 'orderNo', label: '주문 번호', width: 140 },
  { id: 'customerName', key: 'customerName', label: '고객명', width: 150 },
  { id: 'region', key: 'region', label: '지역', width: 100, align: 'center' },
  { id: 'productName', key: 'productName', label: '상품', width: 170 },
  { id: 'category', key: 'category', label: '분류', width: 120, align: 'center' },
  { id: 'quantity', key: 'quantity', label: '수량', width: 90, align: 'right' },
  {
    id: 'unitPrice',
    key: 'unitPrice',
    label: '단가',
    width: 120,
    align: 'right',
    itemRender: ({ value }) => <>{Number(value).toLocaleString()}원</>,
  },
  {
    id: 'total',
    key: 'total',
    label: '합계',
    width: 140,
    align: 'right',
    headerClassName: 'column-groups-header-total',
    itemRender: ({ value }) => <strong>{Number(value).toLocaleString()}원</strong>,
  },
];

export default function ColumnsGroupExample() {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);
  const [columns, setColumns] = React.useState(initialColumns);
  const [groups, setGroups] = React.useState(columnGroups);
  const [frozenColumnIndex, setFrozenColumnIndex] = React.useState(4);
  const frozenBoundaryColumn = columns[frozenColumnIndex - 1];

  return (
    <div className='flex min-h-0 flex-col gap-3'>
      <div className='flex flex-wrap items-center justify-between gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700'>
        <label className='inline-flex items-center gap-2 font-medium'>
          <span>틀고정 경계</span>
          <Select<number>
            aria-label='틀고정 위치'
            style={{ minWidth: 210 }}
            value={frozenColumnIndex}
            options={[
              { value: 0, label: '고정 없음' },
              ...columns.map((column, index) => ({
                value: index + 1,
                label: `${index + 1}개 · ${column.label} 뒤`,
              })),
            ]}
            onChange={setFrozenColumnIndex}
          />
        </label>
        <p className='m-0 text-slate-600' aria-live='polite'>
          {frozenColumnIndex > 0 && frozenBoundaryColumn ? (
            <>
              앞쪽 {frozenColumnIndex}개 컬럼을 고정했습니다. <strong>{frozenBoundaryColumn.label}</strong> 뒤가
              경계입니다.
            </>
          ) : (
            '현재는 고정 컬럼 없이 모든 컬럼이 함께 스크롤됩니다.'
          )}
        </p>
      </div>

      <DataGridContainer ref={containerRef} style={{ height: 560 }}>
        <BGrid<Order>
          className='column-groups-example-grid'
          width={width}
          height={height}
          data={data}
          frozenColumnIndex={frozenColumnIndex}
          headerHeight={96}
          itemHeight={24}
          itemPadding={6}
          columns={columns}
          columnGroups={groups}
          columnSortable
          onChangeColumns={(_, info) => {
            setColumns(info.columns);
            if (info.columnGroups) setGroups(info.columnGroups);
          }}
          rowChecked={{ checkedIndexes: [], onChange: () => undefined }}
          showLineNumber
        />
      </DataGridContainer>
    </div>
  );
}

Define groups as a tree

Keep columns as a flat array that determines rendering order. Use columnGroups to create a tree that references column IDs. A group can contain column IDs and other groups at any depth.

const columns: BGridColumn<Order>[] = [
  { id: 'orderNo', key: 'orderNo', label: 'Order number', width: 140 },
  { id: 'customerName', key: 'customerName', label: 'Customer name', width: 150 },
  { id: 'region', key: 'region', label: 'Region', width: 100 },
  { id: 'productName', key: 'productName', label: 'Product', width: 170 },
];

const columnGroups: BGridColumnGroupNode[] = [
  {
    id: 'order-overview',
    label: 'Order overview',
    children: [
      'orderNo',
      {
        id: 'customer',
        label: 'Customer information',
        children: [
          {
            id: 'customer-detail',
            label: 'Customer details',
            children: ['customerName', 'region'],
          },
          'productName',
        ],
      },
    ],
  },
];

<BGrid columns={columns} columnGroups={columnGroups} headerHeight={88} {...props} />;

Style header cells

Style leaf columns with headerClassName or headerStyle, and group nodes with className or headerStyle. Classes are useful for managing related rules such as hover states and themes, while headerStyle is convenient for a simple dynamic style on one cell. The same classes and styles are applied to headers duplicated in the frozen-column area.

const columns: BGridColumn<Order>[] = [
  {
    id: 'total',
    key: 'total',
    label: 'Total',
    width: 140,
    headerClassName: 'order-grid-header-total',
  },
];

const columnGroups: BGridColumnGroupNode[] = [
  {
    id: 'sales',
    label: 'Sales information',
    className: 'order-grid-header-sales',
    headerStyle: { color: '#166534' },
    children: ['total'],
  },
];

<BGrid className='order-grid' columns={columns} columnGroups={columnGroups} {...props} />;
.order-grid .bgrid-head-group-cell.order-grid-header-sales {
  background-color: #dcfce7;
}

.order-grid .bgrid-head-cell.order-grid-header-total {
  --bgrid-header-hover-bg: #fef08a;

  background-color: #fef9c3;
  color: #854d0e;
}

When both headerAlign and headerStyle.textAlign are set, the dedicated alignment property headerAlign takes precedence. To customize the hover background of a sortable leaf header, redefine --bgrid-header-hover-bg in the cell class.

Understand column key and id

key and id may look similar, but they serve different purposes.

  • key: The data path used to read a cell value from item.values. Use a string for a top-level field, such as 'status', and an array of strings for a nested field, such as ['customer', 'address', 'city'].
  • id: A stable, unique identifier that the grid uses to distinguish columns. It connects leaf references in columnGroups with sorting and filtering state; it does not affect the path used to read data.

Columns that present the same data field in different ways can therefore share a key while using different id values. Every id must be unique across the column set.

const columns: BGridColumn<Order>[] = [
  { id: 'amount-raw', key: 'amount', label: 'Amount', width: 120 },
  { id: 'amount-with-tax', key: 'amount', label: 'Amount including tax', width: 140 },
  {
    id: 'customer-city',
    key: ['customer', 'address', 'city'],
    label: 'Customer city',
    width: 120,
  },
];

If you omit id, the library serializes key to create an internal columnId.

  • key: 'status'key:string:status
  • key: ['customer', 'name']key:array:["customer","name"]

A string leaf in columnGroups refers to this final column ID, not the original key. For example, a column declared as { key: 'status' } without an id must be referenced as children: ['key:string:status']; children: ['status'] will not find it. To avoid depending on the generated format, explicitly set id on columns that belong to a group.

const columns = [{ id: 'status', key: 'status', label: 'Status', width: 100 }];

const columnGroups = [{ id: 'order-state', label: 'Order status', children: ['status'] }];

Here, the group node’s id: 'order-state' identifies the group itself, while 'status' in children refers to the column’s id.

Calculate rows and spans

The deepest group determines the number of header rows. A leaf column that ends at a shallower level fills the remaining rows with rowSpan, while each group uses the number of leaf columns it actually contains as its colSpan. Set headerHeight to at least 22px per header row. The grid emits a development warning when the height is insufficient.

Cross a frozen-column boundary

No additional configuration is required when a group crosses the frozenColumnIndex boundary. The same group label is rendered in both the frozen and scrollable areas, and each area’s colSpan is calculated from the leaf columns it actually contains. The live demo above includes a group that crosses the frozen boundary.

Validate the configuration

The following configurations emit a development warning and fall back to a safe single-row header:

  • A column ID that does not exist
  • The same column referenced in more than one location
  • An empty group or a duplicate group ID
  • A leaf order that differs from the actual columns order
  • A non-contiguous group that skips an intermediate column

When columnSortable is enabled, users can reorder only leaf columns that are direct children of the same parent group. Moving a group itself or moving a leaf into another parent group is blocked. If you use a controlled columns array, update both info.columns and info.columnGroups in onChangeColumns.

Maintain compatibility with columnsGroup

The index-range-based columnsGroup API continues to work for existing applications, but it is deprecated.

<BGrid columns={columns} columnsGroup={[{ label: 'Document information', groupStartIndex: 1, groupEndIndex: 3 }]} />

When both APIs are provided, columnGroups takes precedence. For new screens, use columnGroups: it supports arbitrary depth and remains safe when the column order changes.