Numbro
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { rendererFactory, getRenderer } from 'handsontable/renderers';import { getEditor } from 'handsontable/editors';import { getValidator } from 'handsontable/validators';import { registerCellType } from 'handsontable/cellTypes';import numbro from 'numbro';import languages from 'numbro/dist/languages.min.js';
// Register all Handsontable's modules.registerAllModules();Object.values(languages).forEach((language) => numbro.registerLanguage(language));
function isNumeric(value) { const type = typeof value;
if (type === 'number') { return !isNaN(value) && isFinite(value); } else if (type === 'string') { if (value.length === 0) { return false; } else if (value.length === 1) { return /\d/.test(value); }
const delimiter = Array.from(new Set(['.'])) .map((d) => `\\${d}`) .join('|');
return new RegExp(`^[+-]?(((${delimiter})?\\d+((${delimiter})\\d+)?(e[+-]?\\d+)?)|(0x[a-f\\d]+))$`, 'i').test( value.trim() ); } else if (type === 'object') { return !!value && typeof value.valueOf() === 'number' && !(value instanceof Date); }
return false;}
/* start:skip-in-preview */const data = [ { id: 640329, itemName: 'Lunar Core', itemNo: 'XJ-12', leadEngineer: 'Ellen Ripley', cost: 350000, inStock: true, category: 'Lander', itemQuality: 87, origin: '🇺🇸 USA', quantity: 2, valueStock: 700000, repairable: false, supplierName: 'TechNova', restockDate: '2025-08-01', operationalStatus: 'Awaiting Parts', }, { id: 863104, itemName: 'Zero Thrusters', itemNo: 'QL-54', leadEngineer: 'Sam Bell', cost: 450000, inStock: false, category: 'Propulsion', itemQuality: 0, origin: '🇩🇪 Germany', quantity: 0, valueStock: 0, repairable: true, supplierName: 'PropelMax', restockDate: '2025-09-15', operationalStatus: 'In Maintenance', }, { id: 395603, itemName: 'EVA Suits', itemNo: 'PM-67', leadEngineer: 'Alex Rogan', cost: 150000, inStock: true, category: 'Equipment', itemQuality: 79, origin: '🇮🇹 Italy', quantity: 50, valueStock: 7500000, repairable: true, supplierName: 'SuitCraft', restockDate: '2025-10-05', operationalStatus: 'Ready for Testing', }, { id: 679083, itemName: 'Solar Panels', itemNo: 'BW-09', leadEngineer: 'Dave Bowman', cost: 75000, inStock: true, category: 'Energy', itemQuality: 95, origin: '🇺🇸 USA', quantity: 10, valueStock: 750000, repairable: false, supplierName: 'SolarStream', restockDate: '2025-11-10', operationalStatus: 'Operational', }, { id: 912663, itemName: 'Comm Array', itemNo: 'ZR-56', leadEngineer: 'Louise Banks', cost: 125000, inStock: false, category: 'Communication', itemQuality: 0, origin: '🇯🇵 Japan', quantity: 0, valueStock: 0, repairable: true, supplierName: 'CommTech', restockDate: '2025-12-20', operationalStatus: 'Decommissioned', }, { id: 315806, itemName: 'Habitat Dome', itemNo: 'UJ-23', leadEngineer: 'Dr. Ryan Stone', cost: 1000000, inStock: true, category: 'Shelter', itemQuality: 93, origin: '🇨🇦 Canada', quantity: 3, valueStock: 3000000, repairable: false, supplierName: 'DomeInnovate', restockDate: '2026-01-25', operationalStatus: 'Operational', },];
/* end:skip-in-preview */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1');const cellTypeDefinition = { renderer: rendererFactory(({ hotInstance, td, row, col, prop, value, cellProperties }) => { if (isNumeric(value)) { let classArr = [];
if (Array.isArray(cellProperties.className)) { classArr = cellProperties.className; } else { const className = cellProperties.className ?? '';
if (className.length) { classArr = className.split(' '); } }
const numericFormat = cellProperties.numericFormat; const cellCulture = (numericFormat && numericFormat.culture) || 'en-US'; const cellFormatPattern = numericFormat && numericFormat.pattern;
// Register the language if it's not already registered if (cellCulture && !numbro.languages()[cellCulture]) { const shortTag = cellCulture.replace('-', ''); const langData = numbro.allLanguages ? numbro.allLanguages[cellCulture] : numbro[shortTag];
if (langData) { numbro.registerLanguage(langData); } }
numbro.setLanguage(cellCulture); value = numbro(value).format(cellFormatPattern ?? '0');
if ( classArr.indexOf('htLeft') < 0 && classArr.indexOf('htCenter') < 0 && classArr.indexOf('htRight') < 0 && classArr.indexOf('htJustify') < 0 ) { classArr.push('htRight'); }
if (classArr.indexOf('htNumeric') < 0) { classArr.push('htNumeric'); }
cellProperties.className = classArr.join(' '); td.dir = 'ltr'; }
getRenderer('text')(hotInstance, td, row, col, prop, value, cellProperties); }), validator: getValidator('numeric'), editor: getEditor('numeric'),};
registerCellType('numbro', cellTypeDefinition);
// Define configuration options for the Handsontableconst hotOptions = { data, colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Quantity', 'Cost'], autoRowSize: true, rowHeaders: true, height: 'auto', width: '100%', autoWrapRow: true, headerClassName: 'htLeft', columns: [ { data: 'itemName', type: 'text', width: 130 }, { data: 'category', type: 'text', width: 120 }, { data: 'leadEngineer', type: 'text', width: 150 }, { data: 'quantity', type: 'numbro', width: 150, className: 'htRight', numericFormat: { pattern: '0,0', culture: 'en-US', }, }, { data: 'cost', type: 'numbro', width: 120, className: 'htRight', numericFormat: { pattern: '$0,0.00', culture: 'en-US', }, }, ], licenseKey: 'non-commercial-and-evaluation',};
// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { rendererFactory, getRenderer } from 'handsontable/renderers';import { getEditor } from 'handsontable/editors';import { getValidator } from 'handsontable/validators';import { registerCellType } from 'handsontable/cellTypes';import numbro from 'numbro';import languages from 'numbro/dist/languages.min.js';
// Register all Handsontable's modules.registerAllModules();
Object.values(languages).forEach((language) => numbro.registerLanguage(language));
function isNumeric(value: any): boolean { const type = typeof value;
if (type === 'number') { return !isNaN(value) && isFinite(value);
} else if (type === 'string') { if (value.length === 0) { return false;
} else if (value.length === 1) { return /\d/.test(value); }
const delimiter = Array.from(new Set(['.'])) .map(d => `\\${d}`) .join('|');
return new RegExp(`^[+-]?(((${delimiter})?\\d+((${delimiter})\\d+)?(e[+-]?\\d+)?)|(0x[a-f\\d]+))$`, 'i') .test(value.trim());
} else if (type === 'object') { return !!value && typeof value.valueOf() === 'number' && !(value instanceof Date); }
return false;}
/* start:skip-in-preview */const data = [ { id: 640329, itemName: 'Lunar Core', itemNo: 'XJ-12', leadEngineer: 'Ellen Ripley', cost: 350000, inStock: true, category: 'Lander', itemQuality: 87, origin: '🇺🇸 USA', quantity: 2, valueStock: 700000, repairable: false, supplierName: 'TechNova', restockDate: '2025-08-01', operationalStatus: 'Awaiting Parts', }, { id: 863104, itemName: 'Zero Thrusters', itemNo: 'QL-54', leadEngineer: 'Sam Bell', cost: 450000, inStock: false, category: 'Propulsion', itemQuality: 0, origin: '🇩🇪 Germany', quantity: 0, valueStock: 0, repairable: true, supplierName: 'PropelMax', restockDate: '2025-09-15', operationalStatus: 'In Maintenance', }, { id: 395603, itemName: 'EVA Suits', itemNo: 'PM-67', leadEngineer: 'Alex Rogan', cost: 150000, inStock: true, category: 'Equipment', itemQuality: 79, origin: '🇮🇹 Italy', quantity: 50, valueStock: 7500000, repairable: true, supplierName: 'SuitCraft', restockDate: '2025-10-05', operationalStatus: 'Ready for Testing', }, { id: 679083, itemName: 'Solar Panels', itemNo: 'BW-09', leadEngineer: 'Dave Bowman', cost: 75000, inStock: true, category: 'Energy', itemQuality: 95, origin: '🇺🇸 USA', quantity: 10, valueStock: 750000, repairable: false, supplierName: 'SolarStream', restockDate: '2025-11-10', operationalStatus: 'Operational', }, { id: 912663, itemName: 'Comm Array', itemNo: 'ZR-56', leadEngineer: 'Louise Banks', cost: 125000, inStock: false, category: 'Communication', itemQuality: 0, origin: '🇯🇵 Japan', quantity: 0, valueStock: 0, repairable: true, supplierName: 'CommTech', restockDate: '2025-12-20', operationalStatus: 'Decommissioned', }, { id: 315806, itemName: 'Habitat Dome', itemNo: 'UJ-23', leadEngineer: 'Dr. Ryan Stone', cost: 1000000, inStock: true, category: 'Shelter', itemQuality: 93, origin: '🇨🇦 Canada', quantity: 3, valueStock: 3000000, repairable: false, supplierName: 'DomeInnovate', restockDate: '2026-01-25', operationalStatus: 'Operational', },];/* end:skip-in-preview */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1')!;const cellTypeDefinition = { renderer: rendererFactory(({ hotInstance, td, row, col, prop, value, cellProperties }) => { if (isNumeric(value)) { let classArr = [];
if (Array.isArray(cellProperties.className)) { classArr = cellProperties.className; } else { const className = cellProperties.className ?? '';
if (className.length) { classArr = className.split(' '); } }
const numericFormat = cellProperties.numericFormat; const cellCulture = numericFormat && numericFormat.culture || 'en-US'; const cellFormatPattern = numericFormat && numericFormat.pattern;
// Register the language if it's not already registered if (cellCulture && !numbro.languages()[cellCulture]) { const shortTag = cellCulture.replace('-', ''); const langData = numbro.allLanguages ? numbro.allLanguages[cellCulture] : numbro[shortTag];
if (langData) { numbro.registerLanguage(langData); } }
numbro.setLanguage(cellCulture);
value = numbro(value).format(cellFormatPattern ?? '0');
if ( classArr.indexOf('htLeft') < 0 && classArr.indexOf('htCenter') < 0 && classArr.indexOf('htRight') < 0 && classArr.indexOf('htJustify') < 0 ) { classArr.push('htRight'); }
if (classArr.indexOf('htNumeric') < 0) { classArr.push('htNumeric'); }
cellProperties.className = classArr.join(' '); td.dir = 'ltr'; }
getRenderer('text')(hotInstance, td, row, col, prop, value, cellProperties); }), validator: getValidator('numeric'), editor: getEditor('numeric'),};
registerCellType('numbro', cellTypeDefinition);
// Define configuration options for the Handsontableconst hotOptions: Handsontable.GridSettings = { data, colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Quantity', 'Cost'], autoRowSize: true, rowHeaders: true, height: 'auto', width: '100%', autoWrapRow: true, headerClassName: 'htLeft', columns: [ { data: 'itemName', type: 'text', width: 130 }, { data: 'category', type: 'text', width: 120 }, { data: 'leadEngineer', type: 'text', width: 150 }, { data: 'quantity', type: 'numbro', width: 150, className: 'htRight', numericFormat: { pattern: '0,0', culture: 'en-US', }, }, { data: 'cost', type: 'numbro', width: 120, className: 'htRight', numericFormat: { pattern: '$0,0.00', culture: 'en-US', }, }, ], licenseKey: 'non-commercial-and-evaluation',};
// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);Overview
This guide shows how to create a custom numbro cell type using the Numbro library. Users can format numbers using the Numbro API.
Difficulty: Beginner
Time: ~15 minutes
Libraries: numbro
What You’ll Build
A cell that:
- Displays numbers with locale-aware formatting via Numbro (e.g.,
$350,000.00) - Accepts
numericFormatoptions for per-column formatting customization - Validates input using Handsontable’s built-in numeric validator
- Automatically right-aligns numeric values
Prerequisites
npm install numbroImport Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { rendererFactory, getRenderer } from 'handsontable/renderers';import { getEditor } from 'handsontable/editors';import { getValidator } from 'handsontable/validators';import { registerCellType } from 'handsontable/cellTypes';import numbro from 'numbro';import languages from 'numbro/dist/languages.min.js';registerAllModules();Object.values(languages).forEach((language) => numbro.registerLanguage(language));Why this matters:
numbrohandles locale-aware number formatting (currencies, decimals, thousands separators)rendererFactorycreates a custom renderer that formats values with Numbro before displaying- Registering all Numbro languages upfront enables any
cultureto be used innumericFormat
Create the Numeric Helper
This helper determines whether a value should be treated as a number:
function isNumeric(value) {const type = typeof value;if (type === 'number') {return !isNaN(value) && isFinite(value);} else if (type === 'string') {if (value.length === 0) return false;if (value.length === 1) return /\d/.test(value);const delimiter = Array.from(new Set(['.'])).map(d => `\\${d}`).join('|');return new RegExp(`^[+-]?(((${delimiter})?\\d+((${delimiter})\\d+)?(e[+-]?\\d+)?)|(0x[a-f\\d]+))$`, 'i').test(value.trim());} else if (type === 'object') {return !!value && typeof value.valueOf() === 'number' && !(value instanceof Date);}return false;}Create the Renderer
The renderer formats numeric values using Numbro and delegates to the built-in
textrenderer:renderer: rendererFactory(({ hotInstance, td, row, col, prop, value, cellProperties }) => {if (isNumeric(value)) {const numericFormat = cellProperties.numericFormat;const cellCulture = numericFormat && numericFormat.culture || 'en-US';const cellFormatPattern = numericFormat && numericFormat.pattern;numbro.setLanguage(cellCulture);value = numbro(value).format(cellFormatPattern ?? '0');// Auto-apply htRight alignment for numeric cellstd.dir = 'ltr';}getRenderer('text')(hotInstance, td, row, col, prop, value, cellProperties);})What’s happening:
- Reads
numericFormat.cultureandnumericFormat.patternfrom cell properties - Formats the raw number using
numbro(value).format(pattern) - Auto-applies
htRightalignment unless another alignment class is set - Sets
td.dir = 'ltr'for correct display in RTL layouts - Delegates to the
textrenderer for final DOM output
- Reads
Complete Cell Type Definition
Put all the pieces together and register the cell type:
const cellTypeDefinition = {renderer: rendererFactory(({ hotInstance, td, row, col, prop, value, cellProperties }) => {// ... renderer code from Step 3 (see full example above)}),validator: getValidator('numeric'),editor: getEditor('numeric'),};registerCellType('numbro', cellTypeDefinition);What’s happening:
- renderer: Formats numbers with Numbro and renders as right-aligned text
- validator: Uses the built-in numeric validator to reject non-numeric input
- editor: Uses the built-in numeric editor for input
- registerCellType: Registers the
numbrocell type for use in column config
Use in Handsontable
registerCellType('numbro', cellTypeDefinition);const hotOptions: Handsontable.GridSettings = {data,colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Quantity', 'Cost'],autoRowSize: true,rowHeaders: true,height: 'auto',width: '100%',autoWrapRow: true,headerClassName: 'htLeft',columns: [{ data: 'itemName', type: 'text', width: 130 },{ data: 'category', type: 'text', width: 120 },{ data: 'leadEngineer', type: 'text', width: 150 },{data: 'quantity',type: 'numbro',width: 150,className: 'htRight',numericFormat: {pattern: '0,0',culture: 'en-US',},},{data: 'cost',type: 'numbro',width: 120,className: 'htRight',numericFormat: {pattern: '$0,0.00',culture: 'en-US',},},],licenseKey: 'non-commercial-and-evaluation',};const hot = new Handsontable(container, hotOptions);Key configuration:
type: 'numbro'- uses the custom cell type on Quantity and Cost columnsnumericFormat.pattern- the Numbro format string (e.g.,'$0,0.00'for currency,'0,0'for integers)numericFormat.culture- the locale for formatting (e.g.,'en-US','de-DE')headerClassName: 'htLeft'- left-aligns all column headers
How It Works - Complete Flow
- Initial Render: Cell displays the raw number formatted by Numbro (e.g.,
350000becomes$350,000.00) - User clicks cell: The built-in numeric editor opens for editing
- User enters number: Input is validated against the numeric validator
- Validation: Non-numeric values are rejected; valid numbers are accepted
- Save: The value is stored as a raw number and re-rendered with Numbro formatting