Pikaday
This tutorial shows you how to integrate the Pikaday date picker as a custom Handsontable cell editor, with portal positioning and Moment.js formatting. This recipe also serves as a migration guide for users moving away from the built-in date cell type.
import { HotTable, HotColumn } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import moment from 'moment';import Pikaday from '@handsontable/pikaday';import '@handsontable/pikaday/css/pikaday.css';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';import Handsontable from 'handsontable/base';import './example1.css';
registerAllModules();
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;
const pikadayRenderer = rendererFactory(({ td, value, cellProperties }) => { td.innerText = value ? moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat) : '';});
const pikadayEditor = editorFactory({ position: 'portal', shortcutsGroup: 'customEditor', shortcuts: [ { keys: [['ArrowLeft']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('subtract', 1); _event.preventDefault(); }, }, { keys: [['ArrowRight']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('add', 1); _event.preventDefault(); }, }, { keys: [['ArrowUp']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('subtract', 7); _event.preventDefault(); }, }, { keys: [['ArrowDown']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('add', 7); _event.preventDefault(); }, }, ], init(editor) { editor.parentDestroyed = false; editor.input = editor.hot.rootDocument.createElement('INPUT'); editor.datePicker = editor.container; editor.hot.rootDocument.addEventListener('mousedown', (event) => { if (event.target && event.target.classList.contains('pika-day')) { editor.hideDatepicker(editor); } }); }, getDatePickerConfig(editor) { const htInput = editor.input; const options = {};
if (editor.cellProperties && editor.cellProperties.datePickerConfig) { Object.assign(options, editor.cellProperties.datePickerConfig); }
const origOnSelect = options.onSelect; const origOnClose = options.onClose;
options.field = htInput; options.trigger = htInput; options.container = editor.datePicker; options.bound = false; options.keyboardInput = false; options.format = options.format ?? editor.getDateFormat(editor); options.reposition = options.reposition || false; options.isRTL = false; options.onSelect = function (date) { let dateStr;
if (!isNaN(date.getTime())) { dateStr = moment(date).format(editor.getDateFormat(editor)); }
editor.setValue(dateStr);
if (origOnSelect) { origOnSelect.call(editor.pickaday, date); }
if (Handsontable.helper.isMobileBrowser()) { editor.hideDatepicker(editor); } }; options.onClose = () => { if (!editor.parentDestroyed) { editor.finishEditing(false); }
if (origOnClose) { origOnClose(); } };
return options; }, hideDatepicker(editor) { editor.pickaday.hide(); }, showDatepicker(editor, event) { const dateFormat = editor.getDateFormat(editor); // @ts-ignore const isMouseDown = editor.hot.view.isMouseDown(); const isMeta = event && 'keyCode' in event ? Handsontable.helper.isFunctionKey(event.keyCode) : false;
let dateStr;
editor.datePicker.style.display = 'block'; editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));
// @ts-ignore if (typeof editor.pickaday.useMoment === 'function') { // @ts-ignore editor.pickaday.useMoment(moment); }
// @ts-ignore editor.pickaday._onInputFocus = function () {};
if (editor.originalValue) { dateStr = editor.originalValue;
if (moment(dateStr, dateFormat, true).isValid()) { editor.pickaday.setMoment(moment(dateStr, dateFormat), true); }
if (editor.getValue() !== editor.originalValue) { editor.setValue(editor.originalValue); }
if (!isMeta && !isMouseDown) { editor.setValue(''); } } else if (editor.cellProperties.defaultDate) { dateStr = editor.cellProperties.defaultDate;
if (moment(dateStr, dateFormat, true).isValid()) { editor.pickaday.setMoment(moment(dateStr, dateFormat), true); }
if (!isMeta && !isMouseDown) { editor.setValue(''); } } else { editor.pickaday.gotoToday(); } }, afterClose(editor) { if (editor.pickaday.destroy) { editor.pickaday.destroy(); } }, afterOpen(editor, event) { const cellRect = editor.TD.getBoundingClientRect();
editor.input.style.width = `${cellRect.width}px`; editor.input.style.height = `${cellRect.height}px`; editor.showDatepicker(editor, event); }, getValue(editor) { return editor.input.value; }, setValue(editor, value) { editor.input.value = value; }, getDateFormat(editor) { return editor.cellProperties.dateFormat ?? DEFAULT_DATE_FORMAT; },});
/* start:skip-in-preview */const inputData = [ { 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', }, { id: 954632, itemName: 'Oxygen Unit', itemNo: 'FK-87', leadEngineer: 'Dr. Grace Augustine', cost: 600000, inStock: true, category: 'Life Support', itemQuality: 85, origin: '🇺🇸 USA', quantity: 15, valueStock: 9000000, repairable: true, supplierName: 'OxyGenius', restockDate: '2026-03-02', operationalStatus: 'Awaiting Parts', }, { id: 734944, itemName: 'Processing Rig', itemNo: 'LK-13', leadEngineer: 'Jake Sully', cost: 350000, inStock: true, category: 'Mining', itemQuality: 81, origin: '🇦🇺 Australia', quantity: 25, valueStock: 8750000, repairable: true, supplierName: 'RigTech', restockDate: '2026-04-15', operationalStatus: 'Ready for Testing', }, { id: 834662, itemName: 'Navigation', itemNo: 'XP-24', leadEngineer: 'Dr. Ellie Arroway', cost: 450000, inStock: true, category: 'Navigation', itemQuality: 89, origin: '🇫🇷 France', quantity: 8, valueStock: 3600000, repairable: false, supplierName: 'NavSolutions', restockDate: '2026-05-30', operationalStatus: 'In Maintenance', }, { id: 714329, itemName: 'Surveyor Arm', itemNo: 'QA-86', leadEngineer: 'Mark Watney', cost: 100000, inStock: true, category: 'Exploration', itemQuality: 78, origin: '🇺🇸 USA', quantity: 40, valueStock: 4000000, repairable: true, supplierName: 'ExploreTech', restockDate: '2026-07-12', operationalStatus: 'Decommissioned', },];
const data = inputData.map((item) => ({ ...item, restockDate: moment(new Date(item.restockDate)).format(DATE_FORMAT_US),}));/* end:skip-in-preview */
const ExampleComponent = () => { return ( <HotTable data={data} height="auto" colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost']} autoRowSize={true} rowHeaders={true} 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="restockDate" width={150} allowInvalid={false} hotRenderer={pikadayRenderer} hotEditor={pikadayEditor} renderFormat={DATE_FORMAT_US} dateFormat={DATE_FORMAT_US} correctFormat={true} defaultDate="01/01/2020" datePickerConfig={{ firstDay: 0, showWeekNumber: true, disableDayFn(date) { return date.getDay() === 0 || date.getDay() === 6; }, }} /> <HotColumn data="cost" type="numeric" width={120} className="htRight" locale="en-US" numericFormat={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }} /> </HotTable> );};
export default ExampleComponent;import { HotTable, HotColumn } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import moment from 'moment';import Pikaday from '@handsontable/pikaday';import '@handsontable/pikaday/css/pikaday.css';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';import Handsontable from 'handsontable/base';import './example1.css';
registerAllModules();
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;
interface PikadayEditorInstance { parentDestroyed: boolean; input: HTMLInputElement; datePicker: HTMLElement; pickaday: Pikaday;}
const pikadayRenderer = rendererFactory(({ td, value, cellProperties }) => { td.innerText = value ? moment(new Date(value as string), cellProperties.renderFormat).format(cellProperties.renderFormat) : '';});
const pikadayEditor = editorFactory<PikadayEditorInstance>({ position: 'portal', shortcutsGroup: 'customEditor', shortcuts: [ { keys: [['ArrowLeft']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('subtract', 1); _event.preventDefault(); }, }, { keys: [['ArrowRight']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('add', 1); _event.preventDefault(); }, }, { keys: [['ArrowUp']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('subtract', 7); _event.preventDefault(); }, }, { keys: [['ArrowDown']], callback: (editor, _event) => { // @ts-ignore editor.pickaday.adjustDate('add', 7); _event.preventDefault(); }, }, ], init(editor) { editor.parentDestroyed = false; editor.input = editor.hot.rootDocument.createElement('INPUT') as HTMLInputElement; editor.datePicker = editor.container; editor.hot.rootDocument.addEventListener('mousedown', (event: MouseEvent) => { const target = event.target as Element;
if (target && target.classList.contains('pika-day')) { editor.hideDatepicker(editor); } }); }, getDatePickerConfig(editor: any) { const htInput = editor.input; const options: Record<string, any> = {};
if (editor.cellProperties && editor.cellProperties.datePickerConfig) { Object.assign(options, editor.cellProperties.datePickerConfig); }
const origOnSelect = options.onSelect; const origOnClose = options.onClose;
options.field = htInput; options.trigger = htInput; options.container = editor.datePicker; options.bound = false; options.keyboardInput = false; options.format = options.format ?? editor.getDateFormat(editor); options.reposition = options.reposition || false; options.isRTL = false; options.onSelect = function (date: Date) { let dateStr: string | undefined;
if (!isNaN(date.getTime())) { dateStr = moment(date).format(editor.getDateFormat(editor)); }
editor.setValue(dateStr);
if (origOnSelect) { origOnSelect.call(editor.pickaday, date); }
if (Handsontable.helper.isMobileBrowser()) { editor.hideDatepicker(editor); } }; options.onClose = () => { if (!editor.parentDestroyed) { editor.finishEditing(false); }
if (origOnClose) { origOnClose(); } };
return options; }, hideDatepicker(editor: any) { editor.pickaday.hide(); }, showDatepicker(editor: any, event: Event) { const dateFormat = editor.getDateFormat(editor); // @ts-ignore const isMouseDown = editor.hot.view.isMouseDown(); const isMeta = event && 'keyCode' in event ? Handsontable.helper.isFunctionKey((event as KeyboardEvent).keyCode) : false;
let dateStr: string;
editor.datePicker.style.display = 'block'; editor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));
// @ts-ignore if (typeof editor.pickaday.useMoment === 'function') { // @ts-ignore editor.pickaday.useMoment(moment); }
// @ts-ignore editor.pickaday._onInputFocus = function () {};
if (editor.originalValue) { dateStr = editor.originalValue;
if (moment(dateStr, dateFormat, true).isValid()) { editor.pickaday.setMoment(moment(dateStr, dateFormat), true); }
if (editor.getValue() !== editor.originalValue) { editor.setValue(editor.originalValue); }
if (!isMeta && !isMouseDown) { editor.setValue(''); } } else if (editor.cellProperties.defaultDate) { dateStr = editor.cellProperties.defaultDate;
if (moment(dateStr, dateFormat, true).isValid()) { editor.pickaday.setMoment(moment(dateStr, dateFormat), true); }
if (!isMeta && !isMouseDown) { editor.setValue(''); } } else { editor.pickaday.gotoToday(); } }, afterClose(editor: any) { if (editor.pickaday.destroy) { editor.pickaday.destroy(); } }, afterOpen(editor: any, event: Event) { const cellRect = editor.TD.getBoundingClientRect();
editor.input.style.width = `${cellRect.width}px`; editor.input.style.height = `${cellRect.height}px`; editor.showDatepicker(editor, event); }, getValue(editor) { return editor.input.value; }, setValue(editor, value) { editor.input.value = value; }, getDateFormat(editor: any): string { return editor.cellProperties.dateFormat ?? DEFAULT_DATE_FORMAT; },});
/* 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 inputData: 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', }, { id: 954632, itemName: 'Oxygen Unit', itemNo: 'FK-87', leadEngineer: 'Dr. Grace Augustine', cost: 600000, inStock: true, category: 'Life Support', itemQuality: 85, origin: '🇺🇸 USA', quantity: 15, valueStock: 9000000, repairable: true, supplierName: 'OxyGenius', restockDate: '2026-03-02', operationalStatus: 'Awaiting Parts', }, { id: 734944, itemName: 'Processing Rig', itemNo: 'LK-13', leadEngineer: 'Jake Sully', cost: 350000, inStock: true, category: 'Mining', itemQuality: 81, origin: '🇦🇺 Australia', quantity: 25, valueStock: 8750000, repairable: true, supplierName: 'RigTech', restockDate: '2026-04-15', operationalStatus: 'Ready for Testing', }, { id: 834662, itemName: 'Navigation', itemNo: 'XP-24', leadEngineer: 'Dr. Ellie Arroway', cost: 450000, inStock: true, category: 'Navigation', itemQuality: 89, origin: '🇫🇷 France', quantity: 8, valueStock: 3600000, repairable: false, supplierName: 'NavSolutions', restockDate: '2026-05-30', operationalStatus: 'In Maintenance', }, { id: 714329, itemName: 'Surveyor Arm', itemNo: 'QA-86', leadEngineer: 'Mark Watney', cost: 100000, inStock: true, category: 'Exploration', itemQuality: 78, origin: '🇺🇸 USA', quantity: 40, valueStock: 4000000, repairable: true, supplierName: 'ExploreTech', restockDate: '2026-07-12', operationalStatus: 'Decommissioned', },];
const data = inputData.map((item) => ({ ...item, restockDate: moment(new Date(item.restockDate)).format(DATE_FORMAT_US),}));/* end:skip-in-preview */
const ExampleComponent = () => { return ( <HotTable data={data} height="auto" colHeaders={['Item Name', 'Category', 'Lead Engineer', 'Restock Date', 'Cost']} autoRowSize={true} rowHeaders={true} 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="restockDate" width={150} allowInvalid={false} hotRenderer={pikadayRenderer} hotEditor={pikadayEditor} renderFormat={DATE_FORMAT_US} dateFormat={DATE_FORMAT_US} correctFormat={true} defaultDate="01/01/2020" datePickerConfig={{ firstDay: 0, showWeekNumber: true, disableDayFn(date: Date) { return date.getDay() === 0 || date.getDay() === 6; }, }} /> <HotColumn data="cost" type="numeric" width={120} className="htRight" locale="en-US" numericFormat={{ style: 'currency', currency: 'USD', minimumFractionDigits: 2 }} /> </HotTable> );};
export default ExampleComponent;.ht_editor_visible > input { width: 100%; height: 100%; box-sizing: border-box !important; border: none; border-radius: 0; outline: none; margin-top: -1px; margin-left: -1px; box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px) var(--ht-cell-editor-border-color, #1a42e8), 0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 0 var(--ht-cell-editor-shadow-color, transparent) !important; background-color: var(--ht-cell-editor-background-color, #ffffff) !important; padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px) !important; border: none !important; font-family: inherit; font-size: var(--ht-font-size); line-height: var(--ht-line-height);}.ht_editor_visible > input:focus-visible { border: none !important; outline: none !important;}Overview
This guide shows how to create a custom date picker cell using Pikaday, a date picker library with no dependencies. Migration note: the built-in date cell type with Pikaday will be removed in the next Handsontable release. Use this recipe to maintain Pikaday functionality in your application.
Difficulty: Intermediate
Time: ~25 minutes
Libraries: @handsontable/pikaday, moment
What You’ll Build
A cell that:
- Displays formatted dates (e.g., “12/31/2024” or “31/12/2024”)
- Opens a beautiful calendar picker when edited
- Supports per-column configuration (date formats, first day of week, disabled dates)
- Handles keyboard navigation (arrow keys to navigate dates)
- Auto-closes and saves when a date is selected
- Works with portal positioning for better z-index handling
Prerequisites
npm install @handsontable/pikaday momentWhy these libraries?
@handsontable/pikaday- The Pikaday date picker library (Handsontable’s fork)moment- Date formatting and parsing (can be replaced with date-fns, dayjs, etc.)
Import Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import moment from 'moment';import Pikaday from '@handsontable/pikaday';import '@handsontable/pikaday/css/pikaday.css';import { CellProperties } from 'handsontable/settings';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';registerAllModules();What we’re importing:
- Handsontable core and styles
editorFactoryandrendererFactoryfor creating custom cell type components- Pikaday for date picker functionality
@handsontable/pikaday/css/pikaday.cssfor the calendar panel styling- Moment for date formatting and parsing
Define Date Formats
const DATE_FORMAT_US = 'MM/DD/YYYY';const DEFAULT_DATE_FORMAT = DATE_FORMAT_US;Why constants?
- Reusability across renderer and column configuration
- Single source of truth
- Add more formats (EU, ISO, custom, etc.) by extending this list
Define TypeScript Types
Define types for editor properties and methods to ensure type safety.
type EditorPropertiesType = {input: HTMLInputElement;pickaday: Pikaday;datePicker: HTMLDivElement;parentDestroyed: boolean;};// Helper type to extract the editor type from factory callbackstype FactoryEditorType<TProps, TMethods> = Parameters<Parameters<typeof editorFactory<TProps, TMethods>>[0]["init"]>[0];type EditorMethodsType = {showDatepicker: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,event: Event | undefined,) => void;hideDatepicker: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) => void;getDatePickerConfig: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) => Pikaday.PikadayOptions;getDateFormat: (editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) => string;};What’s happening:
EditorPropertiesType: Defines custom properties added to the editor instanceFactoryEditorType: Helper to extract the correct editor type from factory callbacksEditorMethodsType: Defines custom methods that will be available on the editor
Why this matters:
- Provides full TypeScript type safety
- Enables autocomplete in your IDE
- Catches type errors at compile time
Create the Renderer
The renderer controls how the cell looks when not being edited.
renderer: rendererFactory(({ td, value, cellProperties }) => {td.innerText = moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat,);})What’s happening:
valueis the raw date value (e.g., ISO string “2024-12-31” or formatted “12/31/2024”)cellProperties.renderFormatis a custom property we’ll set per columnmoment().format()converts to desired format- Display the formatted date
Why use
cellProperties?- Allows different columns to display dates differently
- One cell definition, multiple configurations
Editor - Initialize (
init)Create the input element and set up event handling to prevent clicks on the datepicker from closing the editor.
init(editor) {editor.parentDestroyed = false;// Create the input element on init. This is a text input that date picker will be attached to.editor.input = editor.hot.rootDocument.createElement('input',) as HTMLInputElement;// Use the container as the date picker containereditor.datePicker = editor.container;/*** Prevent recognizing clicking on datepicker as clicking outside of table.*/editor.hot.rootDocument.addEventListener('mousedown', (event) => {if (event.target &&(event.target as HTMLElement).classList.contains('pika-day')) {editor.hideDatepicker(editor);}},);}What’s happening:
- Initialize
parentDestroyedflag to track editor lifecycle - Create an
inputelement usingeditor.hot.rootDocument.createElement() - Use the editor container as the Pikaday container
- Create event listener to handle clicks on datepicker days
- The
editorFactoryhelper handles container creation and DOM insertion
Key concepts:
The
Event ListenerpatternThis is crucial! Without it:
- User clicks cell to edit
- Pikaday calendar opens
- User clicks on a calendar day
- Handsontable thinks user clicked “outside” the editor
- Editor closes immediately!
Solution:
editor.hot.rootDocument.addEventListener('mousedown', (event) => {if ((event.target as HTMLElement).classList.contains('pika-day')) {editor.hideDatepicker(editor);}});Why
editor.hot.rootDocument.createElement()?- Handsontable might be in an iframe or shadow DOM
editor.hot.rootDocumentensures correct document context- Ensures compatibility across different environments
- Initialize
Editor - Get Date Picker Config (
getDatePickerConfig)Build the Pikaday configuration object with proper callbacks.
getDatePickerConfig(editor) {const htInput = editor.input;const options: Pikaday.PikadayOptions = {};// Merge custom config from cell propertiesif (editor.cellProperties && editor.cellProperties.datePickerConfig) {Object.assign(options, editor.cellProperties.datePickerConfig);}const origOnSelect = options.onSelect;const origOnClose = options.onClose;// Configure Pikadayoptions.field = htInput;options.trigger = htInput;options.container = editor.datePicker;options.bound = false;options.keyboardInput = false;options.format = options.format ?? editor.getDateFormat(editor);options.reposition = options.reposition || false;options.isRTL = false;// Handle date selectionoptions.onSelect = function (date) {let dateStr;if (!isNaN(date.getTime())) {dateStr = moment(date).format(editor.getDateFormat(editor));}editor.setValue(dateStr);if (origOnSelect) {origOnSelect.call(editor.pickaday, date);}if (Handsontable.helper.isMobileBrowser()) {editor.hideDatepicker(editor);}};// Handle date picker closeoptions.onClose = () => {if (!editor.parentDestroyed) {editor.finishEditing(false);}if (origOnClose) {origOnClose();}};return options;}What’s happening:
- Start with empty options object
- Merge custom config from
cellProperties.datePickerConfig(allows per-column customization) - Store original callbacks to preserve them
- Configure Pikaday with editor-specific settings
- Set up
onSelectto format date and save value - Set up
onCloseto finish editing
Key configuration options:
field: The input element Pikaday attaches totrigger: Element that triggers the picker (same as field)container: Where to render the calendar (editor container)bound: false: Don’t position relative to fieldkeyboardInput: false: Disable direct keyboard input (handled via shortcuts)reposition: false: Don’t auto-reposition (positioning is handled manually)
Editor - Show Datepicker (
showDatepicker)Initialize and display the Pikaday calendar when the editor opens.
showDatepicker(editor, event) {const dateFormat = editor.getDateFormat(editor);// @ts-ignoreconst isMouseDown = editor.hot.view.isMouseDown();const isMeta = event && 'keyCode' in event? Handsontable.helper.isFunctionKey((event as KeyboardEvent).keyCode): false;let dateStr;editor.datePicker.style.display = 'block';// Create new Pikaday instanceeditor.pickaday = new Pikaday(editor.getDatePickerConfig(editor));// Configure Moment.js integration if available// @ts-ignoreif (typeof editor.pickaday.useMoment === 'function') {// @ts-ignoreeditor.pickaday.useMoment(moment);}// @ts-ignoreeditor.pickaday._onInputFocus = function () {};// Handle existing valueif (editor.originalValue) {dateStr = editor.originalValue;if (moment(dateStr, dateFormat, true).isValid()) {editor.pickaday.setMoment(moment(dateStr, dateFormat), true);}if (editor.getValue() !== editor.originalValue) {editor.setValue(editor.originalValue);}if (!isMeta && !isMouseDown) {editor.setValue('');}} else if (editor.cellProperties.defaultDate) {dateStr = editor.cellProperties.defaultDate;if (moment(dateStr, dateFormat, true).isValid()) {editor.pickaday.setMoment(moment(dateStr, dateFormat), true);}if (!isMeta && !isMouseDown) {editor.setValue('');}} else {editor.pickaday.gotoToday();}}What’s happening:
- Get date format for parsing
- Check if mouse is down or function key pressed (for special behavior)
- Show the date picker container
- Create new Pikaday instance with configuration
- Configure Moment.js integration
- Disable input focus handler (focus is handled by the editor lifecycle)
- Set initial date based on cell value, default date, or today
Key concepts:
Why create Pikaday instance in
showDatepicker?- Pikaday instance is created fresh each time editor opens
- Allows per-edit configuration changes
- Ensures clean state for each edit session
The
isMetaandisMouseDownchecks- If function key (F2, etc.) or mouse is down, don’t clear the input
- Preserves value when opening via keyboard or programmatically
- Provides better UX for keyboard users
Editor - Hide Datepicker (
hideDatepicker)Close the Pikaday calendar.
hideDatepicker(editor) {editor.pickaday.hide();}Editor - After Open Hook (
afterOpen)Match the editor input dimensions to the cell and show the datepicker.
afterOpen(editor, event) {const cellRect = editor.TD.getBoundingClientRect();editor.input.style.width = `${cellRect.width}px`;editor.input.style.height = `${cellRect.height}px`;editor.showDatepicker(editor, event);}What’s happening:
- Get the cell’s bounding rectangle for exact dimensions
- Set the input width and height to match the cell
- Show the datepicker
Why
getBoundingClientRect?- Provides pixel-perfect dimensions matching the cell
- Works correctly regardless of cell padding, borders, or theme
- The CSS file handles the visual styling (borders, padding, focus states)
Editor - After Close Hook (
afterClose)Clean up the Pikaday instance when the editor closes.
afterClose(editor) {if (editor.pickaday.destroy) {editor.pickaday.destroy();}}Why this matters:
- Pikaday creates DOM elements and event listeners
- Without cleanup, these accumulate over time
- Essential for long-running applications
Editor - Get Value and Set Value
Standard value management methods.
getValue(editor) {return editor.input.value;}setValue(editor, value) {editor.input.value = value;}- Pikaday automatically updates
input.valuewhen a date is selected getValueandsetValueread and write the input value directly- Formatting is handled by Pikaday and the
onSelectcallback
- Pikaday automatically updates
Editor - Get Date Format (
getDateFormat)Helper method to get the date format for the current cell.
getDateFormat(editor: FactoryEditorType<EditorPropertiesType, EditorMethodsType>,) {return editor.cellProperties.dateFormat ?? DEFAULT_DATE_FORMAT;}Editor - Keyboard Shortcuts
Add keyboard navigation for date selection.
shortcuts: [{keys: [['ArrowLeft']],callback: (editor, _event) => {editor.pickaday.adjustDate('subtract', 1);_event.preventDefault();},},{keys: [['ArrowRight']],callback: (editor, _event) => {editor.pickaday.adjustDate('add', 1);_event.preventDefault();},},{keys: [['ArrowUp']],callback: (editor, _event) => {editor.pickaday.adjustDate('subtract', 7);_event.preventDefault();},},{keys: [['ArrowDown']],callback: (editor, _event) => {editor.pickaday.adjustDate('add', 7);_event.preventDefault();},}]What’s happening:
- ArrowLeft: Move back one day
- ArrowRight: Move forward one day
- ArrowUp: Move back one week (7 days)
- ArrowDown: Move forward one week (7 days)
Editor - Portal Positioning
Use portal positioning for better z-index handling.
position: 'portal',Why portal instead of container?
- Datepicker can extend beyond cell boundaries
- Portal ensures it’s always on top
- Better for complex layouts
Editor Input Styling (CSS)
Style the editor input to match Handsontable’s native editor appearance using CSS custom properties.
.ht_editor_visible > input {width: 100%;height: 100%;box-sizing: border-box !important;border: none;border-radius: 0;outline: none;margin-top: -1px;margin-left: -1px;box-shadow: inset 0 0 0 var(--ht-cell-editor-border-width, 2px)var(--ht-cell-editor-border-color, #1a42e8),0 0 var(--ht-cell-editor-shadow-blur-radius, 0) 0var(--ht-cell-editor-shadow-color, transparent) !important;background-color: var(--ht-cell-editor-background-color, #ffffff) !important;padding: var(--ht-cell-vertical-padding, 4px)var(--ht-cell-horizontal-padding, 8px) !important;border: none !important;font-family: inherit;font-size: var(--ht-font-size);line-height: var(--ht-line-height);}.ht_editor_visible > input:focus-visible {border: none !important;outline: none !important;}What’s happening:
- Uses Handsontable’s CSS custom properties (
--ht-cell-editor-*) for theme compatibility inset box-shadowreplaces borders for the blue editor highlight-1pxmargins correct portal positioning offset- Inherits font properties from the table for consistency
:focus-visibleoverrides prevent browser default focus styles
- Uses Handsontable’s CSS custom properties (
Complete Cell Definition
Put it all together:
const cellDefinition: Pick<CellProperties,'renderer' | 'validator' | 'editor'> = {renderer: rendererFactory(({ td, value, cellProperties }) => {td.innerText = moment(new Date(value), cellProperties.renderFormat).format(cellProperties.renderFormat,);}),editor: editorFactory<EditorPropertiesType,EditorMethodsType>({position: 'portal',shortcutsGroup: 'customEditor',shortcuts: [// ... keyboard shortcuts from Step 13],init(editor) {// ... from Step 5},getDatePickerConfig(editor) {// ... from Step 6},hideDatepicker(editor) {// ... from Step 8},showDatepicker(editor, event) {// ... from Step 7},afterClose(editor) {// ... from Step 10},afterOpen(editor, event) {// ... from Step 9},getValue(editor) {// ... from Step 11},setValue(editor, value) {// ... from Step 11},getDateFormat(editor) {// ... from Step 12},}),};Use in Handsontable
const container = document.querySelector('#example1')!;const hotOptions: Handsontable.GridSettings = {data,colHeaders: ['Item Name', 'Category', 'Lead Engineer', 'Restock Date', '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: 'restockDate',width: 150,allowInvalid: false,...cellDefinition,renderFormat: DATE_FORMAT_US,dateFormat: DATE_FORMAT_US,defaultDate: '01/01/2020',datePickerConfig: {firstDay: 0,showWeekNumber: true,disableDayFn(date: Date) {return date.getDay() === 0 || date.getDay() === 6;},},},{data: 'cost',type: 'numeric',width: 120,className: 'htRight',locale: 'en-US',numericFormat: {style: 'currency',currency: 'USD',minimumFractionDigits: 2,},},],licenseKey: 'non-commercial-and-evaluation',};const hot = new Handsontable(container, hotOptions);Key configuration:
...cellDefinition- Spreads renderer and editor into the column configrenderFormat- Format for displaying dates in cellsdateFormat- Format for Pikaday date pickerdatePickerConfig- Additional Pikaday configuration optionsdefaultDate- Default date when cell is emptyheaderClassName: 'htLeft'- Left-aligns all column headers
How It Works - Complete Flow
- Initial Load: Cell displays formatted date (e.g., “08/01/2025”)
- User Double-Clicks or F2: Editor opens, container positioned in portal
- After Open: Input sized to match cell via
getBoundingClientRect,showDatepickercalled - Show Datepicker: Pikaday instance created, calendar displayed
- User Selects Date:
onSelectcallback fires, formats date, saves value - User Clicks Day: Event listener detects click, hides datepicker, finishes editing
- After Close: Pikaday instance destroyed, memory cleaned up
- Re-render: Cell displays updated formatted date
Migration from Built-in Date Cell Type
If you’re currently using the built-in date cell type with Pikaday, here’s how to migrate:
Before (Built-in):
columns: [{ data: 'restockDate', type: 'date', dateFormat: 'MM/DD/YYYY', datePickerConfig: { firstDay: 0, }}]After (Custom Editor):
columns: [{ data: 'restockDate', ...cellDefinition, // Add the custom cell definition dateFormat: 'MM/DD/YYYY', renderFormat: 'MM/DD/YYYY', // Add render format datePickerConfig: { firstDay: 0, }}]Key differences:
- Replace
type: "date"with...cellDefinition - Add
renderFormatproperty (for cell display) - Keep
dateFormatanddatePickerConfig(they work the same)
Enhancements
Different Date Formats per Column
columns: [{data: 'restockDate',...cellDefinition,renderFormat: 'MM/DD/YYYY', // US formatdateFormat: 'MM/DD/YYYY',},{data: 'restockDate',...cellDefinition,renderFormat: 'DD/MM/YYYY', // EU formatdateFormat: 'DD/MM/YYYY',}]Date Range Restrictions
datePickerConfig: {minDate: new Date('2024-01-01'),maxDate: new Date('2024-12-31'),disableDayFn(date) {// Disable weekendsreturn date.getDay() === 0 || date.getDay() === 6;}}Custom Date Formatting
Replace Moment.js with another library:
import { format, parse } from 'date-fns';// In rendererrenderer: rendererFactory(({ td, value, cellProperties }) => {td.innerText = format(new Date(value), cellProperties.renderFormat);})// In getDatePickerConfigoptions.format = 'MM/DD/YYYY'; // Pikaday format stringLocalization
import '@handsontable/pikaday/css/pikaday.css';import moment from 'moment';import 'moment/locale/fr';moment.locale('fr');datePickerConfig: {i18n: {previousMonth: 'Mois précédent',nextMonth: 'Mois suivant',months: ['Janvier', 'Février', 'Mars', ...],weekdays: ['Dimanche', 'Lundi', 'Mardi', ...],weekdaysShort: ['Dim', 'Lun', 'Mar', ...]}}
Accessibility
Pikaday has good keyboard support out of the box:
Keyboard navigation:
- Arrow Keys: Navigate dates (via the registered shortcuts)
- Enter: Select current date
- Escape: Close datepicker
- Tab: Navigate to next field
- Page Up/Down: Navigate months
- Home/End: Navigate to first/last day of month
ARIA attributes: Pikaday automatically adds ARIA attributes for screen readers.
Performance Considerations
Why This Is Fast
- Lazy Initialization: Pikaday instance created only when editor opens
- Efficient Cleanup: Instance destroyed when editor closes
- Portal Positioning: Better z-index handling without performance cost
What you learned
You integrated the Pikaday date picker as a Handsontable cell editor. You used editorFactory with position: 'portal' for correct z-index handling, Moment.js for date formatting and parsing, and cellProperties.datePickerConfig to drive per-column Pikaday configuration.
Next steps
- Moment.js date - A date cell type combining Moment.js and Pikaday in a registered
moment-datecell type. - Flatpickr - An alternative date picker using the Flatpickr library.
- Date picker (Angular) - A date editor built with Angular components and the native HTML5 date input.