Radio buttons
This tutorial shows you how to build a custom radio button cell type using a renderer that displays radio inputs directly in cells, with ARIA semantics, roving tabindex, and keyboard navigation.
/* file: app.component.ts */import { Component } from '@angular/core';import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper';import { rendererFactory } from 'handsontable/renderers';import { BaseEditor } from 'handsontable/editors/baseEditor';import { registerCellType } from 'handsontable/cellTypes';import type { CellProperties } from 'handsontable/settings';import type Handsontable from 'handsontable/base';
/* start:skip-in-preview */const inputData = [ { task: 'Refactor licensing app navigation', priority: 'high', status: 'in-progress' }, { task: 'Theme Builder onboarding tour', priority: 'medium', status: 'todo' }, { task: 'MCP server v1 release prep', priority: 'high', status: 'in-progress' }, { task: 'GitHub triage rotation docs', priority: 'low', status: 'done' }, { task: 'shadcn/ui integration recipe', priority: 'medium', status: 'todo' },];/* end:skip-in-preview */
type RadioOption = { value: string; label: string } | string;
interface RadioCellProperties extends CellProperties { options: RadioOption[];}
class RadioEditor extends BaseEditor { override init(): void {} override open(): void {} override close(): void {} override focus(): void {} override getValue(): string { return this.originalValue as string; } override setValue(value: string): void { this.originalValue = value; }}
const radioRenderer = rendererFactory(({ instance, td, row, column, value, cellProperties }) => { while (td.firstChild) td.removeChild(td.firstChild);
const options: RadioOption[] = (cellProperties as RadioCellProperties).options || []; const wrapper = document.createElement('div');
wrapper.className = 'htRadioCell'; wrapper.setAttribute('role', 'radiogroup');
const colHeader = instance.getColHeader(column as number);
if (colHeader) wrapper.setAttribute('aria-label', String(colHeader));
const isReadOnly = !!cellProperties.readOnly;
const hasChecked = options.some((opt) => { const v = typeof opt === 'object' ? opt.value : opt;
return String(v) === String(value); });
const cycle = (direction: 'next' | 'prev') => { if (!options.length) return; const i = options.findIndex((opt) => { const v = typeof opt === 'object' ? opt.value : opt;
return String(v) === String(value); }); const last = options.length - 1; const next = direction === 'next' ? (i === last || i === -1 ? 0 : i + 1) : (i <= 0 ? last : i - 1); const newValue = typeof options[next] === 'object' ? (options[next] as { value: string }).value : (options[next] as string);
instance.setDataAtCell(row as number, column as number, newValue); queueMicrotask(() => { const newTd = instance.getCell(row as number, column as number);
newTd?.querySelector<HTMLInputElement>(`input[type="radio"][value="${CSS.escape(newValue)}"]`)?.focus(); }); };
options.forEach((opt, idx) => { const optValue = typeof opt === 'object' ? opt.value : opt; const optLabel = typeof opt === 'object' ? opt.label : opt; const label = document.createElement('label');
label.className = 'htUIRadio';
const input = document.createElement('input');
input.type = 'radio'; input.className = 'htUIRadioInput'; input.name = `radio-r${row}-c${column}`; input.value = optValue; input.checked = String(optValue) === String(value); input.tabIndex = (input.checked || (!hasChecked && idx === 0)) ? 0 : -1; input.disabled = isReadOnly;
const span = document.createElement('span');
span.className = 'htUIRadioLabel'; span.textContent = optLabel;
label.appendChild(input); label.appendChild(span); wrapper.appendChild(label);
input.addEventListener('change', (e) => { e.stopPropagation(); const v = (e.target as HTMLInputElement).value;
instance.setDataAtCell(row as number, column as number, v); queueMicrotask(() => { const newTd = instance.getCell(row as number, column as number);
newTd?.querySelector<HTMLInputElement>(`input[type="radio"][value="${CSS.escape(v)}"]`)?.focus(); }); });
label.addEventListener('mousedown', (e) => e.stopPropagation());
input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); instance.selectCell(row as number, column as number);
return; } if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); cycle('next');
return; } if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); e.stopPropagation(); cycle('prev');
return; } e.stopPropagation(); }); });
td.appendChild(wrapper); td.style.verticalAlign = 'top';});
// eslint-disable-next-line @typescript-eslint/no-explicit-anyregisterCellType('radio', { editor: RadioEditor, renderer: radioRenderer } as any);
const priorityOptions: RadioOption[] = [ { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, { value: 'high', label: 'High' },];
const statusOptions: RadioOption[] = [ { value: 'todo', label: 'Todo' }, { value: 'in-progress', label: 'In progress' }, { value: 'done', label: 'Done' },];
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-radio', template: `<div><hot-table [data]="data" [settings]="gridSettings"></hot-table></div>`,})export class AppComponent { readonly data = inputData;
readonly gridSettings: GridSettings = { colHeaders: ['Task', 'Priority', 'Status'], rowHeaders: true, height: 'auto', width: '100%', rowHeights: 96, columns: [ { data: 'task', type: 'text', width: 300 }, { data: 'priority', type: 'radio', width: 160, options: priorityOptions } as Handsontable.ColumnSettings, { data: 'status', type: 'radio', width: 170, options: statusOptions } as Handsontable.ColumnSettings, ], afterInit(this: Handsontable.Core) { this.rootElement.addEventListener('keydown', (e) => { if ((e.target as HTMLElement).tagName === 'INPUT') return; if (e.key !== 'Enter' && e.key !== 'F2') return;
const sel = this.getSelectedLast();
if (!sel) return;
const [r, c] = sel;
if (this.getCellMeta(r, c)['type'] !== 'radio') return;
const td = this.getCell(r, c); const target = td?.querySelector<HTMLInputElement>('input[type="radio"]:checked') ?? td?.querySelector<HTMLInputElement>('input[type="radio"]');
if (target) { e.stopImmediatePropagation(); e.preventDefault(); target.focus(); } }, true); }, };}/* 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-radio></example1-radio></div>/* Scoped to .htRadioCell so theme's .htUIRadio styles in context menus and filter panels are not affected. The radio indicator appearance (dot, border, checked state) is intentionally left to the Handsontable theme. */.htRadioCell { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; line-height: 1.4;}
.htRadioCell .htUIRadio { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ht-foreground-color, #1f2937); padding: 2px 6px !important; border-radius: 4px; cursor: pointer; user-select: none;}
.htRadioCell .htUIRadioLabel { flex: 1;}
/* Ensure pointer cursor and prevent default outline; theme handles appearance. */.htRadioCell .htUIRadio > input[type='radio'] { cursor: pointer;}Overview
This guide shows how to build a renderer-based radio cell type. The radio buttons are always visible inside the cell — no editor popup. The approach is identical to the JavaScript version: rendererFactory builds the radio UI, a BaseEditor stub satisfies the cell-type API, and an afterInit hook adds the Enter/F2 bridge.
Difficulty: Intermediate Time: ~20 minutes Libraries: None (pure HTML and CSS)
What You’ll Build
A cell type that:
- Renders a list of labeled radio buttons directly in the cell (always visible, no editor popup)
- Stores a single selected value per cell
- Accepts an
optionsarray ({ value, label }or plain strings) per column - Supports keyboard navigation (ArrowDown, ArrowUp, Escape)
- Uses roving tabindex for correct Tab behavior within the group
- Exposes
role="radiogroup"with a label derived from the column header - Works without any external libraries
Prerequisites
None. This uses only native HTML, CSS, and the Handsontable API.
Import Dependencies
app.component.ts import { Component } from '@angular/core';import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper';import { rendererFactory } from 'handsontable/renderers';import { BaseEditor } from 'handsontable/editors/baseEditor';import { registerCellType } from 'handsontable/cellTypes';// app.config.ts — registerAllModules() is called hereimport { registerAllModules } from 'handsontable/registry';registerAllModules();Add CSS Styling
Create a separate CSS file. All styles are scoped to
.htRadioCellso they do not affect the same.htUIRadioclasses used by context menus and filter panels..htRadioCell {display: flex;flex-direction: column;gap: 2px;padding: 4px 0;line-height: 1.4;}.htRadioCell .htUIRadio {display: inline-flex;align-items: center;gap: 8px;font-size: 13px;color: var(--ht-foreground-color, #1f2937);padding: 2px 6px !important;border-radius: 4px;cursor: pointer;user-select: none;}.htRadioCell .htUIRadioLabel {flex: 1;}.htRadioCell .htUIRadio > input[type='radio'] {cursor: pointer;}Handsontable token used:
--ht-foreground-color— label text color
Why only layout CSS?
The Handsontable theme already handles radio indicator appearance (circle border, checked dot, colors) via
.htUIRadio > input[type="radio"]using its own--ht-radio-*CSS variables. The CSS file only provides the container layout (vertical flex, padding, row alignment) without overriding those built-in indicator styles.Editor Stub
Angular uses the same
BaseEditorstub as JavaScript. The renderer handles all interaction.class RadioEditor extends BaseEditor {init(): void {}open(): void {}close(): void {}focus(): void {}getValue(): string { return this.originalValue as string; }setValue(value: string): void { this.originalValue = value; }}Renderer — Build the Radio UI
The renderer builds the radio group on every render. It empties the cell, then creates one
<label class="htUIRadio">row per option.const radioRenderer = rendererFactory(({ instance, td, row, column, value, cellProperties }) => {while (td.firstChild) td.removeChild(td.firstChild);const options = (cellProperties as RadioCellProperties).options || [];const wrapper = document.createElement('div');wrapper.className = 'htRadioCell';wrapper.setAttribute('role', 'radiogroup');// ARIA label from the column headerconst colHeader = instance.getColHeader(column as number);if (colHeader) wrapper.setAttribute('aria-label', String(colHeader));// ... build options, attach listeners, append to tdtd.appendChild(wrapper);td.style.verticalAlign = 'top';});What’s happening:
rendererFactorypasses the full renderer context:instance,td,row,column,value,cellProperties.cellProperties.optionscarries the per-column option list.role="radiogroup"+aria-labelmake the group announce correctly to screen readers.td.style.verticalAlign = 'top'aligns the stacked options to the top of the cell.
Roving Tabindex
Only one radio per group should be reachable with Tab. The checked option (or the first option when nothing is selected) gets
tabIndex=0; all others get-1.const hasChecked = options.some((opt) => {const v = typeof opt === 'object' ? opt.value : opt;return String(v) === String(value);});options.forEach((opt, idx) => {const optValue = typeof opt === 'object' ? opt.value : opt;const input = document.createElement('input');input.checked = String(optValue) === String(value);input.tabIndex = (input.checked || (!hasChecked && idx === 0)) ? 0 : -1;// ...});Why roving tabindex?
The ARIA Authoring Practices Guide for radio groups requires exactly one tabbable element in the group. This lets keyboard users Tab past a whole column of radio cells, then use arrow keys to navigate within a cell they care about.
Click and Change Handlers
Clicking a radio saves the value to the data model and restores focus after Handsontable re-renders the cell.
input.addEventListener('change', (e) => {e.stopPropagation();const v = (e.target as HTMLInputElement).value;instance.setDataAtCell(row as number, column as number, v);queueMicrotask(() => {const newTd = instance.getCell(row as number, column as number);newTd?.querySelector<HTMLInputElement>(`input[type="radio"][value="${CSS.escape(v)}"]`)?.focus();});});// Stop mousedown so Handsontable doesn't steal the click for cell selection.label.addEventListener('mousedown', (e) => e.stopPropagation());Why
queueMicrotask?setDataAtCelltriggers a synchronous re-render that replaces the DOM. The focused element disappears.queueMicrotaskschedules a microtask that runs after the render, finds the new radio by value, and refocuses it — without a visible flicker.Why stop
mousedown?Handsontable listens for
mousedownon the table to update cell selection. WithoutstopPropagation, clicking a radio also repositions the Handsontable cursor, which can interfere with focus management.Keyboard Navigation
Each radio listens for arrow keys and Escape.
input.addEventListener('keydown', (e) => {if (e.key === 'Escape') {e.preventDefault();e.stopPropagation();instance.selectCell(row as number, column as number); // return focus to gridreturn;}if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {e.preventDefault();e.stopPropagation();cycle('next');return;}if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {e.preventDefault();e.stopPropagation();cycle('prev');return;}// Block remaining keys from Handsontable's shortcut system.e.stopPropagation();});The
cyclehelper finds the current index, computes next/prev with wrap-around, callssetDataAtCell, and schedules aqueueMicrotaskfocus restore — the same pattern as the click handler.Enter / F2 Bridge
In Angular, the listener goes inside the
afterInithook ingridSettings— the hook receivesthisas the Handsontable instance:readonly gridSettings: GridSettings = {// ...afterInit(this: Handsontable.Core) {this.rootElement.addEventListener('keydown', (e) => {if ((e.target as HTMLElement).tagName === 'INPUT') return;if (e.key !== 'Enter' && e.key !== 'F2') return;const sel = this.getSelectedLast();if (!sel) return;const [r, c] = sel;if (this.getCellMeta(r, c).type !== 'radio') return;const td = this.getCell(r, c);const target = td?.querySelector<HTMLInputElement>('input[type="radio"]:checked')?? td?.querySelector<HTMLInputElement>('input[type="radio"]');if (target) { e.stopImmediatePropagation(); e.preventDefault(); target.focus(); }}, true);},};Register and Use in Handsontable
Register the cell type and use
type: 'radio'in the column config. Add the Enter/F2 bridge viaafterInit:registerCellType('radio', { editor: RadioEditor, renderer: radioRenderer });readonly gridSettings: GridSettings = {rowHeights: 96,columns: [{ data: 'task', type: 'text' },{ data: 'priority', type: 'radio', options: priorityOptions },{ data: 'status', type: 'radio', options: statusOptions },],afterInit(this: Handsontable.Core) {this.rootElement.addEventListener('keydown', (e) => {// ... same Enter/F2 bridge as JavaScript}, true);},};Why
rowHeights: 96?The default row height (~28 px) is too short to display three stacked options with padding. Setting a fixed height ensures the radio group fits without overflowing.
How It Works - Complete Flow
- Render: Handsontable calls
radioRendererfor each radio cell. The renderer empties the cell and builds the<label>/<input type="radio">group fromcellProperties.options. - View: Radio buttons are always visible. The checked option reflects the current cell value.
- Click: The
changeevent fires, callssetDataAtCell, and queues a microtask to refocus the now-rebuilt radio. - Arrow keys: Each radio’s
keydownlistener callscycle(), which callssetDataAtCelland queues a focus restore. - Enter / F2: The capture-phase listener on
rootElementcatches the key before Handsontable, then focuses the checked (or first) radio. - Escape: The radio’s
keydownlistener callsinstance.selectCell()to return focus to the grid cursor.
Enhancements
Allow Per-Cell Options
Override options from
cellPropertiesat the cell level using Handsontable’scellscallback:cells(row, col) {if (col === 1 && row === 2) {return { options: [{ value: 'critical', label: 'Critical' }] };}}Read-Only State
Mark individual cells as non-interactive by setting
readOnly: truein the column or viacells():columns: [{ data: 'status', type: 'radio', options: statusOptions, readOnly: true },]The renderer checks
cellProperties.readOnlyand setsdisabledon all radio inputs, which blocks both click changes and keyboard navigation. Handsontable also dims the cell background automatically.Validator for Allowed Values
Reject values not present in the options list:
const radioValidator = (value, callback) => {callback(priorityOptions.some((o) => o.value === value));};columns: [{ data: 'priority', type: 'radio', options: priorityOptions, validator: radioValidator },]
Accessibility
Keyboard navigation:
- Tab: Move to the next tabbable radio group (one per cell, via roving tabindex).
- Enter / F2: Enter the radio group from the grid cursor.
- ArrowDown / ArrowRight: Move to the next option (wraps).
- ArrowUp / ArrowLeft: Move to the previous option (wraps).
- Escape: Return focus to the grid.
Screen reader support:
role="radiogroup"on the wrapper.aria-labelderived from the column header.- Each option is a native
<input type="radio">with a visible<label>.
What you learned
You built a renderer-based radio cell type using Handsontable’s rendererFactory and a minimal BaseEditor stub. You learned the roving tabindex pattern, the queueMicrotask focus-restore technique, and how to intercept Enter/F2 in capture phase to bridge keyboard navigation between the grid cursor and the radio group.
Next steps
- Feedback - Another custom cell type using
editorFactoryand an editor popup. - Star Rating - SVG-based custom renderer using
rendererFactory. - Custom cell type - The full reference for renderer, editor, and validator registration.