Numbro
This tutorial shows you how to create a custom numeric cell type using the Numbro library for locale-aware number formatting.
import { HotTable, HotColumn } from '@handsontable/react-wrapper';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));
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;}
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;
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);
/* 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 */
const ExampleComponent = () => { return ( <HotTable data={data} colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Quantity', 'Cost']} autoRowSize={true} rowHeaders={true} height="auto" width="100%" autoWrapRow={true} headerClassName="htLeft" licenseKey="non-commercial-and-evaluation" > <HotColumn data="itemName" type="text" width={130} /> <HotColumn data="category" type="text" width={120} /> <HotColumn data="leadEngineer" type="text" width={150} /> <HotColumn data="quantity" type="numbro" width={150} className="htRight" numericFormat={{ pattern: '0,0', culture: 'en-US' }} /> <HotColumn data="cost" type="numbro" width={120} className="htRight" numericFormat={{ pattern: '$0,0.00', culture: 'en-US' }} /> </HotTable> );};
export default ExampleComponent;import { HotTable, HotColumn } from '@handsontable/react-wrapper';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';// numbro ships no type declarations for its bundled language pack.// @ts-expect-error -- untyped moduleimport languages from 'numbro/dist/languages.min.js';
registerAllModules();Object.values(languages).forEach((language) => numbro.registerLanguage(language as numbro.NumbroLanguage));
function isNumeric(value: unknown): boolean { const type = typeof value;
if (type === 'number') { return !isNaN(value as number) && isFinite(value as number); } else if (type === 'string') { const str = value as string;
if (str.length === 0) { return false; } else if (str.length === 1) { return /\d/.test(str); }
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( str.trim() ); } else if (type === 'object') { return !!value && typeof (value as object).valueOf() === 'number' && !(value instanceof Date); }
return false;}
const cellTypeDefinition = { renderer: rendererFactory(({ hotInstance, td, row, col, prop, value, cellProperties }) => { if (isNumeric(value)) { let classArr: string[] = [];
if (Array.isArray(cellProperties.className)) { classArr = cellProperties.className as string[]; } else { const className = (cellProperties.className as string) ?? '';
if (className.length) { classArr = className.split(' '); } }
const numericFormat = cellProperties.numericFormat as { culture?: string; pattern?: string } | undefined; const cellCulture = (numericFormat && numericFormat.culture) || 'en-US'; const cellFormatPattern = numericFormat && numericFormat.pattern;
if (cellCulture && !numbro.languages()[cellCulture]) { const shortTag = cellCulture.replace('-', ''); const langData = (numbro as any).allLanguages ? (numbro as any).allLanguages[cellCulture] : (numbro as any)[shortTag];
if (langData) { numbro.registerLanguage(langData); } }
numbro.setLanguage(cellCulture); value = numbro(value as number).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);
/* start:skip-in-preview */interface InventoryItem { id: number; itemName: string; itemNo: string; leadEngineer: string; cost: number; inStock: boolean; category: string; itemQuality: number; origin: string; quantity: number; valueStock: number; repairable: boolean; supplierName: string; restockDate: string; operationalStatus: string;}
const data: InventoryItem[] = [ { 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 */
const ExampleComponent = () => { return ( <HotTable data={data} colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Quantity', 'Cost']} autoRowSize={true} rowHeaders={true} height="auto" width="100%" autoWrapRow={true} headerClassName="htLeft" licenseKey="non-commercial-and-evaluation" > <HotColumn data="itemName" type="text" width={130} /> <HotColumn data="category" type="text" width={120} /> <HotColumn data="leadEngineer" type="text" width={150} /> <HotColumn data="quantity" type="numbro" width={150} className="htRight" numericFormat={{ pattern: '0,0', culture: 'en-US' }} /> <HotColumn data="cost" type="numbro" width={120} className="htRight" numericFormat={{ pattern: '$0,0.00', culture: 'en-US' }} /> </HotTable> );};
export default ExampleComponent;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';// numbro ships no type declarations for its bundled language pack.// @ts-expect-error -- untyped moduleimport 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 - Numbro’s bundled language pack ships no TypeScript declarations, so the import needs
@ts-expect-errorin a strict TypeScript project
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
What you learned
You created a custom Numbro-based numeric cell type in Handsontable. You used rendererFactory to format numbers with Numbro before display, registered all Numbro language packs for locale support, and composed the cell type from a custom renderer with the built-in numeric validator and editor.
Next steps
- Moment.js date - A custom cell type using another third-party library for date formatting.
- Moment.js time - A custom cell type using Moment.js for time validation.
- Pikaday - A date picker cell type using Pikaday and Moment.js.