Node Based UI Design: 9 Patterns, No Library
Node based ui design is the wiring screen — boxes dropped on a canvas and joined with lines, the way n8n, Figma, and Blender work.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Node box with ports
- 02 Curved wire between two ports
- 03 Drag from a port to connect
- 04 Cutting a wire
- 05 Dots flowing along the wire
- 06 Node run status
- 07 Marquee select
- 08 Panning and zooming the grid canvas
- 09 Exporting the graph as JSON
The nine follow the order you meet them in while building a screen, not a ladder of difficulty. You place a box (01), draw a wire into it (02), then drag one yourself (03) and cut a bad one (04) — that is the whole editing loop, and everything after it is about reading the graph rather than changing it. Run state comes next: which path is live right now (05), and how each box ended (06). The last three belong to the canvas rather than to any single node — grabbing several boxes at once (07), moving and scaling the floor under them (08), and handing what you drew to another tool as JSON (09). The stages cycle through the same four grounds — paper, ink, yellow, and orange — and no two in a row share one, so nine demos read as nine screens instead of one long one.
01Node box with ports
Two cards sit on a 16px grid floor, each with a blue inbound dot cut into its left edge and an orange outbound dot on its right, and hovering or tabbing to one grows that dot to 1.5 times its size from its own center. The four dots take turns during the preview loop, which is how an item with only 0.79% changed area still moves in 12 of 23 frames — a screen's worth of meaning carried by four 14px circles. The card header is a two-column grid, so a long node name never squeezes the icon out of shape.
// Ports — the inbound dot on the left edge, the outbound dot on the right
.ng__port {
position: absolute; top: 50%;
width: 14px; height: 14px; padding: 0;
border: 2px solid $stage-paper; border-radius: 50%; cursor: pointer;
// A port is a finished dot of its own — it grows from its centre so it never shoves sideways
transform-origin: 50% 50%;
transform: translateY(-50%) scale(1);
transition: transform $dur-quick $easing;
}
.ng__port--in { left: -7px; background: $color; }
.ng__port--out { right: -7px; background: $stage-orange; }
.ng__port:hover,
.ng__port:focus-visible { transform: translateY(-50%) scale(1.5); }
02Curved wire between two ports
One cubic curve runs from the outbound dot to the inbound one, and both control handles reach out horizontally by half the gap between the two points, never less than 40, so the line always leaves a port sideways instead of shooting off diagonally. Press Closer or Farther and the downstream node slides 66 units, the caption reprints the gap and the handle length, and the curve is rebuilt from the new figures. In the preview loop the wire draws itself in and rewinds at 88% of the cycle, a pass that measured intensity 128.0 across 18 of 23 frames.
// Cubic bezier — the handle is half the horizontal gap, and never under 40.
// Both handles leave horizontally, so the wire always exits a port at its side.
function wirePath(x1, y1, x2, y2) {
var h = Math.max(40, Math.abs(x2 - x1) * 0.5);
return { d: 'M' + x1 + ',' + y1 + ' C' + (x1 + h) + ',' + y1 + ' ' + (x2 - h) + ',' + y2 + ' ' + x2 + ',' + y2, handle: h };
}
function layout(shift) {
nodeB.setAttribute('transform', 'translate(' + shift + ',0)');
var inX = BASE_IN.x + shift;
var r = wirePath(OUT.x, OUT.y, inX, BASE_IN.y);
wire.setAttribute('d', r.d);
var len = wire.getTotalLength(); // measure the wire so one dash equals the whole line
wire.style.setProperty('--len', len); // scss reads the dash length back as var(--len)
dxOut.textContent = String(inX - OUT.x);
handleOut.textContent = String(Math.round(r.handle));
}
03Drag from a port to connect
Pressing the outbound dot starts a temporary wire that tracks the pointer, and setPointerCapture keeps that dot receiving move events even after the finger has left its 7-unit circle. Within 28 units of the inbound dot the loose end snaps onto it and the dot swells to 1.7 times its size, so the connection reads as made before the finger lifts. The keyboard walks the same road: Enter on the outbound dot arms it and moves focus, Enter on the inbound dot commits the wire, and the render moved in 20 of 23 frames over 2.515% changed area.
outPort.addEventListener('pointerdown', function (e) {
e.preventDefault(); wake();
outPort.setPointerCapture(e.pointerId); // this dot keeps receiving even once the finger leaves it
dragging = true; snapped = false;
temp.style.opacity = '1';
drawTo(toLocal(e));
});
outPort.addEventListener('pointermove', function (e) { if (dragging) drawTo(toLocal(e)); });
outPort.addEventListener('pointerup', function () {
if (!dragging) return;
if (snapped) commit();
stop();
});
outPort.addEventListener('pointercancel', stop);
04Cutting a wire
Each connection is drawn as two halves meeting in the middle, so pushing stroke-dashoffset up to the 83-unit dash length empties both halves outward from the center instead of retracting the line from one end. Hovering raises a scissors button at that midpoint, and the grab area is an 18-unit transparent stroke with pointer-events: stroke, which keeps the empty space beside the curve behaving as plain canvas. Cut and reconnect both fit inside one 2s loop at 0.899% changed area over 16 of 23 frames.
// The wire: two halves reaching out from the middle (200,y) toward each node.
// d starts at the node and ends in the middle, so a rising dashoffset empties it from the centre out
.ng__wire {
fill: none; stroke: $color; stroke-width: 3; stroke-linecap: round; pointer-events: none;
stroke-dasharray: $wire-len; stroke-dashoffset: 0;
transition: stroke-dashoffset $duration $easing, stroke-width $dur-quick $easing;
}
// Only the grab area is thick — it is caught on the stroke, never on a fill
.ng__hit { fill: none; stroke: transparent; stroke-width: 18; pointer-events: stroke; cursor: pointer; }
.ng__wirewrap:hover .ng__wire,
.ng__wirewrap:focus-within .ng__wire { stroke-width: 5; }
.ng__wirewrap.is-cut .ng__wire { stroke-dashoffset: $wire-len; }
05Dots flowing along the wire
Three cream dots ride the live branch from the upstream node to the downstream one, and their road is not a CSS approximation of the curve: the offset-path string is the same text as the wire's own d attribute. Because the dots are SVG elements, that path resolves in viewBox user space, and a scripted check found each dot center agreeing with getPointAtLength to within 0.01px. Spacing the three with negative delays of 2s divided by three is what keeps all 23 of 23 frames moving, at intensity 157.6, the strongest reading of the nine.
// The flowing dots: the road is not a CSS invention, it is a copy of the wire's own d.
// offset-path on an SVG element resolves in viewBox user space, so dot and wire never drift apart
.ng__dot {
offset-path: path("M122,85 C176,85 224,132 278,132");
offset-distance: 0%;
offset-rotate: 0deg;
fill: $subject-cream;
animation-name: ngFlow; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
// The delays are negative. A positive delay (0.4s / 0.8s) leaves a dot part-way down the road
// when the 2s window closes and teleports it to 0% on the next pass — a delay that is not
// a positive delay closes the loop only when it is a whole multiple of the period
.ng__dot--2 { animation-delay: -0.6667s; }
.ng__dot--3 { animation-delay: -1.3333s; }
06Node run status
Waiting, Running, Done, and Failed are carried by color in two places at once: the card's border and a band that fills down its left edge. The band grows linear through the running stretch alone and every other keyframe swaps with steps(1, end), so no frame ever catches a card half blue and half green. Three cards run in sequence and fall back to waiting inside the loop, for 4.903% changed area across 19 of 23 frames.
// 1: waiting → running (12%) → done (36%) → waiting (80%)
@keyframes ngEdge1 {
0%, 11.9% { border-color: $wait-edge; }
12%, 35.9% { border-color: $run-ink; }
36%, 79.9% { border-color: $done-ink; }
80%, 100% { border-color: $wait-edge; }
}
// The band grows linear through the running stretch only; every other step is a cut
// (each keyframe carries its own timing function)
@keyframes ngBand1 {
0% { transform: scaleY(0); background-color: $wait-band; animation-timing-function: steps(1, end); }
12% { transform: scaleY(0); background-color: $run-ink; animation-timing-function: linear; }
35.9% { transform: scaleY(1); background-color: $run-ink; animation-timing-function: steps(1, end); }
36%, 79.9% { transform: scaleY(1); background-color: $done-ink; animation-timing-function: steps(1, end); }
80%, 100% { transform: scaleY(0); background-color: $wait-band; }
}
07Marquee select
Press the empty floor and drag: a dashed rectangle grows, every node it overlaps lights its ring, and a rolling counter says how many are caught. The two normalizing lines carry the whole idea: take the smaller of the start and current coordinates as the top left and the absolute difference as the size, and the drag runs up and to the left as readily as down and to the right. A rectangle that big makes this the second busiest item at 17.009% changed area over 18 of 23 frames, yet one of the gentlest in intensity at 61.4.
function pick(x, y, w, h) {
var n = 0;
for (var i = 0; i < nodes.length; i++) {
var b = rectOf(nodes[i]);
var hit = b.x < x + w && b.x + b.w > x && b.y < y + h && b.y + b.h > y;
nodes[i].classList.toggle('is-picked', hit);
if (hit) n++;
}
digits.style.transform = 'translateY(' + (-DIGIT_H * n) + 'px)';
live.textContent = live.getAttribute('data-tpl').replace('N', String(n));
}
canvas.addEventListener('pointermove', function (e) {
if (!start) return;
var c = canvas.getBoundingClientRect();
var cx = e.clientX - c.left, cy = e.clientY - c.top;
// Normalised so any drag direction works — the smaller of the two points is the top left
// and the absolute difference is the size. Without these two lines, dragging up or left draws nothing
var x = Math.min(start.x, cx), y = Math.min(start.y, cy);
var w = Math.abs(cx - start.x), h = Math.abs(cy - start.y);
place(x, y, w, h);
pick(x, y, w, h);
});
08Panning and zooming the grid canvas
Dragging the floor moves the grid's background-position and the node layer's translate by one and the same distance, 36px across and 20px down in the preview loop, and the zoom button scales that layer about a transform-origin of 50% 50% so the middle of the viewport holds still. Since the entire floor shifts, this is by far the largest change on screen at 54.162% area, and at the same time the quietest at intensity 41.6 across 15 of 23 frames. The grid itself is two linear-gradient layers on a 24px tile, so there is no image to fetch and no second element to keep in step.
// ⚠ The grid (background-position) and the node layer (translate) must share keyframe
// times AND easing. Ease only one of them and the floor slips against the boxes in the
// in-between frames — which is why both of these run linear
.ng.is-demo .ng__canvas {
animation-name: ngGridPan; animation-duration: $duration; animation-timing-function: linear;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ng.is-demo .ng__world {
animation-name: ngWorldPan; animation-duration: $duration; animation-timing-function: linear;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes ngGridPan {
0% { background-position: 0 0; }
40%, 80% { background-position: -36px -20px; }
88%, 100% { background-position: 0 0; }
}
@keyframes ngWorldPan {
0% { transform: translate(0, 0) scale(1); }
40% { transform: translate(-36px, -20px) scale(1); }
50%, 80% { transform: translate(-36px, -20px) scale(1.2); }
88%, 100% { transform: translate(0, 0) scale(1); }
}
09Exporting the graph as JSON
The demo walks the three nodes exactly as they sit, reads each one's rounded offset from the board, pairs neighbors into links, and hands the object to JSON.stringify with an indent of two. The panel prints that string one span per line, Clipboard.writeText carries it away, and the toast counts its lines from the same string rather than from a stored number, so the count and the text can never disagree. With nothing looping afterward, this is the calmest of the nine at 9 of 23 moved frames, and it is the one that turns a drawing into something another tool can run.
function build() {
// Walk the nodes as they actually sit on screen into an object — every character below comes from it
var b = board.getBoundingClientRect();
var graph = { nodes: [], links: [] };
for (var i = 0; i < nodes.length; i++) {
var r = nodes[i].getBoundingClientRect();
graph.nodes.push({
id: nodes[i].getAttribute('data-id'),
x: Math.round(r.left - b.left),
y: Math.round(r.top - b.top)
});
}
for (var j = 1; j < graph.nodes.length; j++) {
graph.links.push({ from: graph.nodes[j - 1].id, to: graph.nodes[j].id });
}
return JSON.stringify(graph, null, 2);
}
Where it breaks — the trap
The flowing dots in 05 were built first with positive delays of 0, 0.4s, and 0.8s against a 2s cycle, and in a browser left open all afternoon they look correct. They are not. Capture exactly one period and the seam shows: when the capture window closed the second dot was still standing partway along its road, and on the next pass it snapped back to 0%. A positive delay only agrees with a loop when it is a whole multiple of the duration, so the answer was to run the offsets backwards instead — 0, −0.6667s, and −1.3333s, which is 2s divided by three. A negative animation-delay starts an animation already part-way through rather than pinning it to frame zero, and with the three dots spread that way the curve never shows a jump, which is the 23 of 23 moved frames in the measurement.
The second trap has nothing to do with any browser. Every demo's poster image is chosen as the frame that differs most from the one before it, and the wire-cutting item in 04 kept picking the instant the wire came back rather than the instant it was cut. The cause was a tie. While the wire was severed the demo also dimmed a whole node card, and switching that dim on and switching it off changed one and the same 108 × 52 rectangle, so the two candidate frames scored level and the choice wobbled between renders. Dropping the card dim and blanking only the inbound dot, a circle of radius 4.5, took the big rectangle out of the contest and left the poster resting on the scissors button and the splitting wire at a changed area of 0.899%.
The third one is an ordering rule in CSS that quietly moves a node. Where a pan and a zoom share a single declaration, as in translate(-36px, -20px) scale(1.2), the scale is applied first and the translate second, so the 36px is a flat shift laid on top of an already-zoomed picture rather than a distance the zoom stretches: every node's x is pushed away from the 200px center by 1.2 and only afterward shifted. Put the leftmost node through it and the arithmetic reads 200 + (88 − 200) × 1.2 − 36 = 29.6px, where 88 is that node's own left: 22% on a 400-unit stage. At 18% the same formula lands it 19.2px further left and tight against the edge, which is why it was moved. The same item hides a quieter rule underneath: the grid floor and the node layer have to share an easing as well as a distance, because giving an ease to only one of them makes the floor slip against the boxes in every in-between frame, so both of them run linear. If you'd sooner read the SCSS than the prose, the password on the archive below is 4y5hy4a5 and it opens the same nine folders these renders came out of.
Accessibility (reduced-motion)
Every color pair on these nine screens was measured with the WCAG 2.1 relative luminance formula rather than eyeballed, and all of the text pairs clear AA at their size.
| where | foreground | background | ratio |
|---|---|---|---|
| node name | ink #17141a | cream card #fff7e6 | 17.11:1 |
| node name | ink #17141a | white card #ffffff | 18.24:1 |
| node description (ink 74%) | #534f4f | cream card | 7.58:1 |
| legend (ink 78%) | #4a484c | white floor | 9.04:1 |
| curve caption value | yellow #ffd23f | ink stage #17141a | 12.63:1 |
| helper line (ink 82%) | #413621 | yellow stage #ffd23f | 8.20:1 |
| "Running" | blue #2a5fd8 | white card | 5.63:1 |
| "Done" | green #0f7050 | white card | 6.08:1 |
| "Failed" | red #c2361a | white card | 5.47:1 |
| "Waiting" (ink 62%) | #6f6d71 | white card | 5.12:1 |
| button label | cream #fff7e6 | blue #2a5fd8 | 5.29:1 |
| input dot | blue #2f6df6 | cream card | 4.25:1 (graphical, 3:1 bar, never text) |
| output dot | orange #ff4d1f | cream card | 3.11:1 (graphical only) |
| cream text on the orange stage | #fff7e6 | orange stage #ff4d1f | 3.11:1 — fails, so that stage carries no text outside cream cards |
| blue on white | #2f6df6 | #ffffff | 4.53:1 — borderline, so status text was darkened to #2a5fd8 |
Under prefers-reduced-motion: reduce none of the nine goes blank, because each one parks at a frame that still says what it is for. In 01 the pulse loop stops and the ports rest at scale(1), keeping the hover growth but dropping its transition. In 02 the wire holds at stroke-dashoffset: 0, fully drawn. In 03 the fake cursor and the temporary wire are hidden while the committed wire is forced visible, so the still shows a finished connection. In 04 the wire stays whole and the scissors button still appears on hover or focus, only without the spring. In 05 the three dots stop traveling but stay on the curve, with the second parked at offset-distance: 40% and the third at 75%, so the road is still legible. In 06 the running card's band is held at scaleY(1) and the state colors stay put. In 07 the marquee rectangle stays at full size, the rings stay lit on the three nodes it catches, and the counter is parked on the matching number. In 08 the floor and the node layer both return to translate(0, 0) scale(1) with the base zoom label showing. In 09 the code lines and the toast are simply present, with no reveal.
Screen readers get the same story through text. Each port in 01 points at its legend line with aria-describedby; the draggable ports in 03 are focusable buttons with labels, and the hint under them is an aria-live="polite" region that announces the join; the scissors in 04 carries its own label; 06, 07, and 09 each keep a polite live region for the run state, the number of nodes selected and the number of lines copied. Every decorative icon is aria-hidden="true", which is what stops a screen reader from reading out nine identical glyph names.
FAQ
Does any of this need a graph library?
No. The nine demos are HTML, SCSS, and plain JavaScript, and the busiest of them runs under 120 lines of script. A library earns its place when you need automatic layout or thousands of nodes, not when you need a canvas with a dozen boxes on it — see the n8n workflow examples for what those boxes end up running.
Can I drag a wire on a phone?
Yes. The wiring demo listens for Pointer events rather than mouse events, so a finger, a stylus, and a mouse all arrive at one handler, and setPointerCapture keeps the drag alive when the finger slides off the dot. The SVG carries touch-action: none so the browser does not take the gesture away for scrolling, the same rule the touch gesture patterns rely on.
Why are the labels inside the demos so short?
Because the screen strings come from a dictionary and a longer word can walk straight under a port. The outbound dot in 02 sits at x 140 with a radius of 6, so it occupies 134 to 146, and getBBox() measured an early English draft of that node name running past the start of it; the value in the dictionary is now Customers, which ends at 108 and clears the circle. More on the layout side in the split pane layouts, and the rest of the JavaScript pieces sit in the same category.