Number formats and cell styles

In Excel, format is a property of the cell, not the column. Two cells in the same column can show $1,234.50 and 123450% from the same stored number. @svgrid/enterprise ships that: an Excel format-string compiler, and a store that keeps per-cell formatting alive through sorting.

import {
  compileNumberFormat, createFormatStore, entryToStyle,
} from '@svgrid/enterprise/sheet'

Open the live example: Formula bar + cell formats (Spreadsheet)

This is different from the column-level format prop, which is Intl-based and applies to every cell in a column. Use that when a column has one meaning; use this when the user picks per cell.

Formatting a value

const money = compileNumberFormat('$#,##0.00;($#,##0.00)')

money.format(1234.5)    // { text: '$1,234.50' }
money.format(-1234.5)   // { text: '($1,234.50)' }

Compilation is cached per pattern, so a column of ten thousand cells sharing one format parses it once. formatWithPattern(value, pattern) is the one-shot convenience.

The pattern grammar

Sections

A pattern has up to four sections separated by ;:

positive ; negative ; zero ; text

With two, the second covers negatives and zero. With one, it covers everything.

A negative rendered by its own section uses its absolute value, because that section supplies the sign:

formatWithPattern(-1.5, '0.00;(0.00)')   // '(1.50)'  not '(-1.50)'

Digit placeholders

Token Meaning
0 A digit, or a zero if there is none
# A digit, or nothing
? A digit, or a space (so decimal points line up)
formatWithPattern(5, '000')       // '005'
formatWithPattern(0, '#')         // ''      hides zeros
formatWithPattern(1.5, '0.0#')    // '1.5'   second decimal only when present
formatWithPattern(1.55, '0.0#')   // '1.55'

Separators and scaling

, between placeholders groups thousands. , immediately before the decimal point or at the end of the pattern divides by a thousand per comma:

formatWithPattern(1234567, '#,##0')      // '1,234,567'
formatWithPattern(1500000, '0,,"M"')     // '2M'
formatWithPattern(1500, '0.0,"k"')       // '1.5k'

% multiplies by 100 and prints the sign. E+ switches to scientific, where the placeholders after the E+ size the exponent rather than the mantissa:

formatWithPattern(0.425, '0.00%')        // '42.50%'
formatWithPattern(12345, '0.00E+00')     // '1.23E+04'

Colours and literals

[Red], [Blue], [Green], [Black], [White], [Cyan], [Magenta] and [Yellow] set a colour rather than printing. It comes back on the result:

compileNumberFormat('0.00;[Red](0.00)').format(-5)
// { text: '(5.00)', color: '#ff0000' }

Text in quotes, or after a backslash, is emitted as-is. @ in the fourth section is the text placeholder.

_x (leave the width of x) and *x (fill the cell with x) both render as one space: text has no cell width to leave or fill. That is enough for Excel's accounting patterns to read as $ 1,234.50 with the symbol on the left and the number on the right.

A condition in brackets picks a section by value: the first section whose condition the value passes is used, and a section without one is the fallback. [<=9999999]###-####;(###) ###-#### is Excel's phone number.

formatWithPattern(50, '[<100]"small";"big"')    // 'small'
formatWithPattern(5551234567, '[<=9999999]###-####;(###) ###-####')
// '(555) 123-4567'

Masks

Literal text between integer placeholders turns the integer side into a mask that the digits are laid into from the right. Excel's Special formats are all masks:

formatWithPattern(123456789, '000-00-0000')   // '123-45-6789'
formatWithPattern(1234, '00000')              // '01234'
formatWithPattern(1234, '###-####')           // '-1234'   as Excel gives

SPECIAL_FORMATS holds the four: zip, zip4, phone and ssn.

Dates

A pattern containing date tokens is a date pattern.

