Star Rating
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { editorFactory } from 'handsontable/editors';import { rendererFactory } from 'handsontable/renderers';
// Register all Handsontable's modules.registerAllModules();
/* start:skip-in-preview */const data = [ { 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 */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector('#example1');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({ shortcuts: [ { keys: [['1'], ['2'], ['3'], ['4'], ['5']], callback: (editor, _event) => { editor.setValue(_event.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'); editor.input.classList.add('rating-editor'); }, afterInit(editor) { editor.input.addEventListener('mouseover', (event) => { const star = event.target.closest('.rating-star');
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(''); }, }),};
// Define configuration options for the Handsontableconst hotOptions = { 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',};
// 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 { editorFactory } from "handsontable/editors";import { rendererFactory } from "handsontable/renderers";// Register all Handsontable's modules.registerAllModules();
/* start:skip-in-preview */const data = [ { 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 */// Get the DOM element with the ID 'example1' where the Handsontable will be renderedconst container = document.querySelector("#example1")!;
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: Pick< CellProperties, "renderer" | "validator" | "editor"> = { 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(""); }, }),};
// Define configuration options for the Handsontableconst 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",};
// Initialize the Handsontable instance with the specified configuration options// eslint-disable-next-line no-unused-varsconst hot = new Handsontable(container, hotOptions);.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
- Just Handsontable.
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/Right → 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
- Arrow Right: Increase rating (max 5)
- Arrow Left: Decrease rating (min 1)
- Enter: Confirm selection and finish editing
- Escape: Cancel editing
Congratulations! You’ve created a theme-aware SVG star rating editor with hover preview and keyboard support using Handsontable CSS tokens, perfect for intuitive 1-5 star ratings in your data grid!