Angular Data GridHandsontable with shadcn/ui

Overview

This recipe shows how to integrate Handsontable into a Next.js that uses shadcn/ui (opens new window) 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

Complete Example

Open in CodeSandbox (opens new window)

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. wrapperBorderRadius) 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.

Step 1: Install Handsontable

In your project root:

pnpm add handsontable @handsontable/react-wrapper

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

Step 2: Register modules

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

import { registerAllModules } from 'handsontable/registry';
import { registerTheme } from 'handsontable/themes';

registerAllModules();

Step 3: 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",
}

Step 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)
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).

Step 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: "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 "@/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: {
    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>);
});

Use the grid in your page (e.g. app/page.tsx): import DataGrid from "@/components/DataGrid" and render <DataGrid />. The full example exports a DataGridWrapper that 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 "@/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: {
    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!