Creating a Cell Plugin

Build a custom property type for Table View.

A custom property has two parts: a pure data plugin and a matching UI adapter. The data plugin defines storage and table behavior; the UI adapter renders and edits the property.

Create a Tag plugin

Define the plugin

import type { CellPlugin } from "@notion-kit/table-hook/plugins";
import { createCompareFn } from "@notion-kit/table-hook/plugins";
import type {
  BulkEditorProps,
  CellProps,
  TableUiPlugin,
} from "@notion-kit/table-view";

type TagData = string[];
type TagConfig = { options: string[] };
type TagPlugin = CellPlugin<"tag", TagData, TagConfig>;

const TagIcon = <span aria-hidden>🏷️</span>;

function TagCell({ cell }: CellProps) {
  const data = cell.getData<TagPlugin>();
  const config = cell.getInfo().config as TagConfig;

  return (
    <div>
      <span>{data.join(", ") || "—"}</span>
      {config.options.map((tag) => {
        const selected = data.includes(tag);

        return (
          <button
            key={tag}
            type="button"
            aria-pressed={selected}
            onClick={() =>
              cell.update<TagPlugin>(
                selected
                  ? data.filter((value) => value !== tag)
                  : [...data, tag],
              )
            }
          >
            {tag}
          </button>
        );
      })}
    </div>
  );
}

function ClearTags({ column }: BulkEditorProps) {
  return (
    <button
      type="button"
      onClick={() =>
        column.updateCells<TagPlugin>(column.getSelectedRowIds(), [])
      }
    >
      Clear selected tags
    </button>
  );
}

export const tagPlugin: TagPlugin = {
  id: "tag",
  default: {
    config: { options: [] },
    data: [],
  },
  fromValue: (value) =>
    typeof value === "string"
      ? value
          .split(",")
          .map((tag) => tag.trim())
          .filter(Boolean)
      : [],
  toValue: (data) => data.join(", "),
  isEmpty: (data) => data.length === 0,
  toTextValue: (data) => data.join(", "),
  compare: createCompareFn<TagPlugin>((a, b) =>
    a.join(", ").localeCompare(b.join(", ")),
  ),
};

export const tagPluginUi: TableUiPlugin<typeof tagPlugin> = {
  id: "tag",
  meta: {
    name: "Tag",
    desc: "A multi-select tag property",
    icon: TagIcon,
  },
  default: { name: "Tags", icon: TagIcon },
  renderCell: (props) => <TagCell {...props} />,
  renderBulkEditor: (props) => <ClearTags {...props} />,
  renderGroupingValue: () => null,
};

renderCell receives only a TanStack cell instance. Its getData(), getInfo(), getTextValue(), and update() APIs provide the value and mutation behavior required by the renderer. renderBulkEditor is optional; including it makes the property available in the bulk-edit bar. Its column instance exposes getSelectedRowIds() and updateCells() for bulk changes.

Register the plugin

Add the plugin to the list passed to TableView, then include a matching property in the table data.

import type { ColumnDefs, Row } from "@notion-kit/table-hook";
import { DEFAULT_PLUGINS, TableView } from "@notion-kit/table-view";

import { tagPlugin, tagPluginUi } from "./tag-plugin";

const plugins = {
  data: [...DEFAULT_PLUGINS.data, tagPlugin],
  ui: [...DEFAULT_PLUGINS.ui, tagPluginUi],
};

const properties: ColumnDefs<typeof plugins.data> = [
  { id: "name", name: "Name", type: "title" },
  {
    id: "tags",
    name: "Tags",
    type: "tag",
    config: { options: ["Bug", "Feature"] },
  },
];

const now = Date.now();
const data: Row<typeof plugins.data>[] = [
  {
    id: "task-1",
    createdAt: now,
    lastEditedAt: now,
    properties: {
      name: { id: "task-1-name", value: "Ship Table View" },
      tags: { id: "task-1-tags", value: ["Feature"] },
    },
  },
];

export function DatabaseView() {
  return (
    <TableView
      plugins={plugins}
      defaultProperties={properties}
      defaultData={data}
    />
  );
}

Add renderConfigMenu when users need to edit the property configuration, and add sorting, grouping, or counting methods only when the property needs those capabilities.