Defined names + Name Manager
An assumptions-driven twelve-month forecast where every formula reads a defined name - =B2*(1+Growth-Churn) - instead of an address. Pick a name in the Name Box to jump to its cell on the Assumptions sheet; open Formulas -> Name Manager to add, repoint or delete names and watch the forecast recompute. Inputs in blue, red negatives in parentheses, break-even found with INDEX / MATCH. (requires @svgrid/enterprise)
A live, editable Svelte 5 data grid example from the SvGrid gallery (Spreadsheet). See the SvGrid documentation for the full API.
About this example
Defined names and a Name Manager on a Svelte 5 spreadsheet grid: an assumptions-driven twelve-month forecast where every formula reads a name, =B2*(1+Growth-Churn), instead of an address. The Name Box lists the names and jumps to a name's cell on the Assumptions sheet; Formulas > Name Manager adds, repoints and deletes names while the forecast recomputes. Inputs in blue, negatives red in parentheses, break-even found with INDEX and MATCH. Enterprise, in @svgrid/enterprise.
The way a finance model is actually built: one sheet of inputs, each with a NAME, and a forecast that reads the names rather than the addresses. =B2*(1+Growth-Churn) says what it does; =B2*(1+$B$5-$B$6) does not, and breaks the day someone inserts a row above the inputs.
Assumptions six inputs, each defined as a name: StartMRR, Growth, Churn, Margin, Opex, OpexGrowth. Forecast twelve months of MRR, gross profit, opex and net, every formula written against the names. Change an input and the twelve months move; the dependency graph follows the name to its cell, so nothing is left stale.
Name Box lists every name; pick one to jump to it, switching to the Assumptions sheet on the way. Name Manager Formulas -> Name Manager (Ctrl+F3). Add, repoint or delete a name and the sheet recomputes: repoint Growth at the "aggressive" cell next to it and month 12 changes.
The dialog is this file's; wb.names is the package's. After the dialog writes to it, sheet.refresh() tells the shell the workbook moved underneath it, since no cell was typed into.
Imports, features and API used
Imports: @svgrid/grid, svelte, @svgrid/grid/shortcuts, @svgrid/enterprise
Frequently asked questions
How is a name defined?
Through Formulas > Name Manager, with a name and the range it refers to, or from code with createNames. Formulas then use the name in place of the address, and the Name Box lists it.
What happens when a name is repointed?
Every formula that reads the name picks up the new cell on the next recalculation, so the forecast follows without editing a single formula.
Do names cross sheets?
Yes. Growth and Churn live on the Assumptions sheet and the forecast on another; a name carries its sheet, so =B2*(1+Growth-Churn) works from anywhere in the workbook.
Related documentation
Related articles
- A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.
- Pivot Tables in Svelte - Summarize Data Without a Spreadsheet - Run a cross-tab pivot directly inside your Svelte app using @svgrid/enterprise createPivotModel - no Excel, no server-side aggregation, no stale exports.
- Spreadsheet-Style Cell Range Selection in SvGrid - How to enable drag-to-select cell ranges, read live selection state, and build a status-bar footer that sums and averages the selected values.
Source code (459-named-ranges-forecast.svelte)
<script lang="ts">
/**
* 459. Defined names: an assumptions-driven forecast
* ---------------------------------------------------
* The way a finance model is actually built: one sheet of inputs, each
* with a NAME, and a forecast that reads the names rather than the
* addresses. `=B2*(1+Growth-Churn)` says what it does; `=B2*(1+$B$5-$B$6)`
* does not, and breaks the day someone inserts a row above the inputs.
*
* Assumptions six inputs, each defined as a name: StartMRR, Growth,
* Churn, Margin, Opex, OpexGrowth.
* Forecast twelve months of MRR, gross profit, opex and net, every
* formula written against the names. Change an input and
* the twelve months move; the dependency graph follows the
* name to its cell, so nothing is left stale.
*
* Name Box lists every name; pick one to jump to it, switching to
* the Assumptions sheet on the way.
* Name Manager Formulas -> Name Manager (Ctrl+F3). Add, repoint or
* delete a name and the sheet recomputes: repoint Growth at
* the "aggressive" cell next to it and month 12 changes.
*
* The dialog is this file's; `wb.names` is the package's. After the
* dialog writes to it, `sheet.refresh()` tells the shell the workbook
* moved underneath it, since no cell was typed into.
*/
import { SvButton, SvModal } from '@svgrid/grid'
import { tick } from 'svelte'
import type { GridCommandContext } from '@svgrid/grid/shortcuts'
import {
SvSheet, createWorkbook, isValidName, parseA1,
type CellFormatEntry, type RibbonActionId, type DefinedName,
} from '@svgrid/enterprise'
const INPUTS: ReadonlyArray<readonly [label: string, name: string, base: string, aggressive: string]> = [
['Starting MRR', 'StartMRR', '42000', '42000'],
['Monthly growth', 'Growth', '0.06', '0.11'],
['Monthly churn', 'Churn', '0.015', '0.02'],
['Gross margin', 'Margin', '0.78', '0.78'],
['Monthly opex', 'Opex', '38000', '46000'],
['Opex growth', 'OpexGrowth', '0.02', '0.035'],
]
const assumptions = [
['Assumptions'],
['Each input is a defined name. The Forecast sheet reads the names, not the addresses.'],
[],
['Input', 'Base case', 'Aggressive', 'Name'],
...INPUTS.map(([label, name, base, aggressive]) => [label, base, aggressive, name]),
]
const MONTHS = ['Oct 2026', 'Nov 2026', 'Dec 2026', 'Jan 2027', 'Feb 2027', 'Mar 2027',
'Apr 2027', 'May 2027', 'Jun 2027', 'Jul 2027', 'Aug 2027', 'Sep 2027']
const forecast = [
['Month', 'MRR', 'Gross profit', 'Opex', 'Net', 'Cumulative', 'Profitable'],
...MONTHS.map((month, i) => {
const r = i + 2
return [
month,
i === 0 ? '=StartMRR' : `=B${r - 1}*(1+Growth-Churn)`,
`=B${r}*Margin`,
i === 0 ? '=Opex' : `=D${r - 1}*(1+OpexGrowth)`,
`=C${r}-D${r}`,
i === 0 ? `=E${r}` : `=F${r - 1}+E${r}`,
`=IF(E${r}>0,"yes","")`,
]
}),
[],
['First profitable month', '=IFERROR(INDEX(A2:A13,MATCH("yes",G2:G13,0)),"not within 12 months")'],
['Exit MRR', '=B13'],
['Twelve-month net', '=SUM(E2:E13)'],
['Peak burn', '=MIN(F2:F13)'],
]
const wb = createWorkbook([
{ name: 'Forecast', cells: forecast },
{ name: 'Assumptions', cells: assumptions },
])
INPUTS.forEach(([, name], i) => wb.names.define(name, `=Assumptions!$B$${i + 5}`))
// ---- formats ------------------------------------------------------------
const BAND = { bold: true, fill: '#e2e8f0', color: '#0f172a' } as const
const INPUT = { color: '#1d4ed8' } as const
const MONEY = { numFmt: '$#,##0' } as const
const PCT = { numFmt: '0.0%' } as const
const A = 'Assumptions!'
const col = (sheet: string, letter: string, from: number, to: number, entry: CellFormatEntry) =>
Object.fromEntries(Array.from({ length: to - from + 1 }, (_, i) => [`${sheet}${letter}${from + i}`, entry]))
const formats: Record<string, CellFormatEntry> = {
// Forecast
...Object.fromEntries([...'ABCDEFG'].map((c) => [`${c}1`, BAND])),
...col('', 'B', 2, 13, MONEY), ...col('', 'C', 2, 13, MONEY), ...col('', 'D', 2, 13, MONEY),
...col('', 'E', 2, 13, { numFmt: '$#,##0;[Red]($#,##0)' }),
...col('', 'F', 2, 13, { numFmt: '$#,##0;[Red]($#,##0)' }),
...col('', 'G', 2, 13, { align: 'center', color: '#15803d' }),
A15: { bold: true }, A16: { bold: true }, A17: { bold: true }, A18: { bold: true },
B16: MONEY, B17: { numFmt: '$#,##0;[Red]($#,##0)' }, B18: { numFmt: '$#,##0;[Red]($#,##0)' },
// Assumptions
[`${A}A1`]: { bold: true, fontSize: 17 },
[`${A}A2`]: { italic: true, color: '#64748b' },
...Object.fromEntries([...'ABCD'].map((c) => [`${A}${c}4`, BAND])),
[`${A}B5`]: { ...INPUT, ...MONEY }, [`${A}C5`]: MONEY,
[`${A}B6`]: { ...INPUT, ...PCT }, [`${A}C6`]: PCT,
[`${A}B7`]: { ...INPUT, ...PCT }, [`${A}C7`]: PCT,
[`${A}B8`]: { ...INPUT, ...PCT }, [`${A}C8`]: PCT,
[`${A}B9`]: { ...INPUT, ...MONEY }, [`${A}C9`]: MONEY,
[`${A}B10`]: { ...INPUT, ...PCT }, [`${A}C10`]: PCT,
...col(A, 'D', 5, 10, { fontFamily: 'ui-monospace, Menlo, monospace', color: '#64748b' }),
}
// ---- the Name Manager ---------------------------------------------------
let sheet = $state<ReturnType<typeof SvSheet>>()
let open = $state(false)
let names = $state<DefinedName[]>([])
let newName = $state('')
let newRefersTo = $state('')
let editing = $state<string | null>(null)
let editRefersTo = $state('')
let problem = $state<string | null>(null)
let cmdAtOpen: GridCommandContext | null = null
function reload() {
names = wb.names.list()
}
// Returning true takes the action over: the shell's own Name Manager
// stays closed and this one, the worked example, opens instead.
function onAction(action: RibbonActionId, cmd: GridCommandContext): boolean {
if (action !== 'name-manager') return false
cmdAtOpen = cmd
reload()
newName = ''
newRefersTo = ''
editing = null
problem = null
open = true
return true
}
/** What a name is worth right now, for the dialog's Value column. */
function valueOf(entry: DefinedName): string {
const text = entry.refersTo.replace(/^=/, '')
const bang = text.lastIndexOf('!')
const sheetName = bang >= 0 ? text.slice(0, bang).replace(/^'(.*)'$/, '$1') : wb.active
const address = bang >= 0 ? text.slice(bang + 1) : text
const [first, last] = address.split(':')
const from = parseA1(first ?? '')
if (!from || from.row === null) return ''
if (last) {
const to = parseA1(last)
if (to && to.row !== null) {
const cells = (to.row - from.row + 1) * (to.col - from.col + 1)
return `${cells} cells`
}
}
const v = wb.getValue(sheetName, from.row, from.col)
if (typeof v === 'object' && v !== null) return v.error
if (typeof v === 'number') return v.toLocaleString('en-US', { maximumFractionDigits: 4 })
return String(v)
}
/** Accept `Assumptions!B6`, `=Assumptions!$B$6`, `B6` or a range. */
function normalise(refersTo: string): string | null {
const text = refersTo.trim().replace(/^=/, '')
if (text === '') return null
const bang = text.lastIndexOf('!')
const address = bang >= 0 ? text.slice(bang + 1) : text
const [first, last] = address.split(':')
if (!parseA1(first ?? '')) return null
if (last !== undefined && !parseA1(last)) return null
return `=${bang >= 0 ? text.slice(0, bang + 1) : `${wb.active}!`}${address}`
}
function changed() {
// Redefining a name recalculates the workbook, but no cell was typed
// into, so the shell is told the document moved underneath it.
sheet?.refresh()
reload()
}
function add() {
const name = newName.trim()
if (!isValidName(name)) { problem = `"${name}" is not a valid name: letters, digits and underscores, not a cell address`; return }
if (wb.names.has(name)) { problem = `${name} is already defined; edit it below instead`; return }
const refersTo = normalise(newRefersTo)
if (!refersTo) { problem = `"${newRefersTo}" is not a cell or range`; return }
wb.names.define(name, refersTo)
newName = ''
newRefersTo = ''
problem = null
changed()
}
function startEdit(entry: DefinedName) {
editing = entry.name
editRefersTo = entry.refersTo
problem = null
}
function saveEdit() {
if (!editing) return
const refersTo = normalise(editRefersTo)
if (!refersTo) { problem = `"${editRefersTo}" is not a cell or range`; return }
wb.names.define(editing, refersTo)
editing = null
problem = null
changed()
}
function remove(name: string) {
wb.names.remove(name)
changed()
}
async function close() {
open = false
// The modal returns focus to the ribbon button as it unmounts; the sheet
// is where the next keystroke belongs.
await tick()
cmdAtOpen?.focus()
}
</script>
<section class="wrap flex flex-col flex-1 min-h-0">
<SvSheet
bind:this={sheet}
workbook={wb}
height="100%"
rows={20}
columns={8}
columnWidths={{ A: 180, B: 120, C: 120, D: 120, E: 120, F: 120, G: 90 }}
{formats}
{onAction}
/>
<p class="note shrink-0">
Every formula on <strong>Forecast</strong> reads a name. Open the Name Box
and pick <code>Growth</code>: it switches to Assumptions and lands on the
cell. Change it to 0.08 and come back. Then <strong>Formulas -> Name
Manager</strong>: point <code>Growth</code> at <code>Assumptions!C6</code>,
the aggressive case, and watch month 12.
</p>
</section>
<SvModal bind:open onClose={close} title="Name Manager" size="md">
<div class="manager">
<table class="names">
<thead>
<tr><th>Name</th><th>Refers to</th><th>Value</th><th></th></tr>
</thead>
<tbody>
{#each names as entry (entry.name)}
<tr>
<td class="mono">{entry.name}</td>
<td class="mono">
{#if editing === entry.name}
<input
type="text"
bind:value={editRefersTo}
aria-label="Refers to"
onkeydown={(e) => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') editing = null }}
/>
{:else}
{entry.refersTo}
{/if}
</td>
<td class="num">{valueOf(entry)}</td>
<td class="actions">
{#if editing === entry.name}
<SvButton size="sm" onclick={saveEdit}>Save</SvButton>
<SvButton size="sm" variant="ghost" onclick={() => (editing = null)}>Cancel</SvButton>
{:else}
<SvButton size="sm" variant="outline" onclick={() => startEdit(entry)}>Edit</SvButton>
<SvButton size="sm" variant="ghost" onclick={() => remove(entry.name)}>Delete</SvButton>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="4" class="empty">No defined names. Add one below.</td></tr>
{/each}
</tbody>
</table>
<form class="new" onsubmit={(e) => { e.preventDefault(); add() }}>
<label>
<span>Name</span>
<input type="text" bind:value={newName} placeholder="TaxRate" spellcheck="false" />
</label>
<label>
<span>Refers to</span>
<input type="text" bind:value={newRefersTo} placeholder="Assumptions!B11 or B2:B13" spellcheck="false" />
</label>
<SvButton type="submit" variant="outline">New</SvButton>
</form>
{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
</div>
{#snippet footer()}
<SvButton onclick={close}>Close</SvButton>
{/snippet}
</SvModal>
<style>
.wrap { gap: 8px; }
.note { margin: 0; font-size: 13px; line-height: 1.6; color: var(--sg-muted, #64748b); }
.note code {
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
padding: 1px 4px;
border-radius: 4px;
background: var(--sg-header-bg, #f1f5f9);
color: var(--sg-fg, #0f172a);
}
.manager { display: flex; flex-direction: column; gap: 12px; font-size: 13px; color: var(--sg-fg, #0f172a); }
.names { width: 100%; border-collapse: collapse; }
.names th {
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--sg-muted, #64748b);
padding: 4px 8px;
border-bottom: 1px solid var(--sg-border, #e2e8f0);
}
.names td { padding: 5px 8px; border-bottom: 1px solid var(--sg-border, #e2e8f0); vertical-align: middle; }
.mono { font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; }
.num { font-variant-numeric: tabular-nums; text-align: right; }
.actions { display: flex; gap: 4px; justify-content: flex-end; white-space: nowrap; }
.empty { text-align: center; color: var(--sg-muted, #64748b); }
.names input, .new input {
width: 100%;
box-sizing: border-box;
font: inherit;
font-family: ui-monospace, Menlo, monospace;
font-size: 12.5px;
padding: 5px 8px;
border: 1px solid var(--sg-input-border, #cbd5e1);
border-radius: 6px;
background: var(--sg-input-bg, #fff);
color: var(--sg-fg, #0f172a);
}
.new { display: grid; grid-template-columns: 1fr 1.6fr auto; gap: 8px; align-items: end; }
.new label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--sg-muted, #64748b); }
.problem { margin: 0; font-size: 12.5px; color: var(--sg-danger, #dc2626); }
@media (max-width: 560px) {
.new { grid-template-columns: 1fr; }
}
</style>More Spreadsheet examples
- Spreadsheet + Ribbon bar - The whole Excel surface as one component, <SvSheet workbook={wb} />: a six-tab ribbon (Home, Insert, Formulas, Data, Review, View), the Name Box and fx bar, sheet tabs and the Sum / Average / Count status bar. Format cells, merge them, comment on them, validate what goes in, colour them by rule, filter the region, protect the sheet; every button is the same call as its shortcut, so Ctrl+B and the Bold button cannot drift. A two-sheet P&L with the formats travelling in the document.
- 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 spreadsheet-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.
- 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.
- 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).