Feedback
This tutorial shows you how to build an emoji feedback cell using Handsontableโs editorFactory helper, with Handsontable CSS tokens for theme-aware styling and keyboard navigation.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { editorFactory } from 'handsontable/editors';import { registerCellType } from 'handsontable/cellTypes';
// Register all Handsontable's modules.registerAllModules();
/* start:skip-in-preview */const data = [ { feature: 'Dark Mode', category: 'UI', priority: 'High', feedback: '๐', votes: 124, status: 'Planned' }, { feature: 'Bulk Edit', category: 'Core', priority: 'High', feedback: '๐', votes: 98, status: 'In Progress' }, { feature: 'AI Suggestions', category: 'Beta', priority: 'Medium', feedback: '๐คท', votes: 45, status: 'Research' }, { feature: 'Offline Mode', category: 'Infra', priority: 'Low', feedback: '๐', votes: 12, status: 'Backlog' },];/* end:skip-in-preview */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1');const cellDefinition = { editor: editorFactory({ config: ['๐', '๐', '๐คท'], value: '๐', shortcuts: [ { keys: [['ArrowRight'], ['Tab']], callback: (editor, _event) => { let index = editor.config.indexOf(editor.value);
index = index === editor.config.length - 1 ? 0 : index + 1; editor.setValue(editor.config[index]);
return false; // Prevent default tabbing behavior }, }, { keys: [['ArrowLeft']], callback: (editor, _event) => { let index = editor.config.indexOf(editor.value);
index = index === 0 ? editor.config.length - 1 : index - 1; editor.setValue(editor.config[index]); }, }, ], render: (editor) => { editor.input.innerHTML = editor.config .map((option) => `<button class="${editor.value === option ? 'active' : ''}">${option}</button>`) .join(''); }, init: (editor) => { editor.input = document.createElement('DIV'); editor.input.classList.add('feedback-editor'); editor._openedAt = 0; editor.input.addEventListener('click', (event) => { // Ignore synthetic click events that Android fires right after the editor // opens โ they land on the button that just appeared at the touch position. if (Date.now() - editor._openedAt < 300) { return; } if (event.target instanceof HTMLButtonElement) { editor.setValue(event.target.innerText); editor.finishEditing(); } }); editor.render(editor); }, afterOpen: (editor) => { editor._openedAt = Date.now(); }, beforeOpen: (editor, { originalValue, cellProperties }) => { editor.setValue(originalValue); }, }),};
registerCellType('feedback', cellDefinition);
// Define configuration options for the Handsontableconst hotOptions = { data, colHeaders: ['Feature', 'Category', 'Priority', 'Feedback', 'Votes', 'Status'], autoRowSize: true, rowHeaders: true, autoWrapRow: true, height: 'auto', width: '100%', headerClassName: 'htLeft', columns: [ { data: 'feature', type: 'text', width: 200 }, { data: 'category', type: 'text', width: 90 }, { data: 'priority', type: 'text', width: 100 }, { data: 'feedback', width: 100, type: 'feedback' }, { data: 'votes', type: 'numeric', width: 60 }, { data: 'status', type: 'text', width: 120 }, ], licenseKey: 'non-commercial-and-evaluation',};
// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);import Handsontable from "handsontable/base";import { registerAllModules } from "handsontable/registry";import { CellProperties } from "handsontable/settings";import { BaseEditor } from "handsontable/editors/baseEditor";import { editorFactory } from "handsontable/editors";import { registerCellType } from 'handsontable/cellTypes';// Register all Handsontable's modules.registerAllModules();
/* start:skip-in-preview */
const data = [ { feature: "Dark Mode", category: "UI", priority: "High", feedback: "๐", votes: 124, status: "Planned" }, { feature: "Bulk Edit", category: "Core", priority: "High", feedback: "๐", votes: 98, status: "In Progress" }, { feature: "AI Suggestions", category: "Beta", priority: "Medium", feedback: "๐คท", votes: 45, status: "Research" }, { feature: "Offline Mode", category: "Infra", priority: "Low", feedback: "๐", votes: 12, status: "Backlog" },];
/* end:skip-in-preview */
// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector("#example1")!;
const cellDefinition: Pick< CellProperties, "renderer" | "validator" | "editor"> = { editor: editorFactory< { input: HTMLDivElement; value: string; config: string[] } >({ config: ["๐", "๐", "๐คท"], value: "๐", shortcuts: [ { keys: [["ArrowRight"], ["Tab"]], callback: (editor, _event) => { let index = editor.config.indexOf(editor.value); index = index === editor.config.length - 1 ? 0 : index + 1; editor.setValue(editor.config[index]); return false; // Prevent default tabbing behavior }, }, { keys: [["ArrowLeft"]], callback: (editor, _event) => { let index = editor.config.indexOf(editor.value); index = index === 0 ? editor.config.length - 1 : index - 1; editor.setValue(editor.config[index]); }, }, ], render: (editor) => { editor.input.innerHTML = editor.config .map( (option) => `<button class="${editor.value === option ? "active" : ""}">${option}</button>`, ) .join(""); }, init: (editor) => { editor.input = document.createElement("DIV") as HTMLDivElement; editor.input.classList.add("feedback-editor"); editor._openedAt = 0; editor.input.addEventListener("click", (event) => { // Ignore synthetic click events that Android fires right after the editor // opens โ they land on the button that just appeared at the touch position. if (Date.now() - editor._openedAt < 300) { return; } if (event.target instanceof HTMLButtonElement) { editor.setValue(event.target.innerText); editor.finishEditing(); } }); editor.render(editor); }, afterOpen: (editor) => { editor._openedAt = Date.now(); }, beforeOpen: (editor, { originalValue, cellProperties }) => { editor.setValue(originalValue); }, }),};
registerCellType('feedback', cellDefinition as BaseEditor);
// Define configuration options for the Handsontableconst hotOptions: Handsontable.GridSettings = { data, colHeaders: ["Feature", "Category", "Priority", "Feedback", "Votes", "Status"], autoRowSize: true, rowHeaders: true, autoWrapRow: true, height: "auto", width: "100%", headerClassName: "htLeft", columns: [ { data: "feature", type: "text", width: 200 }, { data: "category", type: "text", width: 90 }, { data: "priority", type: "text", width: 100 }, { data: "feedback", width: 100, type: "feedback" }, { data: "votes", type: "numeric", width: 60 }, { data: "status", type: "text", width: 120 }, ], licenseKey: "non-commercial-and-evaluation",};
// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);.feedback-editor { display: flex; gap: var(--ht-gap, 4px); width: 100%; height: 100%; box-sizing: border-box !important; padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px); background-color: var(--ht-cell-editor-background-color, #ffffff); 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); border: none; border-radius: 0;}
.feedback-editor button { display: block; background: var(--ht-background-color, #ffffff); color: var(--ht-foreground-color, #000000); border: 1px solid var(--ht-border-color, #e0e0e0); border-radius: var(--ht-border-radius, 4px); padding: 0; margin: 0; height: 100%; width: 33%; font-size: var(--ht-font-size, 14px); text-align: center; cursor: pointer;}
.feedback-editor button:hover { background: var(--ht-border-color, #e0e0e0);}
.feedback-editor button.active,.feedback-editor button.active:hover { background: var(--ht-accent-color, #1a42e8); color: #ffffff; border-color: var(--ht-accent-color, #1a42e8);}Overview
This guide shows how to create a feedback editor cell using emoji buttons. Use it for status indicators or any scenario where users choose from a small set of visual options.
Difficulty: Beginner Time: ~15 minutes Libraries: None (pure HTML)
What Youโll Build
A cell that:
- Displays emoji feedback buttons (rounded) when editing
- Shows the selected emoji when viewing
- Uses Handsontable CSS tokens for theme-aware styling
- Supports keyboard navigation (arrow keys, Tab)
- Provides click-to-select functionality
- Works without any external libraries
Prerequisites
None! This uses only native HTML and JavaScript features.
Import Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { editorFactory } from 'handsontable/editors';import { registerCellType } from 'handsontable/cellTypes';registerAllModules();What weโre NOT importing:
- No date libraries
- No UI component libraries
- No external emoji libraries
- Handsontable only.
Add CSS Styling
Create a separate CSS file for the editor styles. This uses Handsontable CSS custom properties (tokens) so the editor automatically adapts to custom themes and dark mode.
.feedback-editor {display: flex;gap: var(--ht-gap, 4px);width: 100%;height: 100%;box-sizing: border-box !important;padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px);background-color: var(--ht-cell-editor-background-color, #ffffff);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);border: none;border-radius: 0;}.feedback-editor button {background: var(--ht-background-color, #ffffff);color: var(--ht-foreground-color, #000000);border: 1px solid var(--ht-border-color, #e0e0e0);border-radius: var(--ht-border-radius, 4px);padding: 0;margin: 0;height: 100%;width: 33%;font-size: var(--ht-font-size, 14px);text-align: center;cursor: pointer;}.feedback-editor button:hover {background: var(--ht-border-color, #e0e0e0);}.feedback-editor button.active,.feedback-editor button.active:hover {background: var(--ht-accent-color, #1a42e8);color: #ffffff;border-color: var(--ht-accent-color, #1a42e8);}Handsontable tokens used:
--ht-cell-editor-border-color/--ht-cell-editor-border-width- blue border matching native editors--ht-cell-editor-background-color- editor background--ht-cell-vertical-padding/--ht-cell-horizontal-padding- consistent cell padding--ht-background-color/--ht-foreground-color- base button colors--ht-border-color- button borders and hover state--ht-accent-color- active/selected button highlight--ht-border-radius- button corner rounding--ht-font-size/--ht-gap- consistent sizing
Editor - Initialize (
init)Create the DOM structure with emoji buttons, this function will be called only once.
init(editor) {editor.input = document.createElement('DIV') as HTMLDivElement;editor.input.classList.add('feedback-editor');editor._openedAt = 0;editor.input.addEventListener('click', (event) => {// Ignore synthetic click events that Android fires right after the editor// opens โ they land on the button that just appeared at the touch position.if (Date.now() - editor._openedAt < 300) {return;}if (event.target instanceof HTMLButtonElement) {editor.setValue(event.target.innerText);editor.finishEditing();}});editor.render(editor);}Whatโs happening:
- Create a
divcontainer for the buttons - Add the
feedback-editorCSS class (all styling is in the CSS file) - Add click handler to detect button clicks
- When a button is clicked, set the value and finish editing
- Call
renderto create the initial button layout
- Create a
Editor - Render Function
Create buttons dynamically based on the config, using CSS classes instead of inline styles.
render(editor) {editor.input.innerHTML = editor.config.map((option) =>`<button class="${editor.value === option ? 'active' : ''}">${option}</button>`).join('');}Whatโs happening:
- Generate HTML for each button from
configarray - Add
activeclass to the currently selected button - The
.activeCSS class applies--ht-accent-coloras background - Each button takes 33% width with rounded corners (
--ht-border-radius)
- Generate HTML for each button from
Editor - Keyboard Shortcuts
Add arrow key navigation to cycle through options.
shortcuts: [{keys: [['ArrowRight']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === editor.config.length - 1 ? 0 : index + 1;editor.setValue(editor.config[index]);}},{keys: [['ArrowLeft']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === 0 ? editor.config.length - 1 : index - 1;editor.setValue(editor.config[index]);}}]Whatโs happening:
- ArrowRight: Move to next option (wraps to first if at end)
- ArrowLeft: Move to previous option (wraps to last if at start)
- Finds current index in config array
- Updates value and triggers render automatically
Keyboard navigation benefits:
- Fast selection without mouse
- Accessible for keyboard-only users
- Intuitive left/right navigation
Editor โ Custom Tab Key Behavior
By default, pressing Tab in Handsontable saves the cell and moves the selection horizontally, following your layout direction. In this example, Tab cycles through feedback options โ the same as the arrow keys โ without moving to another cell. The editorโs
shortcutsoption handles this by returningfalsein the callback to prevent the default action (saving and moving to the next cell).shortcuts: [{keys: [['ArrowRight'], ['Tab']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === editor.config.length - 1 ? 0 : index + 1;editor.setValue(editor.config[index]);return false; // Prevent default tabbing behavior},},]How it works:
- Listens for Tab when the editor is active
- Moves to the next option in
config(wraps around at the end) - Updates the editorโs value and button highlight
- Returning
falseblocks Handsontableโs built-in tab handler, so editing stays in place
Editor - Before Open Hook
Initialize the editor with the current cell value when editing starts.
beforeOpen(editor, { originalValue, cellProperties }) {editor.setValue(originalValue);}Whatโs happening:
- Called when editor is about to open
- Receives the current cell value as
originalValue - Sets the editorโs value to match the cell
- This ensures the correct button is highlighted when editing starts
Complete Cell Definition
const cellDefinition = {editor: editorFactory<{ input: HTMLDivElement; value: string; config: string[] }>({config: ['๐', '๐', '๐คท'],value: '๐',shortcuts: [{keys: [['ArrowRight'], ['Tab']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === editor.config.length - 1 ? 0 : index + 1;editor.setValue(editor.config[index]);return false;},},{keys: [['ArrowLeft']],callback: (editor, _event) => {let index = editor.config.indexOf(editor.value);index = index === 0 ? editor.config.length - 1 : index - 1;editor.setValue(editor.config[index]);},},],render: (editor) => {editor.input.innerHTML = editor.config.map((option) =>`<button class="${editor.value === option ? 'active' : ''}">${option}</button>`,).join('');},init: (editor) => {editor.input = document.createElement('DIV') as HTMLDivElement;editor.input.classList.add('feedback-editor');editor._openedAt = 0;editor.input.addEventListener('click', (event) => {if (Date.now() - editor._openedAt < 300) {return;}if (event.target instanceof HTMLButtonElement) {editor.setValue(event.target.innerText);editor.finishEditing();}});editor.render(editor);},afterOpen: (editor) => {editor._openedAt = Date.now();},beforeOpen: (editor, { originalValue, cellProperties }) => {editor.setValue(originalValue);},}),};Whatโs happening:
- config: Array of emoji options (
๐,๐,๐คท) - value: Default/initial value
- shortcuts: Keyboard navigation (ArrowLeft/ArrowRight cycle options, Tab cycles and prevents default)
- render: Creates button HTML with
activeCSS class for the selected option - init: Sets up the container with
feedback-editorclass and click handler - beforeOpen: Initializes editor with the current cell value
Note: No custom renderer needed! Handsontableโs default renderer will display the emoji value in the cell. All visual styling is handled by the CSS file using Handsontable tokens.
- config: Array of emoji options (
Register and Use in Handsontable
Register the cell definition as a reusable cell type, then use it in the column configuration.
registerCellType('feedback', cellDefinition);const container = document.querySelector('#example1')!;const hotOptions: Handsontable.GridSettings = {data: [{ feature: 'Dark Mode', category: 'UI', priority: 'High', feedback: '๐', votes: 124, status: 'Planned' },{ feature: 'Bulk Edit', category: 'Core', priority: 'High', feedback: '๐', votes: 98, status: 'In Progress' },{ feature: 'AI Suggestions', category: 'Beta', priority: 'Medium', feedback: '๐คท', votes: 45, status: 'Research' },{ feature: 'Offline Mode', category: 'Infra', priority: 'Low', feedback: '๐', votes: 12, status: 'Backlog' },],colHeaders: ['Feature', 'Category', 'Priority', 'Feedback', 'Votes', 'Status'],autoRowSize: true,rowHeaders: true,autoWrapRow: true,height: 'auto',width: '100%',headerClassName: 'htLeft',columns: [{ data: 'feature', type: 'text', width: 200 },{ data: 'category', type: 'text', width: 90 },{ data: 'priority', type: 'text', width: 100 },{ data: 'feedback', width: 100, type: 'feedback' },{ data: 'votes', type: 'numeric', width: 60 },{ data: 'status', type: 'text', width: 120 },],licenseKey: 'non-commercial-and-evaluation',};const hot = new Handsontable(container, hotOptions);Key configuration:
registerCellType('feedback', cellDefinition)- Registers the editor as a reusable cell typetype: 'feedback'- Applies the cell type to the Feedback columnheaderClassName: 'htLeft'- Left-aligns all column headers
How It Works - Complete Flow
- Initial Render: Cell displays the emoji value (๐, ๐, or ๐คท)
- User Double-Clicks or Enter: Editor opens over cell showing three rounded buttons with the Handsontable blue border
- Button Display: All options visible, current value highlighted using
--ht-accent-color - User Interaction:
- Click a button: Selects value and closes editor
- Press ArrowLeft/ArrowRight: Cycles through options
- Press Tab: Cycles through options (stays in editor)
- Enter key saves value and closes editor
- Visual Feedback: Selected button highlighted with accent color
- Save: Value saved to cell
- Editor Closes: Cell shows selected emoji
Enhancements
More Feedback Options
Add more emoji options by extending the config array and adjusting the button width in CSS:
config: ['๐', '๐', '๐คท', 'โค๏ธ', '๐ฅ', 'โญ'],Dynamic Config from Cell Properties
Make options configurable per column:
beforeOpen: (editor, { cellProperties }) => {if (cellProperties.feedbackOptions) {editor.config = cellProperties.feedbackOptions;}editor.setValue(editor.originalValue || editor.value);},// Usagecolumns: [{data: 'feedback',type: 'feedback',feedbackOptions: ['๐', '๐', 'โค๏ธ', '๐ฅ']}]Tooltip on Hover
Add tooltips to buttons:
render: (editor) => {const tooltips = { '๐': 'Positive', '๐': 'Negative', '๐คท': 'Neutral' };editor.input.innerHTML = editor.config.map((option) =>`<button class="${editor.value === option ? 'active' : ''}" title="${tooltips[option] || ''}">${option}</button>`).join('');}Text Labels Instead of Emojis
Use text buttons for clarity:
config: ['Positive', 'Negative', 'Neutral'],
Accessibility
Keyboard navigation:
- Tab: Cycles through feedback options (stays in editor)
- ArrowLeft / ArrowRight: Cycles through options
- Enter: Saves value and closes editor
- Escape: Cancels editing
- Click: Direct selection
What you learned
You built an emoji feedback cell editor using Handsontableโs editorFactory helper. You used Handsontable CSS custom properties to style the editor in a theme-aware way, and registered the result as a reusable cell type with registerCellType.
Next steps
- Feedback (React) - The same pattern using Reactโs
EditorComponent. - Feedback Editor (Angular) - The Angular version using
HotCellEditorAdvancedComponent. - Star Rating - Another custom editor built with
editorFactoryand SVG stars.