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.