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
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
the title bar keeps its border-bottom full bleed; the leaves own every inset. nothing escapes anything.
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.
<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.
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.
.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.
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
the line strikes through the word; two crossings make it unreadable.
B: a pill
solid background, 1px border, one size step down. the graph shows through everywhere except under the words.
label contrast is a background decision, not a font-weight decision. thickening text over a line just thickens the line with it.
.flow-label { @apply absolute -translate-x-1/2 -translate-y-1/2 rounded borderborder-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.
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.
.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.