Handsontable with shadcn/ui
Overview
This recipe shows how to integrate Handsontable into a Next.js that uses shadcn/ui by registering a custom theme that uses shadcn’s CSS variables for colors, Lucide-style icons, and Horizon tokens. The grid follows your design system.
Difficulty: Beginner
Time: ~15 minutes
Stack: Next.js, shadcn/ui (Tailwind CSS), Handsontable, @handsontable/react-wrapper
What You’ll Get
- A Handsontable grid with a custom theme (
registerTheme('shadcn-data-grid', { icons, colors, tokens })) where colors map to shadcn’s--primary,--background,--foreground,--muted,--border, etc. viavar(--…). - Icons using Lucide-style SVGs (data URIs with
currentColor) so they match your theme. - Tokens from Handsontable’s Horizon set, with overrides (e.g.
wrapperBorderRadius) to match shadcn’s--radius.
Prerequisites
- A Next.js project with shadcn/ui already set up (e.g.
app/globals.csswith shadcn imports and:rootvariables). - shadcn’s CSS variables defined (e.g.
--primary,--background,--foreground,--muted,--border,--radius). - This recipe uses
lib/for theme and helpers andcomponents/for the grid, adjust paths if your structure differs.
Install Handsontable
In your project root:
Terminal window pnpm add handsontable @handsontable/react-wrapper(or
npm install/yarn add). Use@handsontable/react-wrapperforHotTable+HotColumnandHotTableRef.Register modules
Register Handsontable modules (e.g. in your grid component).
import { registerAllModules } from 'handsontable/registry';import { registerTheme } from 'handsontable/themes';registerAllModules();Define shadcn colors for the theme
Create a colors object that maps Handsontable’s expected shape to shadcn CSS variables. The grid will follow light/dark automatically because
var(--…)is resolved at render time.Option A – use the built-in shadcn colors:
You can import the official mapping from Handsontable instead of defining your own:import colorsShadcn from 'handsontable/themes/static/variables/colors/shadcn';Option B – define your own (e.g. to match your globals.css):
File:lib/theme/colorsShadcn.ts/*** Handsontable theme colors mapped to shadcn CSS variables (globals.css).* Structure must match what tokens/main expects: palette (50–950), primary (100–600), white, black, transparent.* Uses var(--…) so the grid follows your shadcn theme and dark mode.*/export const colorsShadcn = {palette: {50: "var(--color-neutral-50)",100: "var(--color-neutral-200)",200: "var(--color-neutral-100)",300: "var(--color-neutral-300)",400: "var(--color-neutral-400)",500: "var(--color-neutral-500)",600: "var(--color-neutral-600)",700: "var(--color-neutral-700)",800: "var(--color-neutral-800)",900: "var(--color-neutral-900)",950: "var(--color-neutral-950)",},primary: {100: "var(--primary)",200: "var(--primary)",300: "var(--primary)",400: "var(--color-neutral-900)",500: "var(--primary)",600: "var(--color-neutral-800)",},white: "var(--background)",black: "var(--foreground)",transparent: "transparent",}Define Lucide-style icons for the theme
Handsontable themes accept an
iconsobject. Use SVGs withcurrentColorso they follow your text/foreground color. Export as data URIs or inline SVG strings keyed by the theme’s expected icon keys.File:
lib/theme/iconsShadcn.ts/*** Handsontable theme icons using Lucide (shadcn) icon set.* SVGs use currentColor so they follow shadcn theme. Keys must match VALID_ICON_KEYS.*/const lucideAttrs ='xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';function icon(svgContent: string): string {return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`<svg ${lucideAttrs}>${svgContent}</svg>`)}`;}// Lucide: ChevronRight, ChevronLeft, ChevronDown, ChevronUp, ChevronsRight, ChevronsLeft, Check, Menu, Plus, Minus, Circle (filled for radio)export const iconsShadcn = {arrowRight: icon('<path d="m9 18 6-6-6-6"/>'),arrowRightWithBar: icon('<path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/>'),arrowLeft: icon('<path d="m15 18-6-6 6-6"/>'),arrowLeftWithBar: icon('<path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/>'),arrowDown: icon('<path d="m6 9 6 6 6-6"/>'),menu: icon('<path d="M4 5h16"/><path d="M4 12h16"/><path d="M4 19h16"/>'),selectArrow: icon('<path d="m6 9 6 6 6-6"/>'),arrowNarrowUp: icon('<path d="m18 15-6-6-6 6"/>'),arrowNarrowDown: icon('<path d="m6 9 6 6 6-6"/>'),check: icon('<g transform="translate(3,3) scale(0.75)"><path d="M20 6 9 17l-5-5"/></g>'),checkbox: icon('<g transform="translate(3,3) scale(0.75)"><path d="M20 6 9 17l-5-5"/></g>'),caretHiddenLeft: icon('<path d="m15 18-6-6 6-6"/>'),caretHiddenRight: icon('<path d="m9 18 6-6-6-6"/>'),caretHiddenUp: icon('<path d="m18 15-6-6-6 6"/>'),caretHiddenDown: icon('<path d="m6 9 6 6 6-6"/>'),collapseOff: icon('<path d="M5 12h14"/>'),collapseOn: icon('<path d="M5 12h14"/><path d="M12 5v14"/>'),radio: icon('<circle cx="12" cy="12" r="6" fill="currentColor"/>'),} as const;Use the exact icon keys required by your Handsontable theme (e.g. from the theme’s type or docs).
Register the custom theme and create the grid component
Import Horizon tokens (or another built-in token set), override tokens to match shadcn (e.g.
--radius), and register the theme. Then useHotTable+HotColumnwith that theme.Helpers (data and config): Create a module that exports sample grid data and shared HotTable options. The
dataarray should have one object per row, with keys matching your columndataprops (name,age,country,city,isActive,interest, etc.). Theconfigobject holds common props likelicenseKeyandheight.File:
lib/helpers.tsimport { HotTableProps } from "@handsontable/react-wrapper";export const config: Partial<HotTableProps> = {width: "100%",height: "auto",licenseKey: "non-commercial-and-evaluation",autoWrapRow: true,filters: true,// Add more options: nestedHeaders, contextMenu, dropdownMenu, pagination, etc.};export const data = [{ name: "Alice", age: 28, country: "USA", city: "New York", isActive: true, interest: "Tech Gadgets", favoriteProduct: "Laptop", lastLoginDate: "Jan 15, 2025", lastLoginTime: "09:30" },{ name: "Bob", age: 34, country: "UK", city: "London", isActive: false, interest: "Books & Literature", favoriteProduct: "Headphones", lastLoginDate: "Feb 01, 2025", lastLoginTime: "14:00" },// Add more rows; keys must match HotColumn data props];Then in your grid component (File:
components/DataGrid.tsx):"use client";import { forwardRef } from "react";import { HotTable, HotColumn, HotTableRef } from "@handsontable/react-wrapper";import { registerTheme } from "handsontable/themes";import { registerAllModules } from "handsontable/registry";import tokensHorizon from 'handsontable/themes/static/variables/tokens/horizon';import { colorsShadcn } from "/theme/colorsShadcn";import { iconsShadcn } from "/theme/iconsShadcn";import { data, config } from "/helpers";registerAllModules();const shadcnDataGridTheme = registerTheme('shadcn-data-grid', {icons: iconsShadcn,colors: colorsShadcn,tokens: tokensHorizon,}).params({tokens: {wrapperBorderRadius: "var(--radius)",},})const DataGrid = forwardRef<HotTableRef, unknown>(function DataGrid(_, ref) {return (<HotTableref={ref}theme={shadcnDataGridTheme}data={data}{...config}><HotColumn data="name" width={160} /><HotColumn data="age" type="numeric" width={100} /><HotColumndata="country"type="autocomplete"source={["Germany","China","France","Netherlands","Switzerland","USA","Canada","UK","Australia","Spain","Japan","Brazil","South Korea","Mexico",]}strict={true}allowInvalid={true}width={160}/><HotColumndata="city"type="dropdown"source={["Walldorf","Shenzhen","Lyon","Amsterdam","Zurich","New York","Toronto","London","Sydney","Los Angeles","Barcelona","Tokyo","Manchester","Sao Paulo","Miami","Madrid","Seoul","Vancouver","Valencia","Chicago","Mexico City","Houston",]}width={160}/><HotColumndata="isActive"type="checkbox"className="htCenter"width={120}/><HotColumndata="interest"type="dropdown"source={["Electronics","Fashion","Tech Gadgets","Home Decor","Sports & Fitness","Books & Literature","Beauty & Personal Care","Food & Cooking","Travel & Adventure","Art & Collectibles",]}width={220}/><HotColumn data="favoriteProduct" width={220} /><HotColumndata="lastLoginDate"type="date"className="htRight"correctFormat={true}dateFormat="MMM DD, YYYY"width={180}/><HotColumndata="lastLoginTime"type="time"className="htRight"correctFormat={true}timeFormat="HH:mm"width={180}/></HotTable>);});Use the grid in your page (e.g.
app/page.tsx):import DataGrid from "/DataGrid"and render<DataGrid />. The full example exports aDataGridWrapperthat wires the grid ref to URL search params for filtering (see Complete example below).
Complete example (minimal single-file shape)
"use client";import { useRef, useEffect, memo, forwardRef } from "react";import { useSearchParams } from "next/navigation";import { HotTable, HotColumn, HotTableRef } from "@handsontable/react-wrapper";import { registerTheme } from "handsontable/themes";import { registerAllModules } from "handsontable/registry";
import tokensHorizon from 'handsontable/themes/static/variables/tokens/horizon';
import { colorsShadcn } from "/theme/colorsShadcn";import { iconsShadcn } from "/theme/iconsShadcn";import { data, config } from "/helpers";
registerAllModules();
const shadcnDataGridTheme = registerTheme('shadcn-data-grid', { icons: iconsShadcn, colors: colorsShadcn, tokens: tokensHorizon,}).params({ tokens: { wrapperBorderRadius: "var(--radius)", },})
const DataGrid = forwardRef<HotTableRef, unknown>(function DataGrid(_, ref) { return (<HotTable ref={ref} theme={shadcnDataGridTheme} data={data} {...config} > <HotColumn data="name" width={160} /> <HotColumn data="age" type="numeric" width={100} /> <HotColumn data="country" type="autocomplete" source={[ "Germany", "China", "France", "Netherlands", "Switzerland", "USA", "Canada", "UK", "Australia", "Spain", "Japan", "Brazil", "South Korea", "Mexico", ]} strict={true} allowInvalid={true} width={160} /> <HotColumn data="city" type="dropdown" source={[ "Walldorf", "Shenzhen", "Lyon", "Amsterdam", "Zurich", "New York", "Toronto", "London", "Sydney", "Los Angeles", "Barcelona", "Tokyo", "Manchester", "Sao Paulo", "Miami", "Madrid", "Seoul", "Vancouver", "Valencia", "Chicago", "Mexico City", "Houston", ]} width={160} /> <HotColumn data="isActive" type="checkbox" className="htCenter" width={120} /> <HotColumn data="interest" type="dropdown" source={[ "Electronics", "Fashion", "Tech Gadgets", "Home Decor", "Sports & Fitness", "Books & Literature", "Beauty & Personal Care", "Food & Cooking", "Travel & Adventure", "Art & Collectibles", ]} width={220} /> <HotColumn data="favoriteProduct" width={220} /> <HotColumn data="lastLoginDate" type="date" className="htRight" correctFormat={true} dateFormat="MMM DD, YYYY" width={180} /> <HotColumn data="lastLoginTime" type="time" className="htRight" correctFormat={true} timeFormat="HH:mm" width={180} /> </HotTable>);});
const MemoizedDataGrid = memo(DataGrid);
function DataGridWrapper() { const hotTableRef = useRef<HotTableRef>(null); const searchParams = useSearchParams();
useEffect(() => { const hot = hotTableRef.current?.hotInstance; const params = Object.fromEntries(searchParams.entries());
if (hot) { const filtersPlugin = hot.getPlugin('filters');
if (filtersPlugin) { filtersPlugin.clearConditions();
if (params.q) filtersPlugin.addCondition(0, 'begins_with', [params.q]); // Name if (params.country) filtersPlugin.addCondition(2, 'contains', [params.country]); // Country if (params.status) filtersPlugin.addCondition(4, 'eq', [params.status === 'active']); // Active (checkbox)
filtersPlugin.filter(); hot?.render(); } } }, [searchParams]);
return ( <MemoizedDataGrid ref={hotTableRef} /> );}
export default DataGridWrapper;Congratulations! You’ve integrated Handsontable with your shadcn/ui design system using a custom theme, CSS variables, and a filterable data grid that matches your app’s look and feel!
Related
- Themes – Built-in themes and Theme API
- Theme customization – Theme API parameters and CSS variable reference
- Design system (Figma) – Figma kit and design tokens