Back to Blog

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:

Full Playground Drag shapes (rectangle and circle) to see connections update
ABC
AB
AC
BC

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
]
Connection Points Click anywhere on the border to see its normalized coordinates
(0, 0)(1, 0)(0, 1)(1, 1)
Hover over the shape's border and click to pin a point. Coordinates are normalized from (0,0) to (1,1).

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:

Circle Anchors Click anywhere on the border to see its angle
Hover over the circle's border and click to pin a point. Unlike rectangles, circles use angles to define anchor positions.

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.

Creating a Binding How arrows get connected to shapes
Arrow endpoint is floating freely near a shape

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:

Draw Arrows Click and drag from one shape to another to create bindings
ABCD
Click and drag from any shape to start drawing an arrow.

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:

Reactive Arrow Updates Drag the boxes and watch the computed positions change
AB
<line
x1={startPos.x}130
y1={startPos.y}110
x2={endPos.x}220
y2={endPos.y}110
/>

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:

Curved Arrows Same shapes, but now with Bézier curves
AB
<path d="
M 130 110
C 170 110,
180 110,
220 110
" />

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:

  1. Find the point that's t percent between P0 and P1 (call it Q0)
  2. Find the point that's t percent between P1 and P2 (call it Q1)
  3. Find the point that's t percent between Q0 and Q1—that's the point on the curve
How Bézier Curves Work The de Casteljau algorithm visualized
P0P1 (control)P2
At each t, find points along the lines connecting P0-P1 and P1-P2. The green line connects those points, and the blue dot marks where the curve passes through.

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:

Draw Your Own Curve Drag the control points to shape the curve
Start (P0)Control 1Control 2End (P3)
path = "M 50,150 C 130,50 230,50 310,150"

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
Arrow Curve Tension Control points extend along the anchor's normal direction
Control points are placed at endpoint + normal * tension. The normal is the direction perpendicular to the edge where the anchor sits.

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:

Full Playground Drag shapes (rectangle and circle) to see connections update
ABC
AB
AC
BC

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.