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.

JavaScript
import { useRef } from 'react';
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
import { rendererFactory } from 'handsontable/renderers';
import { BaseEditor } from 'handsontable/editors/baseEditor';
import { registerCellType } from 'handsontable/cellTypes';
import './example1.css';
registerAllModules();
class RadioEditor extends BaseEditor {
init() {}
open() {}
close() {}
focus() {}
getValue() { return this.originalValue; }
setValue(value) { this.originalValue = value; }
}
const radioRenderer = rendererFactory(({ instance, td, row, column, value, cellProperties }) => {
while (td.firstChild) td.removeChild(td.firstChild);
const options = cellProperties.options || [];
const wrapper = document.createElement('div');
wrapper.className = 'htRadioCell';
wrapper.setAttribute('role', 'radiogroup');
const colHeader = instance.getColHeader(column);
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) => {
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].value : options[next];
instance.setDataAtCell(row, column, newValue);
queueMicrotask(() => {
const newTd = instance.getCell(row, column);
newTd?.querySelector(`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.value;
instance.setDataAtCell(row, column, v);
queueMicrotask(() => {
const newTd = instance.getCell(row, column);
newTd?.querySelector(`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, column);
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';
});
registerCellType('radio', { editor: RadioEditor, renderer: radioRenderer });
/* start:skip-in-preview */
const data = [
{ 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 */
const priorityOptions = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
];
const statusOptions = [
{ value: 'todo', label: 'Todo' },
{ value: 'in-progress', label: 'In progress' },
{ value: 'done', label: 'Done' },
];
const ExampleComponent = () => {
const hotRef = useRef(null);
const afterInit = function() {
this.rootElement.addEventListener('keydown', (e) => {
if (e.target.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('input[type="radio"]:checked')
?? td?.querySelector('input[type="radio"]');
if (target) {
e.stopImmediatePropagation();
e.preventDefault();
target.focus();
}
}, true);
};
return (
<HotTable
ref={hotRef}
data={data}
colHeaders={['Task', 'Priority', 'Status']}
rowHeaders={true}
height="auto"
width="100%"
rowHeights={96}
afterInit={afterInit}
licenseKey="non-commercial-and-evaluation"
>
<HotColumn data="task" type="text" width={300} />
<HotColumn data="priority" type="radio" width={160} options={priorityOptions} />
<HotColumn data="status" type="radio" width={170} options={statusOptions} />
</HotTable>
);
};
export default ExampleComponent;
TypeScript
import { useRef } from 'react';
import { HotTable, HotColumn } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
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';
import './example1.css';
registerAllModules();
type RadioOption = { value: string; label: string } | string;
interface RadioCellProperties extends CellProperties {
options: RadioOption[];
}
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; }
}
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';
});
registerCellType('radio', { editor: RadioEditor, renderer: radioRenderer });
/* start:skip-in-preview */
interface TaskRow {
task: string;
priority: string;
status: string;
}
const data: TaskRow[] = [
{ 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 */
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' },
];
const ExampleComponent = () => {
const hotRef = useRef<{ hotInstance: Handsontable.Core | null }>(null);
const afterInit = function(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);
};
return (
<HotTable
ref={hotRef}
data={data}
colHeaders={['Task', 'Priority', 'Status']}
rowHeaders={true}
height="auto"
width="100%"
rowHeights={96}
afterInit={afterInit}
licenseKey="non-commercial-and-evaluation"
>
<HotColumn data="task" type="text" width={300} />
<HotColumn data="priority" type="radio" width={160} {...{ options: priorityOptions } as object} />
<HotColumn data="status" type="radio" width={170} {...{ options: statusOptions } as object} />
</HotTable>
);
};
export default ExampleComponent;
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. All interaction (click and keyboard) is handled by the renderer directly. The editor is a minimal stub required by the cell-type system.

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

    import { useRef } from 'react';
    import { HotTable, HotColumn } from '@handsontable/react-wrapper';
    import { registerAllModules } from 'handsontable/registry';
    import { rendererFactory } from 'handsontable/renderers';
    import { BaseEditor } from 'handsontable/editors/baseEditor';
    import { registerCellType } from 'handsontable/cellTypes';
    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

    Every cell type registered with registerCellType must include an editor. Because the renderer handles all interaction, the editor does nothing — its only job is satisfying the cell-type API.

    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

    By default, Enter and F2 open the text editor on a cell. For radio cells, they should instead move focus into the radio group so the user can navigate with arrow keys.

    Add a capture-phase listener on hot.rootElement after initialization:

    hot.rootElement.addEventListener('keydown', (e) => {
    if ((e.target as HTMLElement).tagName === 'INPUT') return; // already inside a radio
    if (e.key !== 'Enter' && e.key !== 'F2') return;
    const sel = hot.getSelectedLast();
    if (!sel) return;
    const [r, c] = sel;
    if (hot.getCellMeta(r, c).type !== 'radio') return;
    const td = hot.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);

    Why capture phase?

    The true third argument makes this listener fire in the capture phase, before Handsontable’s own keydown handlers run. stopImmediatePropagation() prevents Handsontable from also opening the text editor.

  9. Register and Use in Handsontable

    registerCellType('radio', { editor: RadioEditor, renderer: radioRenderer });
    <HotTable data={data} rowHeights={96} licenseKey="non-commercial-and-evaluation" ...>
    <HotColumn data="task" type="text" />
    <HotColumn data="priority" type="radio" options={priorityOptions} />
    <HotColumn data="status" type="radio" options={statusOptions} />
    </HotTable>

    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.