Token Gives
yyyy yy 2026, 26
mmmm mmm mm m September, Sep, 09, 9
dddd ddd dd d Monday, Mon, 14, 14
mmmmm S (the month's first letter)
hh h ss s Hours and seconds
AM/PM am/pm A/P a/p Meridiem, in the case and length the token is written in
.0 .00 .000 after seconds Fractional seconds

m and mm mean minutes after an hour token and months otherwise, so hh:mm gives 15:05 rather than 15:09.

A pattern that names the meridiem puts the hour on a 12-hour clock, as a clock face reads it, and one that does not leaves it on the 24-hour one:

formatWithPattern(46275.625, 'h:mm AM/PM')   // '3:00 PM'
formatWithPattern(46275.625, 'h:mm')         // '15:00'
formatWithPattern(46275.625, 'h:mm A/P')     // '3:00 P'

Times are rounded to the finest unit the pattern shows, which is Excel's rule and the reason 23:59:40 under hh:mm reads 00:00 with the date rolled over. A date-only pattern truncates instead: an afternoon is not tomorrow.

Numbers are read as Excel serial days against the 1899-12-30 epoch. That is correct from 1900-03-01 on; Excel itself is a day out below serial 61 because it counts a 1900-02-29 that never existed, and matching that bug exactly would break real dates.

Elapsed time

[h], [m] and [s] (and [hh], [mm], [ss]) give the whole duration in that unit rather than the clock's reading of it, which is what a timesheet adds up:

formatWithPattern(1.5, '[h]:mm')       // '36:00'   a day and a half
formatWithPattern(1.5, 'h:mm')         // '12:00'   the clock, for comparison
formatWithPattern(0.5, '[mm]:ss')      // '720:00'

Fractions

# ?/? and its relatives show the closest fraction that fits the placeholders: one ? allows a denominator up to 9, two up to 99. A literal denominator is used as it stands, unreduced, which is how eighths and sixteenths are written.

formatWithPattern(1.25, '# ?/?')       // '1 1/4'
formatWithPattern(5.0625, '# ??/??')   // '5  1/16'
formatWithPattern(1.25, '# ?/8')       // '1 2/8'
formatWithPattern(1.25, '?/?')         // '5/4'     no integer part

Text

@ is the text placeholder, and it stands for the value whatever the value is: a number in a cell formatted as Text reads as the number.

formatWithPattern('note', '@')         // 'note'
formatWithPattern(5, '@')              // '5'
formatWithPattern('note', '"x"@')      // 'xnote'
formatWithPattern(5, ';;;')            // ''        Excel's hide-the-cell
formatWithPattern('note', ';;;')       // ''        text as well
formatWithPattern('note', '0.00')      // 'note'    no text section, so as it is

Scientific and engineering notation

The integer placeholders set the step the exponent moves in. One of them is the everyday scientific form; three make it engineering notation, where the exponent is a multiple of three and the mantissa carries up to three integer digits, which is how an engineer reads a datasheet.

formatWithPattern(12345, '0.00E+00')   // '1.23E+04'
formatWithPattern(12345, '##0.0E+0')   // '12.3E+3'
formatWithPattern(0.000123, '##0.0E+0')// '123.0E-6'

Presets

FORMAT_PRESETS holds what Ctrl+Shift+1 through 6 apply: number, time, date, currency, percent, scientific, plus general and accounting, which the ribbon's $ button applies, as Excel's does. percent is 0%, Excel's Percent Style: Ctrl+Shift+% and the % button show 0.425 as 43%. The ribbon's Number Format combo is the one place that differs, as it does in Excel: its Percentage entry applies 0.00%, from FORMAT_CATEGORY_PATTERNS.

accountingPattern(symbol, decimals) spells an accounting pattern for any symbol (an empty one is Excel's "None"), and accountingParts reads one back, which is how Increase Decimal works on an accounting cell:

accountingPattern('$', 2)
// '_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)'
formatWithPattern(1234.5, accountingPattern('$', 2))   // ' $ 1,234.50 '
formatWithPattern(-1234.5, accountingPattern('$', 2))  // ' $ (1,234.50)'

The per-cell store

const store = createFormatStore()

const lookup = {
  rowIdAt: (i) => rows[i]?.id ?? null,
  columnIdAt: (i) => FIELDS[i] ?? null,
}

store.set([[0, 2, 4, 3]], { numFmt: '$#,##0.00' }, lookup)
store.toggle([[0, 0, 0, 4]], 'bold', lookup)
store.get('r1', 'price')     // { numFmt: '$#,##0.00' }

Ranges are [minRow, minCol, maxRow, maxCol] in display coordinates, the same as cmd.ranges. The store converts them to row and column ids on the way in, which is what makes formatting survive a sort: keying by display index means sorting leaves the bold on whatever row now sits at that position.

Method Does
set(rects, patch, at) Merge. A field set to undefined is removed.
clear(rects, at) Remove every entry in the range.
toggle(rects, field, at) Excel's rule: all-on turns off, mixed turns on.
forgetRow(id) / forgetColumn(id) Call when one is deleted, or entries leak.
serialize() / hydrate() The only persistence path.

entryToStyle(entry) renders one to inline CSS for a cell renderer.

Wiring the shortcuts

Ctrl+B, Ctrl+Shift+4 and the rest write through whatever store you attach. Until you attach one they decline, so the key falls through to the grid rather than looking broken:

import { enableSheet, setFormatTarget } from '@svgrid/enterprise'

enableSheet()
setFormatTarget({ store, lookup, onChange: () => (version += 1) })

Ctrl+1 calls setFormatDialogHandler(fn) if you registered one. The shortcut layer ships no dialog: what a Format Cells dialog should look like is a design decision, not a keyboard one.

Exporting

Per-cell formats round-trip to xlsx through the exporter's existing cellVisual hook, so a sheet formatted in the browser opens formatted in Excel. See export.

See also

Related articles

  • Conditional Formatting - Color Cells by Their Value - Four rule types, one prop - add heatmaps, data bars, icon sets, and threshold highlights to any SvGrid column without custom cell renderers.
  • Status Badge Cells in SvGrid - Turn a plain text status column into colored, accessible pill badges without breaking sort, filter, or virtualization - using SvGrid's snippet cell renderer.
  • Sparkline Cells in a Svelte Data Grid - Show inline trend sparklines inside grid cells using SvGrid's built-in sparkline column property - no charting library needed, just a field that holds a number array.