Skip to content

Handsontable with shadcn/ui

This tutorial shows you how to integrate Handsontable into a Next.js app that uses shadcn/ui, registering a custom theme that maps shadcn CSS variables and Lucide icons to the Handsontable Theme API.

Open in sandbox View source on GitHub

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. via var(--…).
  • Icons using Lucide-style SVGs (data URIs with currentColor) so they match your theme.
  • Tokens from Handsontable’s Horizon set, with overrides (e.g. borderRadius) to match shadcn’s --radius.

Prerequisites

  • A Next.js project with shadcn/ui already set up (e.g. app/globals.css with shadcn imports and :root variables).
  • shadcn’s CSS variables defined (e.g. --primary, --background, --foreground, --muted, --border, --radius).
  • This recipe uses lib/ for theme and helpers and components/ for the grid, adjust paths if your structure differs.
  1. Install Handsontable

    In your project root:

    Terminal window
    pnpm add handsontable @handsontable/react-wrapper

    (or npm install / yarn add). Use @handsontable/react-wrapper for HotTable + HotColumn and HotTableRef.

  2. Register modules

    Register Handsontable modules (e.g. in your grid component).

    import { registerAllModules } from 'handsontable/registry';
    import { registerTheme } from 'handsontable/themes';
    registerAllModules();
  3. Define shadcn colors for the theme

    Create a colors object that maps Handsontable’s expected shape to shadcn CSS variables. The browser resolves each var(--…) at render time, so the grid picks up changes to those variables. Dark mode isn’t automatic, though: see Dark mode below.

    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 the Horizon tokens expect: palette (50–950), primary (100–600), white, black, transparent.
    * Uses var(--…) so the grid picks up your shadcn light theme values.
    */
    export const colorsShadcn = {
    palette: {
    50: "var(--color-neutral-50)",
    100: "var(--color-neutral-100)",
    200: "var(--color-neutral-200)",
    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",
    }
  4. Define Lucide-style icons for the theme

    Handsontable themes accept an icons object. Use SVGs with currentColor so 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), X, Search, List
    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"/>'),
    chipClose: icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'),
    search: icon('<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>'),
    plus: icon('<path d="M5 12h14"/><path d="M12 5v14"/>'),
    menuList: icon('<path d="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/>'),
    } as const;

    Provide every icon key the theme expects. If one is missing, registerTheme() logs a Missing icon(s) warning in the console.

  5. 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 use HotTable + HotColumn with that theme.

    Helpers (data and config): Create a module that exports sample grid data and shared HotTable options. The data array should have one object per row, with keys matching your column data props (name, age, country, city, isActive, interest, etc.). The config object holds common props like licenseKey and height.

    File: lib/helpers.ts

    import { 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: "2025-01-15", lastLoginTime: "09:30" },
    { name: "Bob", age: 34, country: "UK", city: "London", isActive: false, interest: "Books & Literature", favoriteProduct: "Headphones", lastLoginDate: "2025-02-01", 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 "../lib/theme/colorsShadcn";
    import { iconsShadcn } from "../lib/theme/iconsShadcn";
    import { data, config } from "../lib/helpers";
    registerAllModules();
    const shadcnDataGridTheme = registerTheme('shadcn-data-grid', {
    icons: iconsShadcn,
    colors: colorsShadcn,
    tokens: tokensHorizon,
    }).params({
    tokens: {
    borderRadius: "var(--radius)",
    },
    }).setColorScheme("light");
    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"
    dateFormat={{ year: 'numeric', month: 'short', day: 'numeric' }}
    width={180}
    />
    <HotColumn
    data="lastLoginTime"
    type="time"
    className="htRight"
    timeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
    width={180}
    />
    </HotTable>);
    });

    Use the grid in your page (e.g. app/page.tsx): import DataGrid from "../components/DataGrid" and render <DataGrid />.

    Dark mode

    The theme’s color scheme decides whether Horizon tokens use their light or dark palette entries. It defaults to auto, which follows the operating system’s prefers-color-scheme setting, not shadcn’s .dark class. .setColorScheme("light") keeps the grid light to match the light shadcn values in colorsShadcn.

    The custom mapping from Step 3 supports light mode only: its white and black follow --background and --foreground, which shadcn swaps in dark mode, so Horizon’s dark token set reads them the wrong way round. To support dark mode, register the theme with the built-in colors from Option A, and call shadcnDataGridTheme.setColorScheme("dark") or setColorScheme("light") when your app toggles the .dark class. Grids that use the theme update in place.

Complete example (minimal single-file shape)

The complete example adds a DataGridWrapper that wires the grid ref to URL search params for filtering. It uses the same DataGrid component from Step 5, wrapped with memo and forwardRef.

"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 "../lib/theme/colorsShadcn";
import { iconsShadcn } from "../lib/theme/iconsShadcn";
import { data, config } from "../lib/helpers";
registerAllModules();
// Theme registration and DataGrid component (same as Step 5)
const shadcnDataGridTheme = registerTheme('shadcn-data-grid', {
icons: iconsShadcn,
colors: colorsShadcn,
tokens: tokensHorizon,
}).params({ tokens: { borderRadius: "var(--radius)" } }).setColorScheme("light");
const DataGrid = forwardRef<HotTableRef, unknown>(function DataGrid(_, ref) {
return (
<HotTable ref={ref} theme={shadcnDataGridTheme} data={data} {...config}>
{/* HotColumn definitions (see Step 5) */}
</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]);
if (params.country) filtersPlugin.addCondition(2, 'contains', [params.country]);
if (params.status) filtersPlugin.addCondition(4, 'eq', [params.status === 'active']);
filtersPlugin.filter();
hot?.render();
}
}
}, [searchParams]);
return <MemoizedDataGrid ref={hotTableRef} />;
}
export default DataGridWrapper;

What you learned

You registered a custom Handsontable theme that maps shadcn CSS variables to Handsontable colors. You used Lucide-style SVG data URIs for icons, Horizon tokens as the base token set, and .params() overrides to apply your --radius variable to the grid wrapper.

Next steps