Cell Merge
Learn how to visually merge identical values in adjacent rows to create readable, grouped report tables.
import { t } from './i18n';
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';
interface InventoryItem {
mainCategory: string;
subCategory: string;
itemCode: string;
itemName: string;
unitPrice: number;
stockQty: number;
warehouse: string;
}
const inventoryRows: InventoryItem[] = [
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('컴퓨터 주변기기', 'Computer Peripherals'), itemCode: 'IT-001', itemName: t('프리미엄 무선 키보드', 'Premium Wireless Keyboard'), unitPrice: 129000, stockQty: 84, warehouse: 'A-01' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('컴퓨터 주변기기', 'Computer Peripherals'), itemCode: 'IT-002', itemName: t('인체공학 마우스', 'Ergonomic Mouse'), unitPrice: 69000, stockQty: 46, warehouse: 'A-01' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('컴퓨터 주변기기', 'Computer Peripherals'), itemCode: 'IT-003', itemName: t('USB-C 멀티 허브', 'USB-C Multi Hub'), unitPrice: 89000, stockQty: 31, warehouse: 'A-02' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('모니터/디스플레이', 'Monitor/Display'), itemCode: 'IT-004', itemName: t('27인치 QHD 모니터', '27-inch QHD Monitor'), unitPrice: 389000, stockQty: 18, warehouse: 'B-01' },
{ mainCategory: t('가전/디지털', 'Home Appliances/Digital'), subCategory: t('모니터/디스플레이', 'Monitor/Display'), itemCode: 'IT-005', itemName: t('32인치 4K 모니터', '32-inch 4K Monitor'), unitPrice: 629000, stockQty: 9, warehouse: 'B-01' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('사무용 가구', 'Office Furniture'), itemCode: 'FN-001', itemName: t('모션 데스크 1400', 'Motion Desk 1400'), unitPrice: 459000, stockQty: 22, warehouse: 'C-01' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('사무용 가구', 'Office Furniture'), itemCode: 'FN-002', itemName: t('인체공학 메시 의자', 'Ergonomic Mesh Chair'), unitPrice: 329000, stockQty: 37, warehouse: 'C-01' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('수납 가구', 'Storage Furniture'), itemCode: 'FN-003', itemName: t('이동식 서랍장', 'Mobile Drawer'), unitPrice: 119000, stockQty: 41, warehouse: 'C-02' },
{ mainCategory: t('가구/인테리어', 'Furniture/Interior'), subCategory: t('수납 가구', 'Storage Furniture'), itemCode: 'FN-004', itemName: t('5단 철제 선반', '5-tier steel shelf'), unitPrice: 149000, stockQty: 26, warehouse: 'C-02' },
{ mainCategory: t('생활/주방', 'Living/Kitchen'), subCategory: t('홈카페', 'Home Cafe'), itemCode: 'KT-001', itemName: t('전자동 커피머신', 'Fully Automatic Coffee Machine'), unitPrice: 749000, stockQty: 12, warehouse: 'D-01' },
{ mainCategory: t('생활/주방', 'Living/Kitchen'), subCategory: t('홈카페', 'Home Cafe'), itemCode: 'KT-002', itemName: t('온도조절 전기포트', 'Temperature Control Electric Kettle'), unitPrice: 99000, stockQty: 53, warehouse: 'D-01' },
{ mainCategory: t('생활/주방', 'Living/Kitchen'), subCategory: t('조리도구', 'Cooking Utensils'), itemCode: 'KT-003', itemName: t('스테인리스 팬 세트', 'Stainless Steel Pan Set'), unitPrice: 189000, stockQty: 29, warehouse: 'D-02' },
];
const data: BGridDataItem<InventoryItem>[] = inventoryRows.map(values => ({ values }));
function CellMergeExample() {
const [columns, setColumns] = React.useState<BGridColumn<InventoryItem>[]>([
{ key: 'mainCategory', label: t('대분류', 'Large Category'), width: 130, align: 'center' },
{ key: 'subCategory', label: t('중분류', 'Medium Category'), width: 150, align: 'center' },
{ key: 'itemCode', label: t('품목코드', 'Item Code'), width: 100, align: 'center' },
{ key: 'itemName', label: t('품목명', 'Item Name'), width: 220 },
{
key: 'unitPrice',
label: t('단가', 'Unit Price'),
width: 120,
align: 'right',
itemRender: ({ values }) => <>{values.unitPrice.toLocaleString()}{t('원', 'KRW')}</>,
},
{ key: 'stockQty', label: t('재고', 'Stock'), width: 80, align: 'right', itemRender: ({ values }) => <>{values.stockQty}{t('개', 'ea')}</> },
{ key: 'warehouse', label: t('창고', 'Warehouse'), width: 90, align: 'center' },
]);
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
return (
<DataGridContainer ref={containerRef}>
<BGrid<InventoryItem>
showLineNumber
frozenColumnIndex={2}
width={width}
height={height}
data={data}
columns={columns}
rowKey='itemCode'
onChangeColumns={(_columnIndex, { columns }) => setColumns(columns)}
cellMergeOptions={{
columnsMap: {
0: { mergeBy: 'mainCategory' },
1: { mergeBy: 'subCategory' },
},
}}
variant='vertical-bordered'
/>
</DataGridContainer>
);
}
export default CellMergeExample;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,
};
}1. When and why should you use cell merging?
Management dashboards, settlement reports, and inventory summaries often repeat the same values—such as category, subcategory, or owner—across several rows. That repetition can make a table harder to scan.
BeautifulGrid’s cell merge feature lets you:
- Detect consecutive adjacent rows with the same value and display their cells as a visual
rowspan. - Apply the same
columnsMapmerge rules to regular and frozen columns. - Configure each merge rule with
mergeByso that it follows the sort order of the data.
2. Complete example: sales items grouped by category
The following example merges cells in the Category and Subcategory columns:
import React, { useState } from 'react';
import { BGrid, type BGridColumn, type BGridDataItem } from 'beautiful-grid';
interface CategoryItem {
mainCategory: string;
subCategory: string;
itemCode: string;
itemName: string;
unitPrice: number;
stockQty: number;
}
export default function CategoryMergeGrid() {
const [data] = useState<BGridDataItem<CategoryItem>[]>([
{ values: { mainCategory: 'Electronics', subCategory: 'Computer accessories', itemCode: 'IT-01', itemName: 'Wireless keyboard', unitPrice: 45000, stockQty: 120 } },
{ values: { mainCategory: 'Electronics', subCategory: 'Computer accessories', itemCode: 'IT-02', itemName: 'Gaming mouse', unitPrice: 38000, stockQty: 85 } },
{ values: { mainCategory: 'Electronics', subCategory: 'Monitors and displays', itemCode: 'IT-03', itemName: '27-inch 4K monitor', unitPrice: 420000, stockQty: 30 } },
{ values: { mainCategory: 'Electronics', subCategory: 'Monitors and displays', itemCode: 'IT-04', itemName: '32-inch curved monitor', unitPrice: 580000, stockQty: 15 } },
{ values: { mainCategory: 'Furniture and interiors', subCategory: 'Office furniture', itemCode: 'FN-01', itemName: '1400 mm sit-stand desk', unitPrice: 350000, stockQty: 25 } },
{ values: { mainCategory: 'Furniture and interiors', subCategory: 'Office furniture', itemCode: 'FN-02', itemName: 'Ergonomic mesh chair', unitPrice: 280000, stockQty: 40 } },
]);
const columns: BGridColumn<CategoryItem>[] = [
{ key: 'mainCategory', label: 'Category', width: 140, align: 'center' },
{ key: 'subCategory', label: 'Subcategory', width: 160, align: 'center' },
{ key: 'itemCode', label: 'Item code', width: 100, align: 'center' },
{ key: 'itemName', label: 'Item name', width: 200 },
{
key: 'unitPrice',
label: 'Unit price',
width: 120,
align: 'right',
itemRender: ({ values }) => `KRW ${values.unitPrice.toLocaleString('en-US')}`,
},
{
key: 'stockQty',
label: 'In stock',
width: 90,
align: 'right',
itemRender: ({ values }) => `${values.stockQty} units`,
},
];
return (
<div>
<BGrid<CategoryItem>
width={810}
height={320}
columns={columns}
data={data}
rowKey="itemCode"
frozenColumnIndex={2} // Freeze Category and Subcategory on the left
// Configure cell merging
cellMergeOptions={{
columnsMap: {
0: { mergeBy: 'mainCategory' }, // Merge Category cells with the same mainCategory value
1: { mergeBy: 'subCategory' }, // Merge Subcategory cells with the same subCategory value
},
}}
headerHeight={34}
itemHeight={30}
/>
</div>
);
}
3. cellMergeOptions specification
type CellMergeOptions = {
columnsMap: {
[columnIndex: number]: BGridCellMergeColumn;
};
};
interface BGridCellMergeColumn {
wordWrap?: boolean;
mergeBy: string | string[]; // Data key used to determine equality
}
columnIndex: The zero-based index of the column to merge.mergeBy: The data field whose values are compared between adjacent rows.
4. Practical tips and caveats
[!IMPORTANT] Sort the data before merging: Cells merge only when consecutive adjacent rows have the same value. If a row with
mainCategory: 'Furniture'appears between rows whosemainCategoryis'Electronics', the merge is interrupted. Sort the data by the merge keys before passing it to the grid so that each group remains contiguous.
[!TIP] Frozen merged columns on mobile screens: Freezing multiple merged columns (
frozenColumnIndex) on narrow mobile screens can obscure the horizontal scrollable area. Consider reducing or removing frozen columns on mobile viewports.