Star Rating
This tutorial shows you how to build an interactive SVG star rating cell using editorFactory and rendererFactory, with hover preview and keyboard shortcuts - no external libraries required.
/* file: app.component.ts */import { Component, ChangeDetectorRef, ChangeDetectionStrategy, inject } from '@angular/core';import { DomSanitizer, SafeHtml } from '@angular/platform-browser';import { GridSettings, HotCellEditorAdvancedComponent, HotCellRendererAdvancedComponent, KeyboardShortcutConfig, HotTableModule} from '@handsontable/angular-wrapper';import { RowObject } from 'handsontable/common';
/* start:skip-in-preview */const starSvg = '<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
const inputData = [ { product: 'Dashboard Pro', category: 'Analytics', rating: 5, reviews: 342, price: 49 }, { product: 'Form Builder', category: 'Tools', rating: 4, reviews: 218, price: 29 }, { product: 'Chart Engine', category: 'Analytics', rating: 3, reviews: 156, price: 39 }, { product: 'Auth Module', category: 'Security', rating: 5, reviews: 89, price: 19 }, { product: 'File Manager', category: 'Storage', rating: 2, reviews: 64, price: 15 }, { product: 'Email Service', category: 'Communication', rating: 4, reviews: 275, price: 25 }, { product: 'Search Index', category: 'Tools', rating: 1, reviews: 31, price: 35 }, { product: 'Cache Layer', category: 'Infra', rating: 4, reviews: 112, price: 20 },];/* end:skip-in-preview */
@Component({ standalone: true, selector: 'example1-star-renderer', changeDetection: ChangeDetectionStrategy.OnPush, template: ` <div class="rating-cell"> @for (star of stars; track $index) { <span class="rating-star" [class.active]="$index < value" [innerHTML]="starSvgMarkup"></span> } </div>`,})export class StarRendererComponent extends HotCellRendererAdvancedComponent<number> { readonly stars = Array(5); readonly starSvgMarkup: SafeHtml = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);}
@Component({ standalone: true, selector: 'example1-star-editor', template: ` <div class="rating-editor" (mouseover)="onMouseOver($event)" (mousedown)="onMouseDown()"> @for (star of stars; track $index) { <span [attr.data-value]="$index + 1" class="rating-star" [class.active]="$index < getValue()" [class.current]="isCurrentStar($index)" [innerHTML]="starSvgMarkup" ></span> } </div> `,})export class StarEditorComponent extends HotCellEditorAdvancedComponent<number> { readonly stars = Array(5); readonly starSvgMarkup: SafeHtml = inject(DomSanitizer).bypassSecurityTrustHtml(starSvg);
isCurrentStar(index: number): boolean { return (index + 1) === parseInt(this.getValue()?.toString() ?? '0', 10); }
private readonly cdr = inject(ChangeDetectorRef);
override shortcuts?: KeyboardShortcutConfig[] = [ { keys: [['1'], ['2'], ['3'], ['4'], ['5']], callback: (_editor, event) => { this.setValue(parseInt((event as KeyboardEvent).key, 10)); this.cdr.detectChanges(); }, }, { keys: [['ArrowRight']], callback: (_editor, _event) => { if (parseInt(this.getValue()?.toString() ?? '0') < 5) { this.setValue(parseInt(this.getValue()?.toString() ?? '0') + 1); this.cdr.detectChanges(); } }, }, { keys: [['ArrowLeft']], callback: (_editor, _event) => { if (parseInt(this.getValue()?.toString() ?? '0') > 1) { this.setValue(parseInt(this.getValue()?.toString() ?? '0') - 1); this.cdr.detectChanges(); } }, }, ];
onMouseOver(event: MouseEvent): void { const star = (event.target as HTMLElement).closest('.rating-star') as HTMLElement | null;
if ( star?.dataset['value'] && parseInt(this.getValue()?.toString() ?? '0') !== parseInt(star.dataset['value'], 10) ) { this.setValue(parseInt(star.dataset['value'], 10)); this.cdr.detectChanges(); } }
onMouseDown(): void { this.finishEdit.emit(); }}
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-rating', template: `<div><hot-table [data]="data" [settings]="gridSettings"></hot-table></div>`,})export class AppComponent { readonly data = inputData;
readonly gridSettings: GridSettings = { autoRowSize: true, rowHeaders: true, autoWrapRow: true, height: 'auto', width: '100%', headerClassName: 'htLeft', colHeaders: ['Product', 'Category', 'Rating', 'Reviews', 'Price'], columns: [ { data: 'product', type: 'text', width: 240 }, { data: 'category', type: 'text', width: 120 }, { data: 'rating', width: 150, renderer: StarRendererComponent, editor: StarEditorComponent, validator: (value: unknown, callback: (valid: boolean) => void) => { const num = parseInt(String(value), 10);
callback(num >= 1 && num <= 5); }, }, { data: 'reviews', type: 'numeric', width: 80 }, { data: 'price', type: 'numeric', width: 80 }, ], };}/* 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-rating></example1-rating></div>.rating-cell { display: flex; align-items: center; margin: 3px 0 0 -1px;}
.rating-star { color: var(--ht-background-secondary-color, #e0e0e0); cursor: default; display: inline-flex; align-items: center;}
.rating-star.active { color: #facc15;}
.rating-editor { display: flex; align-items: center; width: 100%; height: 100%; box-sizing: border-box !important; border: none; border-radius: 0; 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); background-color: var(--ht-cell-editor-background-color, #ffffff); padding: var(--ht-cell-vertical-padding, 4px) var(--ht-cell-horizontal-padding, 8px); font-family: inherit; font-size: inherit; line-height: inherit; cursor: pointer;}
.rating-editor .rating-star { cursor: pointer;}
.rating-editor .rating-star.current { color: var(--ht-accent-color, #1a42e8);}Overview
This guide shows how to create an interactive star rating cell using inline SVG stars. Perfect for product ratings, review scores, or any scenario where users need to provide a 1-5 star rating.
Difficulty: Beginner Time: ~15 minutes Libraries: None (pure HTML, SVG and JavaScript)
What You’ll Build
A cell that:
- Displays 5 SVG stars both when editing and viewing
- Shows filled stars (gold) and unfilled stars (gray)
- Uses Handsontable CSS tokens for theme-aware editor styling
- Supports mouse hover for preview
- Allows keyboard input (1-5 keys, arrow keys)
- Provides immediate visual feedback
- Works without any external libraries
Prerequisites
None! This uses only native HTML, SVG and JavaScript features.
Import Dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';registerAllModules();What we’re NOT importing:
- No icon libraries
- No UI component libraries
- No external SVG sprite sheets
- Handsontable only.
Define the Star SVG
Create an inline SVG string for the star shape. Using
fill="currentColor"allows CSS to control the star color.const starSvg ='<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';What’s happening:
width="1em" height="1em"- Stars scale with the font sizeviewBox="0 0 24 24"- Standard 24x24 coordinate spacefill="currentColor"- Inherits the CSScolorproperty, so active/inactive states are controlled via CSS- The
<path>draws a classic 5-pointed star shape
Why SVG instead of emoji?
- Consistent rendering across all browsers and operating systems
- Full control over color, size, and styling via CSS
- No platform-dependent emoji variations
- Crisp at any resolution
Add CSS Styling
Create a separate CSS file for the rating styles. This uses Handsontable CSS custom properties (tokens) so the editor automatically adapts to custom themes and dark mode.
.rating-cell {display: flex;align-items: center;margin: 3px 0 0 -1px;}.rating-star {color: var(--ht-background-secondary-color, #e0e0e0);cursor: default;display: inline-flex;align-items: center;}.rating-star.active {color: #facc15;}.rating-editor {display: flex;align-items: center;width: 100%;height: 100%;box-sizing: border-box !important;border: none;border-radius: 0;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);background-color: var(--ht-cell-editor-background-color, #ffffff);padding: var(--ht-cell-vertical-padding, 4px)var(--ht-cell-horizontal-padding, 8px);font-family: inherit;font-size: inherit;line-height: inherit;cursor: pointer;}.rating-editor .rating-star {cursor: pointer;}.rating-editor .rating-star.current {color: var(--ht-accent-color, #1a42e8);}Cell container:
.rating-cellwraps the stars in the renderer withdisplay: flexto match the editor layoutmargin: 3px 0 0 -1px- Fine-tunes alignment between renderer and editor to prevent visual jump
Star colors:
var(--ht-background-secondary-color, #e0e0e0)- Inactive/unfilled stars (adapts to theme)#facc15(gold) - Active/filled stars- Colors are applied via the CSS
colorproperty, which the SVG inherits throughfill="currentColor"
Current star indicator:
.rating-editor .rating-star.currenthighlights the last active star (the one matching the rating value) using--ht-accent-color- Makes it clear which star is selected while editing — the current star turns blue (accent color) instead of gold
Handsontable tokens used:
--ht-background-secondary-color- Inactive star color (adapts to theme)--ht-accent-color- Current star highlight in editor (blue)--ht-cell-editor-border-color/--ht-cell-editor-border-width- blue border matching native editors--ht-cell-editor-background-color- editor background--ht-cell-editor-shadow-blur-radius/--ht-cell-editor-shadow-color- editor shadow--ht-cell-vertical-padding/--ht-cell-horizontal-padding- consistent cell padding matching the renderer
Create the Renderer
The renderer displays 5 SVG stars wrapped in a flex container using CSS classes for color control.
renderer: rendererFactory(({ td, value }) => {td.innerHTML = `<div class="rating-cell">${Array.from({ length: 5 },(_, index) =>`<span class="rating-star ${index < value ? 'active' : ''}">${starSvg}</span>`).join('')}</div>`;})What’s happening:
- Stars are wrapped in a
<div class="rating-cell">flex container to match the editor’s flex layout Array.from({ length: 5 })- Creates an array with 5 elements (indices 0-4)index < value- Stars up to the rating value get theactiveclass (gold color)index >= value- Stars beyond the rating stay gray via CSS- Each span contains the inline SVG star
join('')- Concatenates all star spans into a single string
- Stars are wrapped in a
Create the Validator
Ensure values are within the valid range.
validator: (value, callback) => {value = parseInt(value);callback(value >= 0 && value <= 100);}What’s happening:
- Convert value to integer (keyboard input returns strings)
- Validate the value is within the acceptable range
- Call
callback(true)for valid,callback(false)for invalid
Editor - Initialize (
init)Create the container div for the star rating editor.
init(editor) {editor.input = editor.hot.rootDocument.createElement('DIV') as HTMLDivElement;editor.input.classList.add('rating-editor');}What’s happening:
- Create a
divcontainer for the star buttons - Add the
rating-editorCSS class (all styling is in the CSS file) - This container will hold the 5 SVG star elements
Key styling (from CSS):
display: flex; align-items: center- Stars aligned verticallybox-shadowwith--ht-cell-editor-border-color- Blue border matching native editorspaddingwith cell padding tokens - Matches renderer to prevent visual jumpfont-family/font-size/line-height: inherit- Consistent sizing with the cell
- Create a
Editor - After Init Hook (
afterInit)Set up mouse events for hover preview and click selection.
afterInit(editor) {editor.input.addEventListener('mouseover', (event) => {const star = (event.target as HTMLElement).closest('.rating-star') as HTMLElement | null;if (star?.dataset.value &&parseInt(editor.value) !== parseInt(star.dataset.value)) {editor.setValue(star.dataset.value);}});editor.input.addEventListener('mousedown', () => {editor.finishEditing();});}What’s happening:
Mouseover Event:
- User hovers over a star (or its SVG child element)
- Use
closest('.rating-star')to find the parent span — this is important because the hover target may be the SVG<path>element inside the span - Get the hover rating from the span’s
dataset.value - If different from current value, update it
- This creates a “preview” effect as user hovers
Mousedown Event:
- User clicks (mousedown) anywhere in the editor
- Finish editing immediately
- Value is saved to the cell
Why
closest()instead of checkingevent.targetdirectly?- With inline SVGs, the actual hover/click target is often the
<svg>or<path>element, not the parent<span> closest('.rating-star')walks up the DOM tree to find the span with thedata-valueattribute- This ensures reliable star detection regardless of which SVG child element the user interacts with
Editor - Render Function (
render)Generate the HTML for the 5 star buttons based on current rating.
render(editor) {editor.input.innerHTML = Array.from({ length: 5 },(_, index) =>`<span data-value="${index + 1}" class="rating-star ${index < editor.value ? 'active' : ''}${index + 1 === parseInt(editor.value) ? ' current' : ''}">${starSvg}</span>`).join('');}What’s happening:
- Create 5 star spans (indices 0-4, values 1-5)
- Each span has
data-valueattribute with rating (1-5) - Stars use the
rating-starclass for base styling (gray color) - Active stars get the
activeclass (gold color) - The star matching the current value gets the
currentclass (accent color) — this highlights the selected rating in the editor - Each span contains the inline SVG star
- Join all spans into a single HTML string
Dynamic rendering:
- Updates whenever
editor.setValue()is called - Automatically called by
editorFactorywhen value changes - Provides live preview as user interacts
Editor - Keyboard Shortcuts
Add keyboard support for rating selection.
shortcuts: [{keys: [['1'], ['2'], ['3'], ['4'], ['5']],callback: (editor, _event) => {editor.setValue((_event as KeyboardEvent).key);}},{keys: [['ArrowRight']],callback: (editor, _event) => {if (parseInt(editor.value) < 5) {editor.setValue(parseInt(editor.value) + 1);}}},{keys: [['ArrowLeft']],callback: (editor, _event) => {if (parseInt(editor.value) > 1) {editor.setValue(parseInt(editor.value) - 1);}}}]What’s happening:
Number Keys (1-5):
- Press 1-5 to set rating directly
- Fastest way to select a specific rating
- Gets key value from keyboard event
Arrow Keys:
- ArrowRight: Increase rating (max 5)
- ArrowLeft: Decrease rating (min 1)
- Bounded within valid range
- Smooth incremental adjustment
Keyboard navigation benefits:
- Fast selection without mouse
- Accessible for keyboard-only users
- Number keys for direct selection, arrows for adjustment
Complete Cell Definition
const starSvg ='<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';const cellDefinition = {renderer: rendererFactory(({ td, value }) => {td.innerHTML = `<div class="rating-cell">${Array.from({ length: 5 },(_, index) =>`<span class="rating-star ${index < value ? 'active' : ''}">${starSvg}</span>`).join('')}</div>`;}),validator: (value, callback) => {value = parseInt(value);callback(value >= 0 && value <= 100);},editor: editorFactory<{ input: HTMLDivElement }>({shortcuts: [{keys: [['1'], ['2'], ['3'], ['4'], ['5']],callback: (editor, _event) => {editor.setValue((_event as KeyboardEvent).key);},},{keys: [['ArrowRight']],callback: (editor, _event) => {if (parseInt(editor.value) < 5) {editor.setValue(parseInt(editor.value) + 1);}},},{keys: [['ArrowLeft']],callback: (editor, _event) => {if (parseInt(editor.value) > 1) {editor.setValue(parseInt(editor.value) - 1);}},},],init(editor) {editor.input = editor.hot.rootDocument.createElement('DIV') as HTMLDivElement;editor.input.classList.add('rating-editor');},afterInit(editor) {editor.input.addEventListener('mouseover', (event) => {const star = (event.target as HTMLElement).closest('.rating-star') as HTMLElement | null;if (star?.dataset.value &&parseInt(editor.value) !== parseInt(star.dataset.value)) {editor.setValue(star.dataset.value);}});editor.input.addEventListener('mousedown', () => {editor.finishEditing();});},render(editor) {editor.input.innerHTML = Array.from({ length: 5 },(_, index) =>`<span data-value="${index + 1}" class="rating-star ${index < editor.value ? 'active' : ''}${index + 1 === parseInt(editor.value) ? ' current' : ''}">${starSvg}</span>`).join('');},}),};What’s happening:
- starSvg: Inline SVG star with
fill="currentColor"for CSS color control - renderer: Displays 5 SVG stars wrapped in a
.rating-cellflex container with CSS class-based coloring (gold/gray) - validator: Ensures rating is within valid range
- editor: Uses
editorFactoryhelper with:- Keyboard shortcuts for 1-5 keys and arrow keys
- Container initialization with
rating-editorCSS class - Mouse events using
closest()for reliable SVG hover detection - Render function with
currentclass to highlight the selected star using accent color
- starSvg: Inline SVG star with
Use in Handsontable
const container = document.querySelector('#example1')!;const hotOptions: Handsontable.GridSettings = {data,colHeaders: ['Product', 'Category', 'Rating', 'Reviews', 'Price'],autoRowSize: true,rowHeaders: true,height: 'auto',width: '100%',autoWrapRow: true,headerClassName: 'htLeft',columns: [{ data: 'product', type: 'text', width: 240 },{ data: 'category', type: 'text', width: 120 },{ data: 'rating', width: 150, ...cellDefinition },{ data: 'reviews', type: 'numeric', width: 80 },{ data: 'price', type: 'numeric', width: 80 },],licenseKey: 'non-commercial-and-evaluation',};const hot = new Handsontable(container, hotOptions);Key configuration:
...cellDefinition- Spreads the renderer, validator, and editor onto the Rating columnheaderClassName: 'htLeft'- Left-aligns all column headerswidth: '100%'- Table fills the container width
How It Works - Complete Flow
- Initial Render: Cell displays 5 SVG stars — gold for filled, gray for unfilled
- User Double-Clicks or Enter: Editor opens over cell showing interactive stars with Handsontable blue border
- Current Star Indicator: The last active star turns blue (accent color) to clearly show the selected rating
- Mouse Hover: User hovers over stars → preview rating updates in real-time (detected via
closest()) - Click Selection: User clicks → rating selected and editor closes
- Keyboard Input: User presses 1-5 keys → rating set directly
- Arrow Navigation: User presses ArrowLeft/ArrowRight → rating increments/decrements
- Validation: Validator checks the value is valid
- Save: Valid value saved to cell
- Editor Closes: Cell shows updated star rating
Enhancements
Show Numeric Value
Display the numeric rating alongside stars:
renderer: rendererFactory(({ td, value }) => {const stars = Array.from({ length: 5 }, (_, index) =>`<span class="rating-star ${index < value ? 'active' : ''}">${starSvg}</span>`).join('');td.innerHTML = `<div style="display: flex; align-items: center; gap: 8px;"><span>${stars}</span><span style="font-weight: bold; color: #666;">${value}/5</span></div>`;})Custom Star Count
Configurable number of stars per column:
renderer: rendererFactory(({ td, value, cellProperties }) => {const maxStars = cellProperties.maxStars || 5;td.innerHTML = Array.from({ length: maxStars }, (_, index) =>`<span class="rating-star ${index < value ? 'active' : ''}">${starSvg}</span>`).join('');})// Usagecolumns: [{data: 'rating',...cellDefinition,maxStars: 10 // 10-star rating}]Text Labels
Add text labels like “Excellent”, “Good”, etc.:
renderer: rendererFactory(({ td, value }) => {const labels = ['', 'Poor', 'Fair', 'Good', 'Very Good', 'Excellent'];const label = labels[value] || '';const stars = Array.from({ length: 5 }, (_, index) =>`<span class="rating-star ${index < value ? 'active' : ''}">${starSvg}</span>`).join('');td.innerHTML = `<div style="display: flex; align-items: center; gap: 8px;"><span>${stars}</span><span style="font-size: 0.9em; color: #666;">${label}</span></div>`;})Custom Star Colors
Change colors by overriding CSS for specific columns:
/* Red/green rating */.custom-rating .rating-star {color: #e5e7eb;}.custom-rating .rating-star.active {color: #22c55e;}
Accessibility
Keyboard navigation:
- Number keys (1-5): Direct rating selection
- ArrowRight: Increase rating (max 5)
- ArrowLeft: Decrease rating (min 1)
- Enter: Confirm selection and finish editing
- Escape: Cancel editing
What you learned
You built an SVG star rating cell using editorFactory and rendererFactory. You used Handsontable CSS tokens for theme-aware styling, closest() for reliable hover detection on inline SVG elements, and keyboard shortcuts for direct number-key and arrow-key selection.
Next steps
- Star Rating (React) - The same concept using React’s
EditorComponentandreact-star-rating-component. - Star Rating Editor (Angular) - The Angular version using
HotCellEditorAdvancedComponent. - Feedback - Another no-library custom editor using
editorFactoryand CSS tokens.