Custom records

Add custom record types to the store.

import { useCallback, useMemo } from 'react'
import {
	CustomRecordInfo,
	T,
	Tldraw,
	Vec,
	createCustomRecordId,
	createCustomRecordMigrationIds,
	createCustomRecordMigrationSequence,
	createTLStore,
	isCustomRecord,
	track,
	useEditor,
} from 'tldraw'
import 'tldraw/tldraw.css'

// There's a guide at the bottom of this file!

// [1]
const MARKER_TYPE = 'marker'
interface Marker {
	id: string
	typeName: typeof MARKER_TYPE
	x: number
	y: number
	label: string
	icon: string
}

// [2]
const markerVersions = createCustomRecordMigrationIds(MARKER_TYPE, {
	AddIcon: 1,
})

// [3]
const markerRecord: CustomRecordInfo = {
	scope: 'document',
	validator: T.object({
		id: T.string,
		typeName: T.literal(MARKER_TYPE),
		x: T.number,
		y: T.number,
		label: T.string,
		icon: T.string,
	}),
	migrations: createCustomRecordMigrationSequence({
		sequence: [
			{
				id: markerVersions.AddIcon,
				up: (record) => {
					record.icon = '📍'
				},
				down: (record) => {
					delete record.icon
				},
			},
		],
	}),
	createDefaultProperties: () => ({
		x: 0,
		y: 0,
		label: '',
		icon: '📍',
	}),
}

// [4]
function createMarkerId(id?: string) {
	return createCustomRecordId(MARKER_TYPE, id)
}

const ICONS = ['📍', '⭐', '🏠', '🏢', '🎯', '⚠️']

// [5]
const MarkerOverlay = track(function MarkerOverlay() {
	const editor = useEditor()

	const markers = editor.store
		.allRecords()
		.filter((r) => isCustomRecord(MARKER_TYPE, r)) as any as Marker[]

	const addMarker = useCallback(() => {
		const label = prompt('Marker label:')
		if (!label) return
		const center = editor.getViewportScreenCenter()
		const point = editor.screenToPage(center)
		editor.store.put([
			{
				id: createMarkerId(),
				typeName: MARKER_TYPE,
				x: point.x,
				y: point.y,
				label,
				icon: ICONS[Math.floor(Math.random() * ICONS.length)],
			} as any,
		])
	}, [editor])

	return (
		<>
			{markers.map((marker) => {
				const screenPoint = editor.pageToViewport(new Vec(marker.x, marker.y))
				return (
					<div
						key={marker.id}
						style={{
							position: 'absolute',
							left: screenPoint.x,
							top: screenPoint.y,
							transform: 'translate(-50%, -100%)',
							display: 'flex',
							flexDirection: 'column',
							alignItems: 'center',
							pointerEvents: 'all',
							cursor: 'pointer',
						}}
						title={marker.label}
						onPointerDown={(e) => {
							e.stopPropagation()
							if (e.button === 2 || e.ctrlKey) {
								editor.store.remove([marker.id as any])
							}
						}}
					>
						<span style={{ fontSize: 28 }}>{marker.icon}</span>
						<span
							style={{
								fontSize: 11,
								background: 'white',
								border: '1px solid #ccc',
								borderRadius: 4,
								padding: '1px 4px',
								whiteSpace: 'nowrap',
								maxWidth: 120,
								overflow: 'hidden',
								textOverflow: 'ellipsis',
							}}
						>
							{marker.label}
						</span>
					</div>
				)
			})}
			<button
				onClick={addMarker}
				style={{
					position: 'absolute',
					top: 50,
					right: 10,
					zIndex: 1000,
					padding: '6px 12px',
					borderRadius: 6,
					border: '1px solid #ccc',
					background: 'white',
					cursor: 'pointer',
					fontSize: 14,
				}}
			>
				+ Add marker
			</button>
		</>
	)
})

// [6]
export default function CustomRecordsExample() {
	const store = useMemo(
		() =>
			createTLStore({
				records: { [MARKER_TYPE]: markerRecord },
			}),
		[]
	)

	return (
		<div className="tldraw__editor">
			<Tldraw
				store={store}
				components={{
					InFrontOfTheCanvas: MarkerOverlay,
				}}
			/>
		</div>
	)
}

/*
Introduction:

You can add custom record types to the tldraw store to persist and synchronize
domain-specific data that doesn't fit into shapes, bindings, or assets. This example
adds a "marker" record type — like a map pin that marks a location on the canvas.

[1]
Define your record's type name and TypeScript type. The record must have `id` and
`typeName` fields — these are required by the store system.

[2]
Use `createCustomRecordMigrationIds` to define versioned migration IDs for your record
type. These follow the convention `com.tldraw.{typeName}/{version}`.

[3]
Create a CustomRecordInfo configuration object. This tells the store how to handle
your record type:
- `scope`: 'document' records are persisted and synced. 'session' records are local only.
- `validator`: Validates the record structure using tldraw's validation library.
- `migrations`: Optional. Define how the record evolves over time using
  `createCustomRecordMigrationSequence`. Each migration has an `id` (from the version ids),
  an `up` function to add/transform fields, and an optional `down` function for backwards
  compatibility. If omitted, an empty migration sequence is created automatically.
- `createDefaultProperties`: Factory for default property values.

[4]
A helper to create properly formatted record IDs. Record IDs follow the pattern
`typeName:uniqueId`.

[5]
A React component that renders markers on the canvas and provides a button to add new
ones. We use the `track` wrapper so the component re-renders when the store changes.
We use `isCustomRecord` to filter records by type, and `pageToViewport` to position
the markers correctly as the camera moves. Right-click (or ctrl-click) a marker to
remove it.

[6]
We create a store with our custom record type using `createTLStore` and pass it to
Tldraw via the `store` prop. The `records` option registers our marker type alongside
the built-in record types (shapes, assets, etc.).

*/

You can add custom record types to the tldraw store to persist and synchronize domain-specific data alongside shapes, bindings, and other built-in records. This example adds a "marker" record type for pinning locations on the canvas.

Is this page helpful?
Prev
Static assets
Next
Export canvas as image