Column Groups
Create group headers with three or more levels from a tree of column IDs, and use them safely across frozen-column boundaries.
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>
);
}.column-groups-example-grid .bgrid-head-group-cell.column-groups-header-overview {
background-color: #dbeafe;
color: #1e3a8a;
}
.column-groups-example-grid .bgrid-head-group-cell.column-groups-header-customer {
background-color: #dcfce7;
color: #166534;
}
.column-groups-example-grid .bgrid-head-cell.column-groups-header-total {
--bgrid-header-hover-bg: #fef08a;
background-color: #fef9c3;
color: #854d0e;
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}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 fromitem.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 incolumnGroupswith 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:statuskey: ['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
columnsorder - 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.