Color picker
This tutorial shows you how to integrate the Pickr color picker library as a custom Handsontable cell editor, with a swatch renderer and hex validation.
/* file: app.component.ts */import { Component, ChangeDetectionStrategy, ElementRef, ViewChild } from '@angular/core';import { GridSettings, HotCellEditorAdvancedComponent, HotCellRendererAdvancedComponent, HotTableModule} from '@handsontable/angular-wrapper';import Pickr from '@simonwep/pickr';import '@simonwep/pickr/dist/themes/nano.min.css';
/* start:skip-in-preview */const inputData = [ { id: 640329, itemName: 'Lunar Core', itemNo: 'XJ-12', cost: 350000, valueStock: 700000 }, { id: 863104, itemName: 'Zero Thrusters', itemNo: 'QL-54', cost: 450000, valueStock: 0 }, { id: 395603, itemName: 'EVA Suits', itemNo: 'PM-67', cost: 150000, valueStock: 7500000 }, { id: 679083, itemName: 'Solar Panels', itemNo: 'BW-09', cost: 75000, valueStock: 750000 }, { id: 912663, itemName: 'Comm Array', itemNo: 'ZR-56', cost: 125000, valueStock: 0 }, { id: 315806, itemName: 'Habitat Dome', itemNo: 'UJ-23', cost: 1000000, valueStock: 3000000 }, { id: 954632, itemName: 'Oxygen Unit', itemNo: 'FK-87', cost: 600000, valueStock: 9000000 }, { id: 734944, itemName: 'Processing Rig', itemNo: 'LK-13', cost: 350000, valueStock: 8750000 },];/* end:skip-in-preview */
const colorValidator = (value: string): boolean => /^#[0-9A-Fa-f]{6}$/.test(value);
@Component({ standalone: true, selector: 'example1-color-renderer', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div class="color-picker-cell"> <span class="color-picker-swatch" [style.background]="value"></span> </div>`, styles: ` :host { height: 100%; width: 100%; } .color-picker-cell { display: flex; align-items: center; justify-content: center; } .color-picker-swatch { width: 18px; height: 18px; border-radius: 50%; flex-shrink: 0; border: 1px solid rgba(0,0,0,0.15); } `,})export class ColorRendererComponent extends HotCellRendererAdvancedComponent<string> {}
@Component({ standalone: true, selector: 'example1-color-picker-editor', template: ` <div #editorContainer style="width:100%;height:100%;position:relative;"> <input #colorInput class="color-picker-editor" type="text" readonly /> </div> `, styles: ` :host { width: 100%; height: 100%; } .color-picker-editor { width: 100%; height: 100%; border: none; outline: none; box-sizing: border-box !important; cursor: pointer; } `,})export class ColorPickerEditorComponent extends HotCellEditorAdvancedComponent<string> { @ViewChild('colorInput', { static: true }) colorInput!: ElementRef<HTMLInputElement>; @ViewChild('editorContainer', { static: true }) editorContainer!: ElementRef<HTMLElement>;
private pickrInstance: Pickr | null = null; private pickrButton: HTMLButtonElement | null = null; private openedAt = 0;
override afterOpen(): void { this.openedAt = Date.now(); this.colorInput.nativeElement.value = this.value || '#000000';
this.pickrButton = document.createElement('button'); this.pickrButton.textContent = 'Open color picker'; this.pickrButton.style.cssText = 'position:absolute;opacity:0;pointer-events:none;'; this.editorContainer.nativeElement.appendChild(this.pickrButton); const button = this.pickrButton;
this.pickrInstance = Pickr.create({ el: button, theme: 'nano', default: this.value || '#000000', autoReposition: false, padding: 0, components: { preview: true, hue: true }, });
(this.pickrInstance as any)._root.root.style.height = '0'; (this.pickrInstance as any)._root.root.style.overflow = 'hidden';
this.pickrInstance.on('change', (color: { toHEXA: () => { toString: () => string } } | null) => { if (color) { const hex = color.toHEXA().toString(); this.colorInput.nativeElement.value = hex; this.setValue(hex); } });
this.pickrInstance.on('hide', () => { if (Date.now() - this.openedAt < 400) { this.pickrInstance?.show(); return; } this.finishEdit.emit(); });
this.pickrInstance.show();
requestAnimationFrame(() => { requestAnimationFrame(() => { const cellRect = this.editorContainer.nativeElement.getBoundingClientRect();
if (cellRect && this.pickrInstance) { (this.pickrInstance as any)._root.app.style.top = `${cellRect.bottom}px`; } }); }); }
override afterClose(): void { (this.pickrInstance as any)?._root.app.classList.remove('visible'); this.pickrInstance?.hide(); this.pickrInstance?.destroy(); this.pickrInstance = null; this.pickrButton?.remove(); this.pickrButton = null; }
override onFocus(): void { this.colorInput.nativeElement.focus(); }}
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-color-picker', template: `<div><hot-table [data]="data" [settings]="gridSettings"></hot-table></div>`,})export class AppComponent { readonly data = inputData.map((el) => ({ ...el, color: `#${Math.round(0x1000000 + 0xffffff * Math.random()).toString(16).slice(1).toUpperCase()}`, }));
readonly gridSettings: GridSettings = { autoRowSize: true, rowHeaders: true, autoWrapRow: true, height: 'auto', width: '100%', colHeaders: ['ID', 'Item Name', 'Item Color', 'Item No.', 'Cost', 'Value in Stock'], columns: [ { data: 'id', type: 'numeric', width: 80, headerClassName: 'htLeft' }, { data: 'itemName', type: 'text', width: 200, headerClassName: 'htLeft' }, { data: 'color', headerClassName: 'htLeft', editor: ColorPickerEditorComponent, renderer: ColorRendererComponent, validator: colorValidator, } as any, { data: 'itemNo', type: 'text', width: 100, headerClassName: 'htLeft' }, { data: 'cost', type: 'numeric', width: 70, headerClassName: 'htLeft' }, { data: 'valueStock', type: 'numeric', width: 130, headerClassName: 'htRight' }, ], };}/* end-file */
/* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-color-picker></example1-color-picker></div>Overview
This guide shows how to create a custom color picker cell using the Pickr library. Users can click a cell to open a color picker, select a color, and see it rendered with a colored background.
Difficulty: Beginner
Time: ~15 minutes
Libraries: @simonwep/pickr
What You’ll Build
A cell that:
- Displays a colored circle swatch in the cell
- Opens a color picker when edited via an “Open color picker” button
- Shows a styled editor input with Handsontable’s native blue border
- Validates hex color format
- Updates the value as you pick a color; closing the picker saves and finishes editing
- Closes the picker on Tab or Escape
Prerequisites
npm install @simonwep/pickrImport Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';import Pickr from '@simonwep/pickr';import '@simonwep/pickr/dist/themes/nano.min.css';registerAllModules();Why this matters:
editorFactoryandrendererFactoryare Handsontable helpers for creating custom editors and renderers- Pickr is created per-editor in the
afterInithook and attached to a button - Import Pickr theme CSS (e.g.
nano.min.css) for the color picker UI styling
Add CSS Styling
Create a separate CSS file for the cell and editor styles. This uses Handsontable CSS custom properties (tokens) to match the native editor appearance.
.color-picker-cell {display: flex;align-items: center;justify-content: center;}.color-picker-swatch {width: 18px;height: 18px;border-radius: 50%;flex-shrink: 0;border: 1px solid rgba(0, 0, 0, 0.15);}.color-picker-editor {width: 100%;height: 100%;border: none;outline: none;box-sizing: border-box !important;cursor: pointer;-webkit-appearance: none;appearance: none;}.pickr {position: absolute;top: 0;left: 0;opacity: 0;pointer-events: none;}What’s happening:
.color-picker-cellcenters the circle swatch inside the cell.color-picker-swatchrenders a small circle withborder-radius: 50%.color-picker-editorremoves default input borders and appearance so the editor can be styled as needed
Create the Renderer
The renderer controls how the cell looks when not being edited. The renderer displays a colored circle swatch.
renderer: rendererFactory(({ td, value }) => {td.innerHTML = `<span class="color-picker-cell"><span class="color-picker-swatch" style="background:${value}"></span></span>`;})What’s happening:
tdis the table cell DOM elementvalueis the cell’s current value (e.g., “#ff0000”)- The renderer displays a circle swatch with the color as its background
- The swatch is centered inside the cell via CSS flexbox
Create the Validator
The validator ensures only valid hex colors are saved.
validator: (value, callback) => {callback(value.length === 7 && value[0] == '#');}What’s happening:
- Check if value is exactly 7 characters
- Check if it starts with ’#’
- Call
callback(true)for valid,callback(false)for invalid
Improvements for production:
- Use regex:
/^#[0-9A-Fa-f]{6}$/ - Support short format:
#fff - Support RGB/RGBA values
Editor - Initialize (
init)Create the input element that will hold the hex value, and apply the editor CSS class. A separate button for opening the picker is added in
afterInit.init(editor) {editor.input = editor.hot.rootDocument.createElement('INPUT') as HTMLInputElement;editor.input.setAttribute('aria-label', 'Open color picker');editor.input.classList.add('color-picker-editor');}What’s happening:
- Create an
inputelement usingeditor.hot.rootDocument.createElement() - Set an aria-label for accessibility
- Add the
color-picker-editorCSS class for the blue border styling - The
editorFactoryhelper will handle container creation and DOM insertion
Key points:
- Use
editor.hot.rootDocument.createElement()(notdocument.createElement()) for iframe/shadow DOM compatibility - The input stores the current hex value; a button added in
afterInitwill open the Pickr popup - The CSS class styles the editor (no border, full size) so the picker button is the main control
- Create an
Editor - After Init Hook (
afterInit)Create a button, attach Pickr to it, and set up the
changeandhideevent handlers. SettingpreventCloseElementkeeps the editor open when the user clicks inside the Pickr popup.afterInit(editor) {const button = editor.hot.rootDocument.createElement('button');button.textContent = 'Open color picker';button.classList.add('color-picker-button');editor.input.after(button);editor.pickr = Pickr.create({el: button,theme: 'nano',default: editor.input.value || '#000000',autoReposition: false,padding: 0,components: {preview: true,hue: true,}});// Collapse the Pickr trigger button so it doesn't add vertical space// between the cell editor and the popup.editor.pickr._root.root.style.height = '0';editor.pickr._root.root.style.overflow = 'hidden';editor.preventCloseElement = editor.pickr._root.app;editor.pickr.on('change', (color) => {if (color) {const hex = color.toHEXA().toString();editor.input.value = hex;}});editor.pickr.on('hide', () => {if (Date.now() - editor._openedAt < 400) {editor.pickr.show();return;}editor.finishEditing();});}What’s happening:
- Create a button and insert it after the input so users can open the picker
- Create a Pickr instance with theme
nanoand preview + hue components - Set
editor.preventCloseElementto the Pickr root so clicking inside the popup doesn’t close the editor - On
change, update the input value from the selected color (usingcolor.toHEXA().toString()) - On
hide, calleditor.finishEditing()so closing the picker saves the value and closes the editor
Why
afterInitinstead ofinit?afterInitruns after the input is added to the DOM- Pickr needs the button to be in the DOM to attach properly
Editor - After Open Hook (
afterOpen)Set the current color and show the Pickr picker.
afterOpen(editor) {editor._openedAt = Date.now();editor.pickr.setColor(editor.input.value || '#000000');editor.pickr.show();// Pickr positions its popup relative to the trigger button with an// internal offset. Use double-rAF to ensure Pickr's own positioning// is complete before overriding the top to sit flush below the cell.requestAnimationFrame(() => {requestAnimationFrame(() => {const cellRect = editor.TD.getBoundingClientRect();editor.pickr._root.app.style.top = `${cellRect.bottom}px`;});});}What’s happening:
- Sets the picker to the cell’s current color
- Calls
show()to open the Pickr popup
Why
afterOpeninstead ofopen?afterOpenruns after positioning is complete- The
editorFactoryhelper handles positioning inopen
Editor - After Close Hook (
afterClose)Ensure the Pickr popup is hidden when the editor closes.
afterClose(editor) {editor.pickr._root.app.classList.remove('visible');editor.pickr.hide();}What’s happening:
- Called when the editor is closed (by Tab, Escape, clicking outside, etc.)
- Hides the Pickr picker via
editor.pickr.hide() - Without this, the picker popup could remain visible after the editor closes
Editor - Get Value / Set Value
Standard value management hooks.
getValue(editor) {return editor.input.value;}setValue(editor, value) {editor.input.value = value;}What’s happening:
getValuereturns the input’s current value (hex color code) when Handsontable saves the cellsetValueinitializes the editor with the cell’s current color value- On Pickr’s
changeevent,editor.input.valueis set from the selected color; when the user closes the picker,hidefires andeditor.finishEditing()is called
Editor - Keyboard Shortcuts
Add a Tab shortcut to hide the picker (which triggers the
hideevent and thenfinishEditing()).shortcuts: [{keys: [['Tab']],callback: (editor) => {editor.pickr.hide();},},]What’s happening:
- Pressing Tab hides the Pickr popup, which fires the
hideevent and callseditor.finishEditing() - Uses the
editorFactoryshortcuts API to register key bindings afterClosealso callseditor.pickr.hide()when the editor closes by other means
- Pressing Tab hides the Pickr popup, which fires the
Complete Cell Definition
Put it all together. The editor uses a hidden input for the value and a button that opens the Pickr popup; color is updated on
changeand editing finishes when the picker is closed (hide).type ColorPickerEditor = {input: HTMLInputElement;pickr: ReturnType<typeof Pickr.create>;preventCloseElement: HTMLElement;};const cellDefinition = {renderer: rendererFactory(({ td, value }) => {td.innerHTML = `<span class="color-picker-cell"><span class="color-picker-swatch" style="background:${value}"></span></span>`;}),validator: (value, callback) => {callback(value.length === 7 && value[0] == '#');},editor: editorFactory<ColorPickerEditor>({init(editor) {editor.input = editor.hot.rootDocument.createElement('INPUT') as HTMLInputElement;editor.input.setAttribute('aria-label', 'Open color picker');editor.input.classList.add('color-picker-editor');},afterInit(editor) {const button = editor.hot.rootDocument.createElement('button');button.textContent = 'Open color picker';button.classList.add('color-picker-button');editor.input.after(button);editor.pickr = Pickr.create({el: button,theme: 'nano',default: editor.input.value || '#000000',autoReposition: false,padding: 0,components: { preview: true, hue: true },});// Collapse the Pickr trigger button so it doesn't add vertical space// between the cell editor and the popup.editor.pickr._root.root.style.height = '0';editor.pickr._root.root.style.overflow = 'hidden';editor.preventCloseElement = editor.pickr._root.app;editor.pickr.on('change', (color) => {if (color) editor.input.value = color.toHEXA().toString();});editor.pickr.on('hide', () => {if (Date.now() - editor._openedAt < 400) {editor.pickr.show();return;}editor.finishEditing();});},afterOpen(editor) {editor._openedAt = Date.now();editor.pickr.setColor(editor.input.value || '#000000');editor.pickr.show();// Pickr positions its popup relative to the trigger button with an// internal offset. Override the top to sit flush below the cell.requestAnimationFrame(() => {requestAnimationFrame(() => {const cellRect = editor.TD.getBoundingClientRect();editor.pickr._root.app.style.top = `${cellRect.bottom}px`;});});},afterClose(editor) {editor.pickr._root.app.classList.remove('visible');editor.pickr.hide();},getValue(editor) {return editor.input.value;},setValue(editor, value) {editor.input.value = value;},shortcuts: [{keys: [['Tab']],callback: (editor) => editor.pickr.hide(),},],}),};What’s happening:
- renderer: Displays a colored circle swatch centered in the cell
- validator: Ensures hex color format (# followed by 6 characters)
- editor: Uses
editorFactoryhelper with:init: Creates styled input element and aria-labelafterInit: Creates button, Pickr on the button (nano theme),preventCloseElement, andchange/hidehandlersafterOpen: Sets current color and shows the pickerafterClose: Hides the Pickr popup when editor closesshortcuts: Tab key hides the picker (which triggershideand thenfinishEditing)getValue/setValue: Standard value management via the input
Note: The
editorFactoryhelper handles container creation, positioning, and lifecycle management automatically.Use in Handsontable
const container = document.querySelector('#example1')!;const hotOptions: Handsontable.GridSettings = {data: [{ id: 1, itemName: 'Lunar Core', color: '#FF5733', itemNo: 'XJ-12', cost: 350000, valueStock: 700000 },{ id: 2, itemName: 'Zero Thrusters', color: '#33FF57', itemNo: 'QL-54', cost: 450000, valueStock: 0 },{ id: 3, itemName: 'EVA Suits', color: '#3357FF', itemNo: 'PM-67', cost: 150000, valueStock: 7500000 },],colHeaders: ['ID', 'Item Name', 'Item Color', 'Item No.', 'Cost', 'Value in Stock'],autoRowSize: true,rowHeaders: true,height: 'auto',width: '100%',columns: [{ data: 'id', type: 'numeric', width: 80, headerClassName: 'htLeft' },{ data: 'itemName', type: 'text', width: 200, headerClassName: 'htLeft' },{ data: 'color', headerClassName: 'htLeft', ...cellDefinition },{ data: 'itemNo', type: 'text', width: 100, headerClassName: 'htLeft' },{ data: 'cost', type: 'numeric', width: 70, headerClassName: 'htLeft' },{ data: 'valueStock', type: 'numeric', width: 130, headerClassName: 'htRight' },],licenseKey: 'non-commercial-and-evaluation',};const hot = new Handsontable(container, hotOptions);Key configuration:
...cellDefinition- Spreads renderer, validator, and editor into the color column config- The validator ensures only valid hex colors are saved
- The live example uses more rows and random hex colors; you can use any data that includes a
colorfield
How It Works - Complete Flow
- Initial Render: Cell displays a colored circle swatch
- User Double-Clicks or F2: Editor opens with a styled input and an “Open color picker” button
- Color Picker Opens:
afterOpensets the current color and callspickr.show() - User Selects Color: Pickr updates the preview; the
changeevent updateseditor.input.valuewith the hex fromcolor.toHEXA().toString() - User Closes Picker (or presses Tab): The
hideevent fires (or Tab triggerspickr.hide()), theneditor.finishEditing()is called - Validation: Validator checks hex format (# followed by 6 characters)
- Save: If valid, value is saved to cell; if invalid, editor may stay open
- Editor Closes:
afterClosecallseditor.pickr.hide(), cell renderer shows updated swatch
Enhancements
Add Color Swatches
Provide preset colors:
editor.pickr = Pickr.create({el: button,// ...other optionsswatches: ['#ff0000','#00ff00','#0000ff','#ffff00','#ff00ff','#00ffff'],});Support Alpha Channel
Allow transparency by setting
lockOpacity: falseand enabling the opacity component:Pickr.create({// ...lockOpacity: false,components: {preview: true,opacity: true,hue: true,// ...},});// Update validator for rgbavalidator: (value, callback) => {const rgbaRegex = /^rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*[\d.]+)?\)$/;callback(rgbaRegex.test(value));}Use a Different Theme
This recipe uses the
nanotheme. Pickr also offersclassicandmonolith. To switch, change the CSS import and thethemeoption:import '@simonwep/pickr/dist/themes/classic.min.css';// In Pickr.create():theme: 'classic',
What you learned
You integrated the Pickr color picker library as a Handsontable cell editor. You used editorFactory to manage the editor lifecycle, rendererFactory to display a color swatch, and Handsontable’s CSS tokens to style the editor consistently with the rest of the grid.
Next steps
- Colorful Picker (React) - The same pattern using
react-colorfuland React’sEditorComponent. - Color Picker (Angular) - The same pattern using Angular components and the native HTML5 color input.
- Star Rating - Another custom editor built with
editorFactoryand SVG.