Cell merging (spreadsheet shell)
A real invoice rendered on an Excel-style shell: A / B / C / D / E column letters across the top, row numbers down the left. Brand band, bill-from / bill-to address blocks, meta block, line items, totals, notes, signatures - all assembled from MergeSpec + CellBorderSpec. Editable Qty / Rate / addresses / notes; totals recompute live.
A live, editable Svelte 5 data grid example. Open the interactive demo or read the documentation.
What this example shows
Opens like Excel: row gutter 1..1000, column letters A..Z. The first ~21 rows hold a full invoice (brand banner, bill-from / bill-to blocks, meta block, line items, totals, notes box, signature lines) assembled from `MergeSpec` + `CellBorderSpec`. Below the invoice the sheet is empty - free typing space.
Editable: line-item Qty / Rate (column C / D), addresses, meta values, notes - free text. The invoice's amount, subtotal, tax and total recompute on every commit.
Source code (170-cell-merging.svelte)
<script lang="ts">
/**
* 170. Cell merging - invoice region on a real spreadsheet
* ---------------------------------------------------------
* Opens like Excel: row gutter 1..1000, column letters A..Z. The
* first ~21 rows hold a full invoice (brand banner, bill-from /
* bill-to blocks, meta block, line items, totals, notes box,
* signature lines) assembled from `MergeSpec` + `CellBorderSpec`.
* Below the invoice the sheet is empty - free typing space.
*
* Editable: line-item Qty / Rate (column C / D), addresses, meta
* values, notes - free text. The invoice's amount, subtotal, tax
* and total recompute on every commit.
*/
import { tick } from 'svelte'
import {
SvGrid,
tableFeatures,
renderSnippet,
spreadsheetLayout,
type ColumnDef,
type MergeSpec,
type CellBorderSpec,
type SvGridApi,
} from '@svgrid/grid'
let gridWrapper: HTMLDivElement | null = null
function focusGrid() {
const tableEl = gridWrapper?.querySelector<HTMLElement>('table.sv-grid-table')
tableEl?.focus({ preventScroll: true })
}
function maybeRefocusGrid(e: MouseEvent) {
const target = e.target as HTMLElement | null
if (!target) return
if (target.closest('input, button, select, textarea, [tabindex]')) return
tick().then(focusGrid)
}
const COL_LETTERS = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] as const
type ColKey = typeof COL_LETTERS[number]
type Row = { rn: number; kind: Kind } & Record<ColKey, string | number>
type Kind =
| 'blank' | 'brand' | 'spacer' | 'addressLabel' | 'addressBody' | 'meta'
| 'itemsHeader' | 'item' | 'subtotal' | 'tax' | 'grandTotal'
| 'notesBanner' | 'notes' | 'signLabel' | 'signLine'
const TOTAL_ROWS = 1000
function emptyRow(rn: number): Row {
const r: Record<string, unknown> = { rn, kind: 'blank' }
for (const c of COL_LETTERS) r[c] = ''
return r as Row
}
// Build the seed sheet with the invoice in rows 1..21 (0-indexed 0..20)
function buildSeed(): Row[] {
const out = Array.from({ length: TOTAL_ROWS }, (_, i) => emptyRow(i + 1))
const set = (rowOneBased: number, kind: Kind, partial: Partial<Record<ColKey, string | number>>) => {
const r = out[rowOneBased - 1]!
r.kind = kind
for (const [k, v] of Object.entries(partial)) (r as any)[k] = v
}
// Row 1 - brand band
set(1, 'brand', { A: 'NORTHWIND INDUSTRIES INVOICE' })
set(2, 'spacer', {})
set(3, 'addressLabel', { A: 'BILL FROM', C: 'BILL TO' })
set(4, 'addressBody', {
A: 'Northwind Industries Inc.\n1208 Market St, Suite 400\nChicago, IL 60607\[email protected]',
C: 'Acme Robotics LLC\n55 Innovation Way\nAustin, TX 78701\[email protected]',
})
set(5, 'spacer', {})
set(6, 'meta', { C: 'Invoice #', D: 'INV-2026-104' })
set(7, 'meta', { C: 'Issue date', D: '2026-06-22' })
set(8, 'meta', { C: 'Due date', D: '2026-07-22' })
set(9, 'itemsHeader', { A: 'Description', B: 'Qty', C: 'Rate', D: 'Amount' })
set(10,'item', { A: 'Engineering hours - Backend', B: 84, C: 175, D: 0 })
set(11,'item', { A: 'Engineering hours - Frontend', B: 62, C: 175, D: 0 })
set(12,'item', { A: 'Design hours - UI / UX', B: 28, C: 160, D: 0 })
set(13,'item', { A: 'Project management', B: 12, C: 150, D: 0 })
set(14,'item', { A: 'Cloud infrastructure (monthly)',B: 1, C: 980, D: 0 })
set(15,'subtotal', { A: 'Subtotal', D: 0 })
set(16,'tax', { A: 'Tax (8.25%)', D: 0 })
set(17,'grandTotal', { A: 'TOTAL DUE', D: 0 })
set(18,'notesBanner', { A: 'NOTES' })
set(19,'notes', { A: 'Wire transfer or ACH preferred. Bank details on request. Late payments accrue interest at 1.5% per month.' })
set(20,'signLabel', { A: 'Authorized signature', C: 'Customer signature' })
set(21,'signLine', {})
return out
}
let rows = $state<Row[]>(buildSeed())
const TAX_RATE = 0.0825
function recompute() {
let subtotal = 0
for (const r of rows) {
if (r.kind === 'item') {
const qty = typeof r.B === 'number' ? r.B : Number(r.B) || 0
const rate = typeof r.C === 'number' ? r.C : Number(r.C) || 0
r.D = qty * rate
subtotal += qty * rate
}
}
for (const r of rows) {
if (r.kind === 'subtotal') r.D = subtotal
if (r.kind === 'tax') r.D = subtotal * TAX_RATE
if (r.kind === 'grandTotal') r.D = subtotal * (1 + TAX_RATE)
}
}
recompute()
function onCellValueChange(e: { rowIndex: number; columnId: string; newValue: unknown }) {
const col = e.columnId as ColKey
const r = rows[e.rowIndex]!
if ((col === 'B' || col === 'C') && r.kind === 'item' && typeof e.newValue === 'string') {
const n = Number(e.newValue.trim())
if (Number.isFinite(n)) (r as any)[col] = n
}
recompute()
rows = [...rows]
}
const features = tableFeatures({})
let api = $state<SvGridApi<typeof features, Row> | null>(null)
function isInvoiceItem(rowIdx: number, col: ColKey): boolean {
const r = rows[rowIdx]
if (!r) return false
if (r.kind === 'item' && (col === 'B' || col === 'C')) return true
if (r.kind === 'addressBody' && (col === 'A' || col === 'C')) return true
if (r.kind === 'meta' && col === 'D') return true
if (r.kind === 'notes' && col === 'A') return true
return false
}
const columns: ColumnDef<typeof features, Row>[] = [
{ field: 'rn', header: '', width: 56, align: 'center', editable: false,
cellClass: 'sv-row-gutter',
cell: (ctx) => renderSnippet(RowNumCell, { row: ctx.row.original }) },
{ field: 'A', header: 'A', width: 280, align: 'left',
editable: (ctx) => ctx.row.original.kind === 'blank'
|| ctx.row.original.kind === 'addressBody'
|| ctx.row.original.kind === 'item'
|| ctx.row.original.kind === 'notes',
editorType: 'text',
cell: (ctx) => renderSnippet(ACell, { row: ctx.row.original, rowIdx: ctx.row.index }) },
{ field: 'B', header: 'B', width: 90, align: 'right',
editable: (ctx) => ctx.row.original.kind === 'blank' || ctx.row.original.kind === 'item',
editorType: 'number',
cell: (ctx) => renderSnippet(SimpleCell, { row: ctx.row.original, value: ctx.row.original.B }) },
{ field: 'C', header: 'C', width: 160, align: 'right',
editable: (ctx) => ctx.row.original.kind === 'blank'
|| ctx.row.original.kind === 'item'
|| ctx.row.original.kind === 'addressBody'
|| ctx.row.original.kind === 'meta',
editorType: 'text',
cell: (ctx) => renderSnippet(CCell, { row: ctx.row.original }) },
{ field: 'D', header: 'D', width: 170, align: 'right',
editable: (ctx) => ctx.row.original.kind === 'blank' || ctx.row.original.kind === 'meta',
editorType: 'text',
cell: (ctx) => renderSnippet(DCell, { row: ctx.row.original }) },
...COL_LETTERS.filter((c) => c !== 'A' && c !== 'B' && c !== 'C' && c !== 'D').map((c) => ({
field: c,
header: c,
width: 90,
align: 'left' as const,
editorType: 'text' as const,
})),
]
const columnOrder = columns.map((c) => c.field as string)
// --- Merges (only for the invoice region, rows 0..20) -------------
const merges: MergeSpec[] = [
// Brand band: A..F
{ rowIndex: 0, columnId: 'A', colspan: 6 },
{ rowIndex: 1, columnId: 'A', colspan: 6 },
{ rowIndex: 4, columnId: 'A', colspan: 6 },
// Address labels: A+B / C+D+E+F
{ rowIndex: 2, columnId: 'A', colspan: 2 },
{ rowIndex: 2, columnId: 'C', colspan: 4 },
// Address bodies
{ rowIndex: 3, columnId: 'A', colspan: 2 },
{ rowIndex: 3, columnId: 'C', colspan: 4 },
// Meta value cell: D + E + F
{ rowIndex: 5, columnId: 'D', colspan: 3 },
{ rowIndex: 6, columnId: 'D', colspan: 3 },
{ rowIndex: 7, columnId: 'D', colspan: 3 },
// Item rows: amount D + E + F
...[9, 10, 11, 12, 13].map((r) => ({ rowIndex: r, columnId: 'D', colspan: 3 } as MergeSpec)),
// Totals: label spans A..C, amount D + E + F
{ rowIndex: 14, columnId: 'A', colspan: 3 }, { rowIndex: 14, columnId: 'D', colspan: 3 },
{ rowIndex: 15, columnId: 'A', colspan: 3 }, { rowIndex: 15, columnId: 'D', colspan: 3 },
{ rowIndex: 16, columnId: 'A', colspan: 3 }, { rowIndex: 16, columnId: 'D', colspan: 3 },
// Notes banner + body: A..F
{ rowIndex: 17, columnId: 'A', colspan: 6 },
{ rowIndex: 18, columnId: 'A', colspan: 6 },
// Signatures: A+B+C / D+E+F
{ rowIndex: 19, columnId: 'A', colspan: 3 }, { rowIndex: 19, columnId: 'D', colspan: 3 },
{ rowIndex: 20, columnId: 'A', colspan: 3 }, { rowIndex: 20, columnId: 'D', colspan: 3 },
]
// --- Borders ------------------------------------------------------
const accent = '#0f172a'
const muted = '#cbd5e1'
const brand = '#2563eb'
const borders: CellBorderSpec[] = [
// Brand band heavy bottom
{ rowIndex: 0, columnId: 'A', bottom: { width: 3, color: brand } },
// Address labels: bottom underline
{ rowIndex: 2, columnId: 'A', bottom: { width: 1, color: muted } },
{ rowIndex: 2, columnId: 'C', bottom: { width: 1, color: muted } },
// Address body boxes: 4-edge thin frame
{ rowIndex: 3, columnId: 'A',
top: { width: 1, color: muted }, bottom: { width: 1, color: muted },
left: { width: 1, color: muted }, right: { width: 1, color: muted } },
{ rowIndex: 3, columnId: 'C',
top: { width: 1, color: muted }, bottom: { width: 1, color: muted },
left: { width: 1, color: muted }, right: { width: 1, color: muted } },
// Meta rows: bottom border on C + D
...[5, 6, 7].flatMap((r) => ([
{ rowIndex: r, columnId: 'C', bottom: { width: 1, color: muted } } as CellBorderSpec,
{ rowIndex: r, columnId: 'D', bottom: { width: 1, color: muted } } as CellBorderSpec,
])),
// Items header: top + bottom
...['A','B','C','D'].map((col) => ({
rowIndex: 8, columnId: col,
top: { width: 2, color: accent } as const,
bottom: { width: 2, color: accent } as const,
} as CellBorderSpec)),
// Subtotal + tax bottom hairlines on D
{ rowIndex: 14, columnId: 'D', top: { width: 1, color: accent }, bottom: { width: 1, color: muted } },
{ rowIndex: 15, columnId: 'D', bottom: { width: 1, color: muted } },
// Grand total Excel-style
{ rowIndex: 16, columnId: 'D',
top: { width: 2, color: accent },
bottom: { width: 3, style: 'double', color: accent } },
// Notes box outline
{ rowIndex: 17, columnId: 'A',
top: { width: 1, color: muted }, left: { width: 1, color: muted }, right: { width: 1, color: muted } },
{ rowIndex: 18, columnId: 'A',
bottom: { width: 1, color: muted }, left: { width: 1, color: muted }, right: { width: 1, color: muted } },
// Signature top lines
{ rowIndex: 20, columnId: 'A', top: { width: 1, color: accent } },
{ rowIndex: 20, columnId: 'D', top: { width: 1, color: accent } },
]
</script>
{#snippet RowNumCell({ row }: { row: Row })}
<span class="row-num">{row.rn}</span>
{/snippet}
{#snippet ACell({ row, rowIdx }: { row: Row; rowIdx: number })}
<span
class="inv-desc"
class:is-brand={row.kind === 'brand'}
class:is-addressLabel={row.kind === 'addressLabel'}
class:is-addressBody={row.kind === 'addressBody'}
class:is-itemsHeader={row.kind === 'itemsHeader'}
class:is-subtotal={row.kind === 'subtotal'}
class:is-tax={row.kind === 'tax'}
class:is-grand={row.kind === 'grandTotal'}
class:is-notesBanner={row.kind === 'notesBanner'}
class:is-notes={row.kind === 'notes'}
class:is-signLabel={row.kind === 'signLabel'}
>{row.A}</span>
{/snippet}
{#snippet SimpleCell({ row, value }: { row: Row; value: number | string })}
<span class:cell-itemsHeader={row.kind === 'itemsHeader'}>{value === '' ? '' : value}</span>
{/snippet}
{#snippet CCell({ row }: { row: Row })}
{#if row.kind === 'item' && typeof row.C === 'number'}
<span>{Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(row.C)}</span>
{:else if row.kind === 'addressLabel'}
<span class="inv-addr-label">{row.C}</span>
{:else if row.kind === 'addressBody'}
<span class="inv-addr-body">{row.C}</span>
{:else if row.kind === 'meta'}
<span class="inv-meta-label">{row.C}</span>
{:else if row.kind === 'itemsHeader'}
<span class="cell-itemsHeader">{row.C}</span>
{:else if row.kind === 'signLabel'}
<span class="inv-sign-label">{row.C}</span>
{:else}
<span>{row.C === '' ? '' : row.C}</span>
{/if}
{/snippet}
{#snippet DCell({ row }: { row: Row })}
{#if row.kind === 'meta'}
<span class="inv-meta-value">{row.D}</span>
{:else if row.kind === 'itemsHeader'}
<span class="cell-itemsHeader">{row.D}</span>
{:else if typeof row.D === 'number' && row.D !== 0 && (row.kind === 'item' || row.kind === 'subtotal' || row.kind === 'tax' || row.kind === 'grandTotal')}
<span
class:inv-amount-total={row.kind === 'subtotal' || row.kind === 'tax'}
class:inv-amount-grand={row.kind === 'grandTotal'}
>{Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(row.D)}</span>
{:else}
<span>{row.D === '' ? '' : row.D}</span>
{/if}
{/snippet}
<section class="flex flex-col flex-1 min-h-0 gap-3 no-zebra">
<header>
<h2 class="text-base font-semibold">Cell merging on a real spreadsheet</h2>
<p class="text-xs mt-1" style="color: var(--sg-muted);">
Spreadsheet shell: row numbers 1..{TOTAL_ROWS}, columns A..Z. Rows 1..21 hold a full
invoice built from <code>MergeSpec</code> + <code>CellBorderSpec</code> (brand band,
bill-from / bill-to blocks, meta block, line items, totals, notes, signatures). Edit any
<em>Qty</em> or <em>Rate</em> in the line items, or any address / meta value - amounts,
subtotal, tax and TOTAL DUE recompute live. Type anywhere below row 21 - blank cells
behave like an empty sheet.
</p>
</header>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="inv-wrap"
bind:this={gridWrapper}
onclick={maybeRefocusGrid}
use:spreadsheetLayout={{ merges, borders, columnOrder }}
>
<SvGrid responsive={true}
data={rows}
columns={columns}
features={features}
sortable={false}
filterable={false}
selectionMode="cell"
showColumnFilters={false}
showPagination={false}
showRowSelection={false}
enableInlineEditing={true}
enableCellSelection={true}
enableRowSummaries={false}
rowHeight={28}
containerHeight="100%"
fitColumns={false}
virtualization={true}
columnVirtualization={true}
onApiReady={(next) => { api = next; tick().then(focusGrid) }}
onCellValueChange={onCellValueChange}
/>
</div>
</section>
<style>
.inv-wrap {
flex: 1;
min-height: 0;
border: 1px solid var(--sg-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
background: var(--sg-bg, #ffffff);
}
/* Row-number gutter: cell stays interactive so :hover fires, but
every visual side-effect of selecting it is suppressed. */
:global(.sv-grid-cell.sv-row-gutter) {
cursor: default;
}
:global(.sv-grid-cell.sv-row-gutter.sv-grid-cell-active),
:global(.sv-grid-cell.sv-row-gutter[data-selected-range="true"]) {
box-shadow: none !important;
}
:global(.sv-grid-cell.sv-row-gutter .sv-grid-fill-handle) {
display: none !important;
}
/* Spreadsheet UX: no zebra, no row hover - only the row + column
headers tint on hover. */
:global(.no-zebra .sv-grid-table tbody tr:nth-child(even) .sv-grid-cell),
:global(.no-zebra .sv-grid-table tbody tr:nth-child(odd) .sv-grid-cell),
:global(.no-zebra .sv-grid-table tbody tr:hover .sv-grid-cell) {
background: transparent !important;
}
:global(.no-zebra .sv-grid-table tbody tr .sv-grid-cell.sv-row-gutter) {
background: var(--sg-header-bg, #f1f5f9) !important;
}
:global(.no-zebra .sv-grid-table tbody tr .sv-grid-cell.sv-row-gutter:hover) {
background: var(--sg-row-hover-bg, #e2e8f0) !important;
}
:global(.no-zebra .sv-grid-column:hover) {
background: var(--sg-row-hover-bg, #e2e8f0) !important;
}
:global(.row-num) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 11px;
font-weight: 600;
color: var(--sg-muted, #64748b);
}
/* Hide the column-menu hamburger + sort + filter triggers on the
row-header column header. */
:global([data-svgrid-header-col="rn"] .sv-grid-col-menu-btn),
:global([data-svgrid-header-col="rn"] .sv-grid-col-filter-btn),
:global([data-svgrid-header-col="rn"] .sv-grid-header-sort) {
display: none !important;
}
:global(.inv-desc) {
color: var(--sg-fg, #0f172a);
font-size: 13px;
white-space: pre-line;
line-height: 1.45;
display: inline-block;
width: 100%;
}
:global(.inv-desc.is-brand) {
font-size: 16px;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--sg-accent, #2563eb);
padding: 4px 12px;
}
:global(.inv-desc.is-addressLabel),
:global(.inv-addr-label) {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--sg-muted, #64748b);
}
:global(.inv-desc.is-addressBody),
:global(.inv-addr-body) {
font-size: 11.5px;
line-height: 1.5;
color: var(--sg-fg, #0f172a);
white-space: pre-line;
padding: 4px 8px;
display: inline-block;
width: 100%;
}
:global(.inv-desc.is-itemsHeader),
:global(.cell-itemsHeader) {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sg-fg, #0f172a);
}
:global(.inv-meta-label) {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--sg-muted, #64748b);
}
:global(.inv-meta-value) {
font-size: 12px;
font-weight: 600;
color: var(--sg-fg, #0f172a);
}
:global(.inv-desc.is-subtotal),
:global(.inv-desc.is-tax) {
text-align: right;
width: 100%;
display: inline-block;
font-weight: 600;
color: var(--sg-fg, #0f172a);
}
:global(.inv-desc.is-grand) {
text-align: right;
width: 100%;
display: inline-block;
font-weight: 800;
font-size: 14px;
color: var(--sg-fg, #0f172a);
}
:global(.inv-amount-total) { font-weight: 700; }
:global(.inv-amount-grand) { font-weight: 800; font-size: 14px; color: var(--sg-fg, #0f172a); }
:global(.inv-desc.is-notesBanner) {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--sg-accent, #2563eb);
background: color-mix(in oklab, var(--sg-accent, #2563eb) 8%, transparent);
padding: 4px 10px;
display: inline-block;
width: 100%;
}
:global(.inv-desc.is-notes) {
font-size: 12px;
color: var(--sg-muted, #475569);
padding: 4px 10px;
line-height: 1.5;
}
:global(.inv-desc.is-signLabel),
:global(.inv-sign-label) {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--sg-muted, #64748b);
}
</style>Related documentation
More Spreadsheet examples
- Spreadsheet + Ribbon bar - Excel-style Ribbon UI driving the grid via SvGridApi: cell formatting (bold, color, number format), insert/delete row, sort, live SUM/AVG/COUNT.
- Spreadsheet + formulas - Real formula engine inside the grid: cell refs (A1), ranges (A1:A10), SUM / AVG / IF / COUNTIF / ROUND, arithmetic, string concat, cycle detection.
- Per-cell custom borders (KPI) - Editable KPI scorecard. spreadsheetLayout paints HOT-style per-edge custom borders via an absolute-positioned overlay (no border-collapse conflicts). Edit any quarter or target - the borders re-derive: green double = beat target, blue solid = hit, amber dotted = near miss, red dashed = bad miss; row champion gets a colored full frame.
- HyperFormula integration - Full HyperFormula engine wired into the grid as a peer-optional dep. Editable spreadsheet with A1-style cell refs, dozens of formulas across math (SUM / SUMIF), lookup (VLOOKUP / INDEX-MATCH), text (CONCAT / UPPER), date (TODAY / DATEDIF), logical (IF nests), financial (PMT / IRR / NPV), statistical (AVERAGE / MAX / RANK).
- Blank sheet - just type - An empty Excel-style sheet on a plain <SvGrid>: column-letter headers (A..Z), a built-in 1..N row gutter, a name box + formula bar with a browsable function picker, gridlines, range selection and a fill handle. A real HyperFormula engine underneath: type a literal or a formula like =SUM(B2:D2) / =IF(...) and every dependent cell recalculates live. Drag a row or column border to resize; right-click for Cut / Copy / Paste / Clear.