Skip to content

Custom Properties

The library ships with six built-in property types: string, number, date, checkbox, single-select, and multi-select. You can create your own by implementing the PropertyDefinition interface.

interface PropertyDefinition<ParsedValue = unknown> {
type: string; // matches the property's `type` field in the schema
displayName: string; // shown in UI dropdowns (e.g. "Text", "Number")
parse: (raw: string, property: Property) => ParsedValue;
serialize: (value: ParsedValue, property: Property) => SerializedValue;
compare: (a: ParsedValue, b: ParsedValue) => number;
operators: OperatorDefinition[];
defaultFilterOperator?: string;
defaultFilterValue?: string;
renderIcon: RenderFunction;
renderValue: RenderFunction; // read-only cell display
renderEditor: RenderFunction; // editable cell input
renderFilterValue: (params: FilterValueDisplayParams) => unknown;
renderFilterEditor: (params: FilterValueEditorParams) => unknown;
}

Every property definition needs all of these. The parse and serialize methods convert between the string-based storage format and your parsed type. The compare method is used for sorting. The render* methods provide the React components for display and editing.

Let’s build a property type that stores times as HH:mm strings and provides time-specific filtering.

import type { OperatorDefinition } from "@blocknote/block-view/core";
const timeOperators: OperatorDefinition[] = [
{
key: "is",
label: "Is",
match: (cellValue, filterValue) => cellValue.value === filterValue,
},
{
key: "is_before",
label: "Is before",
match: (cellValue, filterValue) => cellValue.value < filterValue,
},
{
key: "is_after",
label: "Is after",
match: (cellValue, filterValue) => cellValue.value > filterValue,
},
{
key: "is_empty",
label: "Is empty",
isUnary: true,
match: (cellValue) => !cellValue.value,
},
{
key: "is_not_empty",
label: "Is not empty",
isUnary: true,
match: (cellValue) => !!cellValue.value,
},
];

Operators define filter logic. Each has a key (stored in filter rules), a label (shown in the UI), and a match function that evaluates whether a cell value matches the filter. Unary operators (like “is empty”) don’t need a filter value input.

import type { CellRendererParams } from "@blocknote/block-view/table";
function TimeCellRenderer({ value, editable, onChange }: CellRendererParams) {
const timeValue = value?.value ?? "";
if (!editable) {
return <span>{timeValue || ""}</span>;
}
return (
<input type="time" value={timeValue} onChange={(e) => onChange({ value: e.target.value })} />
);
}

The renderer receives value (a PropertyValue or undefined), editable (boolean), and onChange (to persist changes). Call onChange with { value: "serialized string" } to save.

import type { PropertyDefinition } from "@blocknote/block-view/core";
import { Clock } from "lucide-react";
const timeDefinition: PropertyDefinition<string> = {
type: "time",
displayName: "Time",
parse: (raw) => raw ?? "",
serialize: (value) => ({ value }),
compare: (a, b) => a.localeCompare(b),
operators: timeOperators,
defaultFilterOperator: "is",
defaultFilterValue: "",
renderIcon: () => <Clock size={14} />,
renderValue: TimeCellRenderer,
renderEditor: TimeCellRenderer,
renderFilterValue: ({ value }) => <span>{value || ""}</span>,
renderFilterEditor: ({ value, onChange }) => (
<input type="time" value={value} onChange={(e) => onChange(e.target.value)} />
),
};

Pass your custom definitions alongside the defaults:

import { defaultPropertyDefinitions } from "@blocknote/block-view/react";
const allDefinitions = [...defaultPropertyDefinitions, timeDefinition];
const { table } = useCollectionTableView({
collectionManager: manager,
viewId,
propertyDefinitions: allDefinitions,
});

In your collection data, define a property with type: "time":

schema: {
meetingTime: {
id: "meetingTime",
collectionId: "my-collection",
label: "Meeting Time",
type: "time",
// ... other required fields
},
}

A more complex example — a property type that stores user emails but renders rich user cards.

import type { PropertyDefinition, OperatorDefinition } from "@blocknote/block-view/core";
import { stringOperators } from "@blocknote/block-view/core";
import type { CellRendererParams } from "@blocknote/block-view/table";
import { User } from "lucide-react";
// Mock user lookup
const USERS = [
{ email: "alice@example.com", name: "Alice", avatar: "A" },
{ email: "bob@example.com", name: "Bob", avatar: "B" },
];
function UserCellRenderer({ value, editable, onChange }: CellRendererParams) {
const email = value?.value ?? "";
const user = USERS.find((u) => u.email === email);
if (!editable) {
return user ? (
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<span
style={{
width: 20,
height: 20,
borderRadius: "50%",
background: "#6366f1",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
}}
>
{user.avatar}
</span>
{user.name}
</span>
) : (
<span style={{ color: "#999" }}>{email || ""}</span>
);
}
return (
<select value={email} onChange={(e) => onChange({ value: e.target.value })}>
<option value="">Select user...</option>
{USERS.map((u) => (
<option key={u.email} value={u.email}>
{u.name}
</option>
))}
</select>
);
}
const userDefinition: PropertyDefinition<string> = {
type: "user",
displayName: "User",
parse: (raw) => raw ?? "",
serialize: (value) => ({ value }),
compare: (a, b) => a.localeCompare(b),
operators: stringOperators,
defaultFilterOperator: "is",
defaultFilterValue: "",
renderIcon: () => <User size={14} />,
renderValue: UserCellRenderer,
renderEditor: UserCellRenderer,
renderFilterValue: ({ value }) => {
const user = USERS.find((u) => u.email === value);
return <span>{user?.name ?? value}</span>;
},
renderFilterEditor: ({ value, onChange }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Select user...</option>
{USERS.map((u) => (
<option key={u.email} value={u.email}>
{u.name}
</option>
))}
</select>
),
};

The library exports pre-composed operator arrays you can reuse or extend:

Operator setOperators
stringOperatorscontains, does_not_contain, is, is_not, starts_with, ends_with, is_empty, is_not_empty
numberOperatorsequals, does_not_equal, greater_than, gte, less_than, lte, is_empty, is_not_empty
dateOperatorsis, is_not, is_before, is_after, is_on_or_before, is_on_or_after, is_empty, is_not_empty
checkboxOperatorsis, is_not
singleSelectOperatorsis, is_not, is_empty, is_not_empty
multiSelectOperatorsis, is_not, contains, does_not_contain, is_empty, is_not_empty

Import them from @blocknote/block-view/core:

import { stringOperators, numberOperators } from "@blocknote/block-view/core";

If you’re building a server-side or non-React context, use the core definitions that exclude render methods:

import {
stringDefinitionCore,
numberDefinitionCore,
dateDefinitionCore,
checkboxDefinitionCore,
singleSelectDefinitionCore,
multiSelectDefinitionCore,
} from "@blocknote/block-view/core";

These provide type, parse, serialize, compare, and operators without any rendering logic.