Skip to content

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.

TypeScript
/* 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-any
registerCellType('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 */
HTML
<div>
<example1-radio></example1-radio>
</div>
CSS
/* 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 options array ({ 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.

  1. 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 here
    import { registerAllModules } from 'handsontable/registry';
    registerAllModules();
  2. Add CSS Styling

    Create a separate CSS file. All styles are scoped to .htRadioCell so they do not affect the same .htUIRadio classes 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.

  3. Editor Stub

    Angular uses the same BaseEditor stub 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; }
    }
  4. 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 header
    const colHeader = instance.getColHeader(column as number);
    if (colHeader) wrapper.setAttribute('aria-label', String(colHeader));
    // ... build options, attach listeners, append to td
    td.appendChild(wrapper);
    td.style.verticalAlign = 'top';
    });

    What’s happening:

    • rendererFactory passes the full renderer context: instance, td, row, column, value, cellProperties.
    • cellProperties.options carries the per-column option list.
    • role="radiogroup" + aria-label make the group announce correctly to screen readers.
    • td.style.verticalAlign = 'top' aligns the stacked options to the top of the cell.
  5. 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.

  6. 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?

    setDataAtCell triggers a synchronous re-render that replaces the DOM. The focused element disappears. queueMicrotask schedules 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 mousedown on the table to update cell selection. Without stopPropagation, clicking a radio also repositions the Handsontable cursor, which can interfere with focus management.

  7. 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 grid
    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;
    }
    // Block remaining keys from Handsontable's shortcut system.
    e.stopPropagation();
    });

    The cycle helper finds the current index, computes next/prev with wrap-around, calls setDataAtCell, and schedules a queueMicrotask focus restore — the same pattern as the click handler.

  8. Enter / F2 Bridge

    In Angular, the listener goes inside the afterInit hook in gridSettings — the hook receives this as 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);
    },
    };
  9. Register and Use in Handsontable

    Register the cell type and use type: 'radio' in the column config. Add the Enter/F2 bridge via afterInit:

    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

  1. Render: Handsontable calls radioRenderer for each radio cell. The renderer empties the cell and builds the <label> / <input type="radio"> group from cellProperties.options.
  2. View: Radio buttons are always visible. The checked option reflects the current cell value.
  3. Click: The change event fires, calls setDataAtCell, and queues a microtask to refocus the now-rebuilt radio.
  4. Arrow keys: Each radio’s keydown listener calls cycle(), which calls setDataAtCell and queues a focus restore.
  5. Enter / F2: The capture-phase listener on rootElement catches the key before Handsontable, then focuses the checked (or first) radio.
  6. Escape: The radio’s keydown listener calls instance.selectCell() to return focus to the grid cursor.

Enhancements

  1. Allow Per-Cell Options

    Override options from cellProperties at the cell level using Handsontable’s cells callback:

    cells(row, col) {
    if (col === 1 && row === 2) {
    return { options: [{ value: 'critical', label: 'Critical' }] };
    }
    }
  2. Read-Only State

    Mark individual cells as non-interactive by setting readOnly: true in the column or via cells():

    columns: [
    { data: 'status', type: 'radio', options: statusOptions, readOnly: true },
    ]

    The renderer checks cellProperties.readOnly and sets disabled on all radio inputs, which blocks both click changes and keyboard navigation. Handsontable also dims the cell background automatically.

  3. 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-label derived 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 editorFactory and an editor popup.
  • Star Rating - SVG-based custom renderer using rendererFactory.
  • Custom cell type - The full reference for renderer, editor, and validator registration.