Skip to content
FLAVIO COPES
flaviocopes.com
2026

Build a draggable node diagram without a library

By

Build a Railway-style draggable infrastructure canvas with vanilla JS. Positioned nodes, SVG edges, pointer events, no diagram library needed.

~~~

Railway’s stack canvas looks like a diagram tool. Nodes, edges, drag, live numbers.

You don’t need React Flow or D3 for that. I built one in vanilla TypeScript for StackPlan. No diagram library. Just divs, an SVG layer, and pointer events.

Two layers in one container

The canvas is a relative container with a fixed height. Nodes are absolute divs inside it. Edges live in an SVG that covers the whole area with pointer-events: none.

That split matters. HTML cards are easy to style and read. SVG lines are easy to redraw when a node moves.

<div class="canvas relative" style="width:900px;height:560px">
  <svg class="absolute inset-0 pointer-events-none">
    <line x1="172" y1="280" x2="452" y2="120" stroke="#94a3b8" />
  </svg>
  <div class="node absolute" style="left:60px;top:232px;width:224px">
    Cloudflare Workers
  </div>
</div>

Each node gets left and top from a { x, y } map. When you drag, you update that map. Alpine or any reactive layer re-binds the style.

Drag with pointer events

Mouse events break when the cursor leaves the element mid-drag. Pointer events fix that.

On pointerdown, store the node id and the offset between the cursor and the node’s top-left corner. Call setPointerCapture on the target so you keep getting events even outside the card.

onPointerDown(e, nodeId) {
  this.draggingId = nodeId
  const rect = canvas.getBoundingClientRect()
  const pos = positions[nodeId]
  this.dragOffset = {
    x: e.clientX - rect.left - pos.x,
    y: e.clientY - rect.top - pos.y,
  }
  e.currentTarget.setPointerCapture(e.pointerId)
}

onPointerMove(e) {
  if (!this.draggingId) return
  const rect = canvas.getBoundingClientRect()
  positions[this.draggingId] = {
    x: e.clientX - rect.left - this.dragOffset.x,
    y: e.clientY - rect.top - this.dragOffset.y,
  }
}

onPointerUp(e) {
  e.currentTarget.releasePointerCapture(e.pointerId)
  this.draggingId = null
}

My advice: use a small movement threshold before you treat a press as a drag. That way a tap can open a detail drawer and a drag can move the card.

Recompute edges on every move

Each edge stores from and to node ids plus x1, y1, x2, y2. When positions change, recompute from the center of each card:

function nodeCenter(pos) {
  return { x: pos.x + cardW / 2, y: pos.y + cardH / 2 }
}

function updateEdgeGeometry(edges, positions) {
  for (const edge of edges) {
    const c1 = nodeCenter(positions[edge.from])
    const c2 = nodeCenter(positions[edge.to])
    edge.x1 = c1.x
    edge.y1 = c1.y
    edge.x2 = c2.x
    edge.y2 = c2.y
    edge.len = Math.hypot(c2.x - c1.x, c2.y - c1.y)
  }
}

Watch the positions object and call this every time it changes. The SVG lines follow the cards frame by frame.

Animate cost numbers with requestAnimationFrame

When a tier changes, the monthly price jumps. Showing the new number instantly feels cheap. Tween it.

Keep a display value separate from the target value. Each frame, interpolate with an ease-out curve and round to cents:

function animateNumber(from, to, onUpdate, durationMs = 600) {
  const start = performance.now()

  function tick(now) {
    const t = Math.min(1, (now - start) / durationMs)
    const eased = 1 - Math.pow(1 - t, 3)
    onUpdate(t >= 1 ? to : Math.round(from + (to - from) * eased))
    if (t < 1) requestAnimationFrame(tick)
  }

  requestAnimationFrame(tick)
}

Run one tween per visible node plus one for the stack total. Cancel the previous tween when a new target arrives mid-animation.

Check prefers-reduced-motion. When it’s on, skip the tween and set the final value immediately.

What you get

No dependency graph. No layout solver. You place nodes in columns, let users drag them, redraw lines from centers, and tick costs with rAF.

That’s enough for an infrastructure canvas that feels alive. The hard part is polish — staggered enter animations, edge draw-ins, debounced layout saves — not the core model.

~~~

Related posts about js: