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.
The PropertyDefinition Interface
Section titled “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.
Example: A “Time” Property
Section titled “Example: A “Time” Property”Let’s build a property type that stores times as HH:mm strings and provides time-specific filtering.
Step 1: Define Operators
Section titled “Step 1: Define Operators”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.
Step 2: Build the Cell Renderer
Section titled “Step 2: Build the Cell Renderer”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.
Step 3: Compose the PropertyDefinition
Section titled “Step 3: Compose the PropertyDefinition”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)} /> ),};Step 4: Register It
Section titled “Step 4: Register It”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,});Step 5: Use It in Your Schema
Section titled “Step 5: Use It in Your Schema”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 },}Example: A “User” Property
Section titled “Example: A “User” Property”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 lookupconst 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> ),};Built-in Operators
Section titled “Built-in Operators”The library exports pre-composed operator arrays you can reuse or extend:
| Operator set | Operators |
|---|---|
stringOperators | contains, does_not_contain, is, is_not, starts_with, ends_with, is_empty, is_not_empty |
numberOperators | equals, does_not_equal, greater_than, gte, less_than, lte, is_empty, is_not_empty |
dateOperators | is, is_not, is_before, is_after, is_on_or_before, is_on_or_after, is_empty, is_not_empty |
checkboxOperators | is, is_not |
singleSelectOperators | is, is_not, is_empty, is_not_empty |
multiSelectOperators | is, 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";Headless Core Definitions
Section titled “Headless Core Definitions”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.