DOM-based shape size

Size a custom shape from its rendered DOM instead of from w and h props.

/* eslint-disable react-hooks/rules-of-hooks */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
	AtomMap,
	EditorAtom,
	RecordProps,
	Rectangle2d,
	ShapeUtil,
	T,
	Tldraw,
	TLShape,
	TLShapeId,
	useEditor,
} from 'tldraw'
import 'tldraw/tldraw.css'
import { contents } from './contents'

const DYNAMIC_SIZE_TYPE = 'dynamic-size'

// [1]
declare module 'tldraw' {
	export interface TLGlobalShapePropsMap {
		[DYNAMIC_SIZE_TYPE]: { contents: string[] }
	}
}

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

const SHAPE_WIDTH_PX = 150

// [2]
type DynamicSizeShape = TLShape<typeof DYNAMIC_SIZE_TYPE>

// [3]
const ShapeSizes = new EditorAtom('shape sizes', (editor) => {
	const map = new AtomMap<TLShapeId, { width: number; height: number }>('shape sizes')

	// [a] Clean up sizes when shapes are deleted
	editor.sideEffects.registerAfterDeleteHandler('shape', (shape) => {
		map.delete(shape.id)
	})

	return map
})

// [4]
function useDynamicShapeSize(shape: DynamicSizeShape) {
	const ref = useRef<HTMLDivElement>(null)
	const editor = useEditor()

	const updateShapeSize = useCallback(() => {
		if (!ref.current) return

		// [a] Get actual DOM dimensions
		const width = ref.current.offsetWidth
		const height = ref.current.offsetHeight

		// [b] Update the shape size in our global atom
		ShapeSizes.update(editor, (map) => {
			const existing = map.get(shape.id)
			if (existing && existing.width === width && existing.height === height) return map
			return map.set(shape.id, { width, height })
		})
	}, [editor, shape.id])

	// [c] Update size immediately on render
	useLayoutEffect(() => {
		updateShapeSize()
	})

	// [d] Watch for DOM size changes using ResizeObserver
	useLayoutEffect(() => {
		if (!ref.current) return
		const observer = new ResizeObserver(updateShapeSize)
		observer.observe(ref.current)
		return () => {
			observer.disconnect()
		}
	}, [updateShapeSize])

	return ref
}

// [5]
export class DynamicSizeShapeUtil extends ShapeUtil<DynamicSizeShape> {
	// [a]
	static override type = DYNAMIC_SIZE_TYPE
	static override props: RecordProps<DynamicSizeShape> = {
		contents: T.arrayOf(T.string),
	}

	// [b]
	getDefaultProps(): DynamicSizeShape['props'] {
		return {
			contents,
		}
	}

	// [c]
	override canCull(shape: DynamicSizeShape) {
		return false
	}

	// [d]
	override canResize(shape: DynamicSizeShape) {
		return false
	}

	// [e]
	getGeometry(shape: DynamicSizeShape) {
		const size = ShapeSizes.get(this.editor).get(shape.id)
		return new Rectangle2d({
			width: SHAPE_WIDTH_PX,
			height: size?.height ?? 50,
			isFilled: true,
		})
	}

	// [f]
	component(shape: DynamicSizeShape) {
		const ref = useDynamicShapeSize(shape)

		const [contentsToShow, setContentsToShow] = useState<string>('')

		// [i] Animate text content to demonstrate dynamic sizing
		useEffect(() => {
			const animationDuration = 6000
			const tick = (time: number) => {
				const progress = (time % animationDuration) / animationDuration
				const amountToShow = progress < 0.5 ? progress * 2 : 1 - (progress - 0.5) * 2

				setContentsToShow(
					shape.props.contents
						.slice(0, Math.floor(amountToShow * shape.props.contents.length))
						.join(' ')
				)

				frame = requestAnimationFrame(tick)
			}

			let frame = requestAnimationFrame(tick)

			return () => {
				cancelAnimationFrame(frame)
			}
		}, [shape.props.contents])

		// [ii] Return DOM element that will be measured
		return (
			<div ref={ref} style={{ width: SHAPE_WIDTH_PX }}>
				{contentsToShow}
			</div>
		)
	}

	// [g]
	getIndicatorPath(shape: DynamicSizeShape) {
		const { width, height } = this.editor.getShapeGeometry(shape).bounds
		const path = new Path2D()
		path.rect(0, 0, width, height)
		return path
	}
}

// [6]
const shapeUtils = [DynamicSizeShapeUtil]

export default function SizeFromDomExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw
				shapeUtils={shapeUtils}
				onMount={(editor) => {
					editor.selectAll()
					editor.deleteShapes(editor.getSelectedShapeIds())

					editor.createShape({
						type: DYNAMIC_SIZE_TYPE,
						x: 100,
						y: 100,
					})

					editor.selectAll().zoomToSelection()
				}}
			/>
		</div>
	)
}

/*
Two pieces do the work and can be reused for other shapes: the ShapeSizes atom and the
useDynamicShapeSize hook.

[1]
Extend TLGlobalShapePropsMap to add our shape's props to the global type system.

[2]
The shape only stores content. Its size is measured from the DOM element that renders it.

[3]
ShapeSizes is an EditorAtom holding an AtomMap from shape id to measured size. Using an
EditorAtom (rather than a module-level atom) scopes the sizes to one editor instance, and
AtomMap makes each shape's entry its own signal, so getGeometry for one shape doesn't
recompute when a different shape's size changes.

	[a] Delete a shape's entry when the shape is deleted, so the map doesn't grow forever.

[4]
useDynamicShapeSize measures a DOM element and writes its size to ShapeSizes:

	[a] offsetWidth/offsetHeight report layout size and ignore CSS transforms, so they're already
	    in shape space regardless of the camera zoom.

	[b] Writing to the atom invalidates getGeometry (see [e]) so selection bounds, hit testing,
	    and the indicator all follow the new size. We bail if nothing changed to avoid a redundant
	    store update.

	[c] Measure on every render, so the first paint already has a real size rather than the fallback.

	[d] ResizeObserver catches changes that don't go through React, such as fonts loading.

[5]
The shape util:

	[a] Standard type and props. Note there is no w or h prop.

	[b] Default props with some sample content.

	[c] Never cull the shape. Culled shapes are hidden with display: none, so its element would
	    measure 0×0 and the stored size would go stale until it scrolled back into view.

	[d] Resizing is disabled because the size is not something the user controls.

	[e] getGeometry reads from ShapeSizes. Because the read happens inside a computed, the geometry
	    updates reactively when the measured size changes. Before the first measurement we fall back
	    to a fixed height.

	[f] The component renders the content and attaches the ref from useDynamicShapeSize:

		[i] The text is animated so you can watch the selection bounds track the DOM.

		[ii] The measured element is given the shape width explicitly; only the height is dynamic.

	[g] The indicator uses the shape's geometry bounds, so it matches the measured size.

[6]
Standard setup: pass the shape util to Tldraw and create one shape on mount.

To reuse this in your own shape:
1. Call useDynamicShapeSize(shape) in your component and attach the returned ref.
2. Read ShapeSizes.get(this.editor).get(shape.id) in getGeometry.
3. Don't also store w/h props for the same dimension, or decide which one wins.
*/

Most shapes store their size as props. This shape has none: its component measures its own DOM element (with a ResizeObserver) and writes the result into an EditorAtom holding an AtomMap of shape id to size. getGeometry reads that atom, so selection bounds, hit testing, and the indicator update reactively as the content changes.

The shape's text animates in and out so you can watch the selection bounds follow it. Select the shape to see the indicator track the measured height.

Is this page helpful?
Prev
Attach shapes together (bindings)
Next
Layout constraints (bindings)