Skip to content
JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import { getRenderer } from 'handsontable/renderers';
import { getEditor } from 'handsontable/editors';
import { registerCellType } from 'handsontable/cellTypes';
import moment from 'moment';
// Register all Handsontable's modules.
registerAllModules();
/* 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',
time: '09:30',
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',
time: '14: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',
time: '08:00',
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',
time: '16:45',
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',
time: '11: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',
time: '23:00',
operationalStatus: 'Operational',
},
];
/* end:skip-in-preview */
// Get the DOM element with the ID 'example1' where the Handsontable will be rendered
const container = document.querySelector('#example1');
const cellTimeTypeDefinition = {
renderer: getRenderer('text'),
validator(value, callback) {
const timeFormat = this.timeFormat ?? 'h:mm:ss a';
let valid = true;
if (value === null) {
value = '';
}
value = /^\d{3,}$/.test(value) ? parseInt(value, 10) : value;
const twoDigitValue = /^\d{1,2}$/.test(value);
if (twoDigitValue) {
value += ':00';
}
const date = moment(
value,
[
'YYYY-MM-DDTHH:mm:ss.SSSZ',
'X',
'x', // Unix ms timestamp
],
true
).isValid()
? moment(value)
: moment(value, timeFormat);
let isValidTime = date.isValid();
// is it in the specified format
let isValidFormat = moment(value, timeFormat, true).isValid() && !twoDigitValue;
if (this.allowEmpty && value === '') {
isValidTime = true;
isValidFormat = true;
}
if (!isValidTime) {
valid = false;
}
if (!isValidTime && isValidFormat) {
valid = true;
}
if (isValidTime && !isValidFormat) {
if (this.correctFormat === true) {
const correctedValue = date.format(timeFormat);
this.instance.setDataAtCell(this.visualRow, this.visualCol, correctedValue, 'timeValidator');
valid = true;
} else {
valid = false;
}
}
callback(valid);
},
editor: getEditor('text'),
};
registerCellType('moment-time', cellTimeTypeDefinition);
// Define configuration options for the Handsontable
const hotOptions = {
data,
colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Arrival Time', '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: 'time',
type: 'moment-time',
width: 150,
timeFormat: 'HH:mm',
correctFormat: true,
},
{
data: 'cost',
type: 'numeric',
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-vars
const hot = new Handsontable(container, hotOptions);
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
import { getRenderer } from 'handsontable/renderers';
import { getEditor } from 'handsontable/editors';
import { registerCellType } from 'handsontable/cellTypes';
import moment from 'moment';
// Register all Handsontable's modules.
registerAllModules();
/* 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',
time: '09:30',
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',
time: '14: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',
time: '08:00',
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',
time: '16:45',
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',
time: '11: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',
time: '23:00',
operationalStatus: 'Operational',
},
];
/* end:skip-in-preview */
// Get the DOM element with the ID 'example1' where the Handsontable will be rendered
const container = document.querySelector('#example1')!;
const cellTimeTypeDefinition = {
renderer: getRenderer('text'),
validator: function(value, callback) {
const timeFormat = this.timeFormat ?? 'h:mm:ss a';
let valid = true;
if (value === null) {
value = '';
}
value = /^\d{3,}$/.test(value) ? parseInt(value, 10) : value;
const twoDigitValue = /^\d{1,2}$/.test(value);
if (twoDigitValue) {
value += ':00';
}
const date = moment(value, [
'YYYY-MM-DDTHH:mm:ss.SSSZ',
'X', // Unix timestamp
'x' // Unix ms timestamp
], true).isValid() ?
moment(value) : moment(value, timeFormat);
let isValidTime = date.isValid();
// is it in the specified format
let isValidFormat = moment(value, timeFormat, true).isValid() && !twoDigitValue;
if (this.allowEmpty && value === '') {
isValidTime = true;
isValidFormat = true;
}
if (!isValidTime) {
valid = false;
}
if (!isValidTime && isValidFormat) {
valid = true;
}
if (isValidTime && !isValidFormat) {
if (this.correctFormat === true) {
const correctedValue = date.format(timeFormat);
this.instance.setDataAtCell(this.visualRow, this.visualCol, correctedValue, 'timeValidator');
valid = true;
} else {
valid = false;
}
}
callback(valid);
},
editor: getEditor('text'),
};
registerCellType('moment-time', cellTimeTypeDefinition);
// Define configuration options for the Handsontable
const hotOptions: Handsontable.GridSettings = {
data,
colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Arrival Time', '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: 'time',
type: 'moment-time',
width: 150,
timeFormat: 'HH:mm',
correctFormat: true,
},
{
data: 'cost',
type: 'numeric',
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-vars
const hot = new Handsontable(container, hotOptions);

Overview

This guide shows how to create a custom time cell type using the Moment.js library. Users can format times using the Moment.js API.

Difficulty: Beginner Time: ~15 minutes Libraries: moment

What You’ll Build

A cell that:

  • Displays time values as formatted text
  • Accepts timeFormat options for customization (e.g., HH:mm, h:mm:ss a)
  • Validates time input using Moment.js
  • Auto-corrects time format when correctFormat is enabled

Prerequisites

Terminal window
npm install moment
  1. Import Dependencies

    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    import { getRenderer } from 'handsontable/renderers';
    import { getEditor } from 'handsontable/editors';
    import { registerCellType } from 'handsontable/cellTypes';
    import moment from 'moment';
    registerAllModules();

    Why this matters:

    • moment handles time parsing, validation, and formatting
    • getRenderer('text') and getEditor('text') reuse Handsontable’s built-in text renderer and editor
    • registerCellType registers the custom cell type for use in column config
  2. Create the Renderer

    We reuse the built-in text renderer, which displays the time value as plain text:

    renderer: getRenderer('text')
  3. Create the Validator

    The validator parses the input using Moment.js and checks it against the configured timeFormat. It handles Unix timestamps, two-digit shorthand (e.g., 9 becomes 9:00), and auto-correction:

    validator: function(value, callback) {
    const timeFormat = this.timeFormat ?? 'h:mm:ss a';
    let valid = true;
    if (value === null) {
    value = '';
    }
    value = /^\d{3,}$/.test(value) ? parseInt(value, 10) : value;
    const twoDigitValue = /^\d{1,2}$/.test(value);
    if (twoDigitValue) {
    value += ':00';
    }
    const date = moment(value, [
    'YYYY-MM-DDTHH:mm:ss.SSSZ',
    'X', // Unix timestamp
    'x' // Unix ms timestamp
    ], true).isValid() ?
    moment(value) : moment(value, timeFormat);
    let isValidTime = date.isValid();
    // is it in the specified format
    let isValidFormat = moment(value, timeFormat, true).isValid() && !twoDigitValue;
    if (this.allowEmpty && value === '') {
    isValidTime = true;
    isValidFormat = true;
    }
    if (!isValidTime) {
    valid = false;
    }
    if (!isValidTime && isValidFormat) {
    valid = true;
    }
    if (isValidTime && !isValidFormat) {
    if (this.correctFormat === true) {
    const correctedValue = date.format(timeFormat);
    this.instance.setDataAtCell(this.visualRow, this.visualCol, correctedValue, 'timeValidator');
    valid = true;
    } else {
    valid = false;
    }
    }
    callback(valid);
    }

    What’s happening:

    • Converts numeric-only input (3+ digits) to integers for Unix timestamp parsing
    • Appends :00 to 1-2 digit values (e.g., 9 becomes 9:00)
    • Tries ISO 8601 and Unix timestamp formats first, then falls back to the configured timeFormat
    • If correctFormat is enabled, auto-corrects valid but misformatted times
  4. Create the Editor

    We reuse the built-in text editor — a simple text input for editing the time value:

    editor: getEditor('text')
  5. Complete Cell Type Definition

    Put all the pieces together and register the cell type:

    const cellTimeTypeDefinition = {
    renderer: getRenderer('text'),
    validator: function(value, callback) {
    // ... validator code from Step 3
    },
    editor: getEditor('text'),
    };
    registerCellType('moment-time', cellTimeTypeDefinition);

    What’s happening:

    • renderer: Uses the built-in text renderer to display the time value
    • validator: Custom validator that validates and optionally corrects time format using Moment.js
    • editor: Uses the built-in text editor for simple text input
    • registerCellType: Registers the moment-time cell type for use in column config
  6. Use in Handsontable

    registerCellType('moment-time', cellTimeTypeDefinition);
    const hotOptions: Handsontable.GridSettings = {
    data,
    colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Arrival Time', '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: 'time',
    type: 'moment-time',
    width: 150,
    timeFormat: 'HH:mm',
    correctFormat: true,
    },
    {
    data: 'cost',
    type: 'numeric',
    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: 'moment-time' - uses the custom cell type on the Arrival Time column
    • timeFormat: 'HH:mm' - the Moment.js format string for 24-hour time
    • correctFormat: true - automatically reformats valid times to the expected format
    • headerClassName: 'htLeft' - left-aligns all column headers

How It Works - Complete Flow

  1. Initial Render: Cell displays the time value as plain text using the text renderer
  2. User clicks cell: The built-in text editor opens for editing
  3. User enters time: Input like 9, 14:30, or a Unix timestamp is accepted
  4. Validation: Moment.js checks the format and time validity; auto-corrects if correctFormat is enabled
  5. Save: Valid values are saved to the cell; invalid values are rejected