Pikaday
Overview
This guide shows how to create a custom date picker cell using Pikaday, a lightweight, no-dependencies date picker library. This guide is essential for migration - 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 { 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
- 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
- Easy to add more formats (EU, ISO, custom, etc.)
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 (we handle it via shortcuts)reposition: false: Don’t auto-reposition (we handle positioning)
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 (we handle focus ourselves)
- 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;}Why simple?
- Pikaday automatically updates
input.valuewhen date is selected - We just read/write the input value
- Formatting is handled by Pikaday and our
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',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,correctFormat: true,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',numericFormat: {pattern: '$0,0.00',culture: 'en-US',},},],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 '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 our 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
Congratulations! You’ve created a production-ready Pikaday date picker cell with full customization options, keyboard navigation, and proper lifecycle management. This recipe ensures you can continue using Pikaday even after the built-in date cell type is removed!