How smart arrows work in diagramming tools
I use tools like Miro and tldraw to create architecture docs, flowcharts, and system designs. I've always wondered how the arrows know to adapt when I drag shapes around. They stretch and bend, but they keep the right amount of tension.
Try it yourself. Drag the shapes around:
When you move a box closer, the arrow relaxes into a gentle arc. When you pull it far away, the curve reaches out. The arrows stay connected and always find a path between shapes.
How does that work? It involves three things: connection points, bindings, and Bézier curves. Let's build it from scratch using JavaScript and SVG.
Connection points (anchors)
Shapes need places where arrows can attach, and these places are called anchor points.
Each shape defines anchors at the center of each edge. Instead of storing absolute coordinates, we store normalized positions as percentages (0-1) of the shape's bounds:
const anchors = [
{ id: 'top', position: { x: 0.5, y: 0 } }, // center top
{ id: 'right', position: { x: 1, y: 0.5 } }, // center right
{ id: 'bottom', position: { x: 0.5, y: 1 } }, // center bottom
{ id: 'left', position: { x: 0, y: 0.5 } }, // center left
]When the shape moves or resizes, we compute the anchor's world position by multiplying with the shape's current bounds.
Circles are different
Circles don't have edges, so we use angles instead of edge positions. Any point on a circle's border can be described by its angle from the center:
The formula is center + radius * (cos(θ), sin(θ)). It's the same idea as before: you store a normalized value and compute the world position when you need it.
The binding
When you drag an arrow endpoint near a shape, the application detects proximity and offers to "snap" to the nearest anchor. If you release there, it creates a binding: a reference that links the arrow's endpoint to a specific shape and anchor.
A binding stores which shape and anchor the arrow connects to:
const binding = {
shapeId: "box-123",
anchor: { id: "right", position: { x: 1, y: 0.5 } }
}The arrow endpoint's position is computed from the shape's position plus the anchor offset, rather than stored directly.
Try drawing your own arrows. Click and drag from one shape to another to create a binding:
Reactive updates
When a shape moves, we don't manually update every connected arrow. Instead, the arrow's position is computed from its bindings:
function getEndpointPosition(binding, shapes) {
const shape = shapes.find(s => s.id === binding.shapeId)
return {
x: shape.x + binding.anchor.position.x * shape.width,
y: shape.y + binding.anchor.position.y * shape.height
}
}Drag the boxes and watch the arrow endpoints update in real-time:
Arrows automatically stay connected because when you move a shape, all its bound arrows update without any manual bookkeeping.
Straight arrows work, but...
The simplest arrow is a straight line between two anchor points:
// Straight line: just connect the two points
<line
x1={startPos.x} y1={startPos.y}
x2={endPos.x} y2={endPos.y}
/>It works, but something feels off. Drag box B up or down in the demo above. When the boxes aren't horizontally aligned, the arrow cuts awkwardly through space. It doesn't feel like it "belongs" to either shape.
Why curves look better
Diagramming tools use curves instead of straight lines. Try the same interaction with a curved arrow:
The curve leaves each shape perpendicular to its edge, which makes the arrow look like it belongs to the shape rather than just touching it.
// Curved arrow: use a Bézier curve with control points
const cp1 = {
x: startPos.x + startNormal.x * tension,
y: startPos.y + startNormal.y * tension
}
const cp2 = {
x: endPos.x + endNormal.x * tension,
y: endPos.y + endNormal.y * tension
}
<path d={`M ${startPos.x} ${startPos.y}
C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y},
${endPos.x} ${endPos.y}`} />Arrows should respect the direction of their anchors, and the normal vector is what tells the curve which way to leave the shape.
Bézier curves
The curves you see are cubic Bézier curves. What is a Bézier curve?
The de Casteljau algorithm
A Bézier curve is built by finding points between other points. Given a parameter t from 0 to 1, you can find a point that's t percent of the way between two points. When t is 0.5, you get the midpoint. When t is 0.25, you get a point a quarter of the way along.
For a quadratic curve with 3 control points (P0, P1, P2), at any value of t:
- Find the point that's
tpercent between P0 and P1 (call it Q0) - Find the point that's
tpercent between P1 and P2 (call it Q1) - Find the point that's
tpercent between Q0 and Q1—that's the point on the curve
As t goes from 0 to 1, the final point traces out the curve. The green line connects the intermediate points and always points in the direction the curve is heading.
Draw your own
A cubic Bézier curve has 4 control points: two endpoints and two "handles" that pull the curve in different directions. Drag the control points to see how they shape the curve:
Notice how each control point affects the curve. The handles create a smooth connection by controlling the direction the curve enters and exits each endpoint.
Arrow curves
For arrows, we compute control points automatically by placing them along the normal direction of each anchor:
- An anchor on the right edge has a normal pointing right → control point extends right
- An anchor on the top edge has a normal pointing up → control point extends up
The tension parameter controls how far the control points extend from the anchor. Higher tension creates more pronounced curves; lower tension approaches a straight line.
function getControlPoint(endpoint, normal, tension) {
return {
x: endpoint.x + normal.x * tension,
y: endpoint.y + normal.y * tension
}
}Putting it together
Here's the full system with multiple shapes and arrows:
Each arrow has a start binding and an end binding. On every render, we compute everything from those bindings:
function renderArrow(arrow, shapes) {
// 1. Look up the bound shapes
const startShape = shapes.find(s => s.id === arrow.startBinding.shapeId)
const endShape = shapes.find(s => s.id === arrow.endBinding.shapeId)
// 2. Compute world positions from normalized anchors
const startPos = getEndpointPosition(arrow.startBinding, startShape)
const endPos = getEndpointPosition(arrow.endBinding, endShape)
// 3. Get the normal direction for each anchor
const startNormal = getNormal(arrow.startBinding.anchor)
const endNormal = getNormal(arrow.endBinding.anchor)
// 4. Generate control points along those normals
const cp1 = getControlPoint(startPos, startNormal, tension)
const cp2 = getControlPoint(endPos, endNormal, tension)
// 5. Render the Bézier curve
return `M ${startPos.x} ${startPos.y}
C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y},
${endPos.x} ${endPos.y}`
}When a shape moves, we don't update any stored positions. We just re-render, and the arrows end up in the right place because they're computed from the current shape positions.
Summary
Anchors define attachment points as normalized coordinates, and bindings store which shape and anchor an arrow connects to. When you need the actual position, you compute it by multiplying the normalized anchor with the shape's bounds. Curves get their shape from control points that extend along the anchor's normal direction.
The main idea is that you don't store positions directly. Instead, you store which things are connected and compute the positions from that relationship.