diagram: flow diagrams, edges follow the nodes

A node/edge board (the react-flow shape) is a set of boxes and a set of lines that must stay attached while the boxes move. Every detail below is one decision about that attachment: where a handle sits, how a line leaves it, what the label reads like, where the node may land. Drag everything; turn on the inspector (i): amber marks padding owners, red marks the hacks that pay for them.

node padding is leaf-only

A flow node is a tiny card: a title bar and a body. The layout rule survives at this size: the node arranges its two leaves, the leaves pad. A pads the node and pays with negative margins; B never does.

A: the node pads

upload.mp3
a recording lands here

the title bar wants to run edge to edge, so it escapes with negative margins, and the body needs its own hack to follow.

B: the leaves pad

upload.mp3
a recording lands here

the title bar keeps its border-bottom full bleed; the leaves own every inset. nothing escapes anything.

ruling

a node is a container; its head and body are leaves. at 176px wide the rule costs nothing and pays every time the node grows a footer, a badge, or a resize handle.

B markup
<div class="flow-node">
<div class="flow-head">upload.mp3</div>
<div class="flow-body">a recording lands here</div>
</div>
.flow-node { @apply rounded-lg border border-rule bg-paper; }
.flow-head { @apply border-b border-rule px-3 py-1.5; }
.flow-body { @apply px-3 py-1.5 text-ink/60; }

handles center on the border midpoint

An edge anchors at the center of its port handle, wherever the css put it. That is the whole contract: get the handle wrong and the line detaches from the card. Drag the nodes in both boards; A's endpoint swims inside the card, B's stays glued to the border.

A: the handle parks inside

the handle was “tidied” into the corner; the edge now enters through the title text and the gap reads as a bug.

B: half in, half out

the handle straddles the border midpoint: 6px out, 6px in, so the line visually plugs into the card.

ruling

the handle is the node's connection geometry, not a decoration. center it on the edge it names, and give it a fat invisible hit area (12px visible, 28px clickable) because ports are small targets on a crowded board.

B handle css
.flow-handle[data-port='left'] { @apply -left-1.5 top-[calc(50%-6px)]; }
.flow-handle[data-port='right'] { @apply -right-1.5 top-[calc(50%-6px)]; }
.flow-handle::after { @apply absolute -inset-2 content-['']; }

edges leave a handle in the handle's direction

A right-side port points right, so the edge's first pixels must travel horizontally. A draws straight segments: drag a node and the line cuts through the card it is supposed to leave. B bends the line out horizontally first with a cubic bezier whose control points share the handle's y.

A: straight lines

drag transcribe around: the line pierces whatever it passes over, including its own endpoints.

B: bezier with horizontal exits

same content, same drag: the line leaves and enters flat, then curves. the bend is clamped so short hops do not balloon.

the bend
bend = clamp(|b.x - a.x| / 2, 24, 120)
d = "M a.x a.y C (a.x + bend) a.y, (b.x - bend) b.y, b.x b.y"

labels are pills, not floating text

An edge label sits on the line it names, which is the worst place to read text. Bare text loses to every crossing line; a pill with a solid background punches a readable hole in the graph.

A: bare text on the line

audio

the line strikes through the word; two crossings make it unreadable.

B: a pill

audio

solid background, 1px border, one size step down. the graph shows through everywhere except under the words.

ruling

label contrast is a background decision, not a font-weight decision. thickening text over a line just thickens the line with it.

B label css
.flow-label { @apply absolute -translate-x-1/2 -translate-y-1/2 rounded border
border-rule bg-paper px-1.5 py-0.5 text-[0.65rem]; }

grid snapping keeps the board legible

Free drag ends in near-misses: nodes 3px apart read as aligned and are not. Snapping positions to an 8px grid on drop keeps columns and rows honest without a layout engine. Toggle it and drag the same node twice.

toggle, then drag a node, watch the dots
the snap
snap = (value) => board.matches('[data-snap]') ? Math.round(value / 8) * 8 : value

selection hugs the border and forgives the click

A selected node reads through an inset ring on the border: the card keeps its size, nothing reflows. Edges get the same treatment with a wider stroke, and the click target is a wide transparent twin of the path (react-flow's interactionWidth), because a 1.5px line is not a pointer target.

selection styling
.flow-node.is-active { @apply ring-2 ring-inset ring-clay; }
.flow-edge-hit { @apply fill-none stroke-transparent; stroke-width: 14; cursor: pointer; }
.flow-edge-path.is-active { @apply stroke-clay; stroke-width: 2; }

Every board on this page is one FlowBoard fed by one plain node/edge array in react state, which is why every demo shares the same drag, snap, and selection behavior.

search pages

go to any page