Shape indexing

Every shape in tldraw has an index property that determines its visual stacking order (z-order) on the canvas. Shapes with higher index values appear in front of shapes with lower values. The index system uses string-based fractional indexing, which allows efficient reordering and supports real-time collaboration.

Why fractional indexing?

Integer-based indexing has problems. If you have shapes at indices [0, 1, 2] and want to insert between 0 and 1, you'd need to renumber subsequent shapes or use floats that eventually lose precision.

Fractional indexing uses lexicographically sortable strings. You can always generate a new index between any two existing indices. Standard JavaScript string comparison sorts them correctly, and reordering only updates the moved shapes - not every shape on the canvas.

Index structure

Indices are strings like 'a1', 'a2', or 'a1V'. The first letter is the integer part (base-62 encoded), and optional following characters are the fractional part for inserting between existing indices. The @tldraw/utils package uses the jittered fractional indexing algorithm to generate these strings.

Generating indices

The @tldraw/utils package exports functions for generating indices:

import {
	getIndexBetween,
	getIndexAbove,
	getIndexBelow,
	getIndicesAbove,
	getIndicesBelow,
	getIndicesBetween,
	sortByIndex,
	IndexKey,
} from '@tldraw/utils'

// Generate a single index between two existing indices
const between = getIndexBetween('a1' as IndexKey, 'a3' as IndexKey) // e.g. 'a2'

// Generate indices above or below an existing index
const above = getIndexAbove('a1' as IndexKey) // e.g. 'a2'
const below = getIndexBelow('a2' as IndexKey) // e.g. 'a1'

// Generate multiple indices at once (exact values vary due to jittering)
const multipleAbove = getIndicesAbove('a0' as IndexKey, 3)
const multipleBelow = getIndicesBelow('a2' as IndexKey, 2)
const multipleBetween = getIndicesBetween('a0' as IndexKey, 'a2' as IndexKey, 2)

// Sort objects by their index property
const shapes = [{ index: 'a2' as IndexKey }, { index: 'a1' as IndexKey }]
shapes.sort(sortByIndex) // [{ index: 'a1' }, { index: 'a2' }]

The algorithm includes jittering (randomization) to reduce conflicts in collaborative environments. When two users insert shapes at the same position simultaneously, jittering makes them generate different indices instead of identical ones.

Reordering shapes

The Editor provides four methods for changing shape z-order. You can pass either shape IDs or shape objects to any of these methods.

Send to back and bring to front

// Move shapes to the very back or front
editor.sendToBack(['shape:abc123' as TLShapeId, 'shape:def456' as TLShapeId])
editor.bringToFront(['shape:abc123' as TLShapeId])

These move shapes to the bottom or top of the z-order within their parent.

Send backward and bring forward

// Move shapes one step back or forward
editor.sendBackward(['shape:abc123' as TLShapeId])
editor.bringForward(['shape:abc123' as TLShapeId])

By default, these methods only move shapes past other shapes they visually overlap. This makes keyboard shortcuts feel intuitive - pressing "send backward" moves a shape behind the shape it's actually covering, not behind some distant shape.

To move past any shape regardless of overlap:

editor.sendBackward(['shape:abc123' as TLShapeId], { considerAllShapes: true })

Order preservation

All reordering methods preserve relative order. If you select shapes A, B, and C (stacked A-B-C from back to front) and bring them forward, they stay in A-B-C order at their new position.

How reordering works internally

Shape indices are always relative to siblings within the same parent. When you reorder shapes, tldraw:

  1. Groups shapes by parent
  2. Finds the insertion point (front, back, or adjacent to an overlapping shape)
  3. Generates new indices using getIndicesBetween
  4. Updates only the shapes that actually need new indices

If shapes are already at the target position, no updates occur.

Collaboration

Fractional indexing works well for real-time collaboration. When two users simultaneously reorder shapes, they generate different indices in the same region of index space. Both operations succeed and merge cleanly.

The jittering mentioned earlier is important here. Without it, two users inserting at the same position would generate identical indices, causing conflicts. With jittering, they get different indices that sort near each other but remain distinct.

Since reordering only updates the moved shapes' indices, it doesn't interfere with other concurrent edits to different shapes.

Index validation

IndexKey is a branded type - you can't accidentally pass an arbitrary string as an index. Use validateIndexKey to check if a string is valid:

import { validateIndexKey } from '@tldraw/utils'

validateIndexKey('a1') // passes, 'a1' is a valid index
validateIndexKey('invalid!') // throws an error

The store validates indices when you create or update shapes, so the editor won't enter an invalid state.

Prev
Shape clipping
Next
Shape transforms