GODRICH

플로우차트 UI 9종 — 노드를 선으로 잇는 화면

플로우차트 UI 는 상자를 늘어놓고 선으로 이어 「무엇 다음에 무엇이 오는지」를 눈으로 보여 주는 화면입니다. 여기 모은 아홉 벌은 n8n·피그마 같은 편집기의 그 화면을 라이브러리 없이 바닐라 JS 와 SCSS 로만 다시 짠 부품이라, 붙이는 즉시 끌리고 이어지고 끊깁니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개의 순서는 유행이 아니라 이런 화면을 만드는 사람이 실제로 겪는 순서입니다. 상자를 놓고(01) 두 점을 곡선으로 이은 다음(02), 손으로 끌어서 이어 보고(03), 잘못 이은 것을 끊고(04), 지금 실행 중인 길을 보여 주고(05), 끝난 결과를 상태로 표시하고(06), 여러 개를 한꺼번에 골라(07) 화면을 밀고 키운 뒤(08), 마지막에 그린 것을 JSON 으로 꺼냅니다(09). 무대 색은 네 가지를 돌려 씁니다 — 종이색(01·06), 먹색(02·05·09), 옐로(03·07), 오렌지(04·08). 같은 토큰을 나눠 쓰기 때문에 아홉 중 몇 개만 골라 한 페이지에 섞어도 색과 간격이 어긋나지 않습니다. 자동화 쪽 짝인 n8n 워크플로우 예제 9종 과 편집기 좌우를 나누는 CSS 화면 분할 패널 9종 을 같이 보면 편집기 한 벌이 거의 완성됩니다.

01포트 달린 노드 상자

상자 왼쪽에 받는 점(파랑), 오른쪽에 보내는 점(오렌지)을 붙여 두고 커서를 올리면 그 점만 1.5배로 커집니다. 점은 자기 중심에서 커져야 하므로 transform-origin 을 50% 50% 로 두고 세로 가운데 정렬용 translateY(-50%) 를 scale 앞에 붙여 둡니다. 누적면적 0.79% · 움직인 프레임 12/23 으로, 네 점이 0.2초씩 어긋나 차례로 부푸는 미리보기만 도는 조용한 부품입니다.

transform-origin: 50% 50%aria-describedbygrid-template-columns
.ng__port {
  position: absolute; top: 50%;
  width: 14px; height: 14px; padding: 0;
  border: 2px solid $stage-paper; border-radius: 50%; cursor: pointer;
  // 포트는 스스로 완결한 점이다 — 커질 때 옆으로 밀리지 않게 자기 중심에서 (blog-demo 규칙 8)
  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); }

02두 점을 잇는 곡선

선 한 줄은 3차 베지어 하나이고, 양쪽 손잡이는 두 점의 가로 거리의 절반(최소 40)만큼 수평으로 뻗습니다. 손잡이가 수평이라 선은 언제나 포트 옆구리로 빠져나가고, 노드를 66px 오른쪽으로 밀면 손잡이도 같이 길어져 곡선이 완만해집니다. 그린 뒤 getTotalLength() 로 잰 길이를 --len 에 넣어 점선 한 칸을 선 전체 길이로 만들면, 그 한 칸을 밀고 당기는 것만으로 선이 그려졌다 지워집니다(강도 128.0 · 18/23).

stroke-dasharraystroke-dashoffsetstroke-linecap: round
// 3차 베지어 — 손잡이는 두 점의 가로 거리의 절반, 최소 40.
// 손잡이가 두 점에서 수평으로 뻗으므로 선은 항상 포트 옆구리로 빠진다.
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();          // 선 길이를 재서 점선 한 칸으로 만든다
  wire.style.setProperty('--len', len);   // 점선 칸 길이는 scss 가 var(--len) 으로 받는다
  dxOut.textContent = String(inX - OUT.x);
  handleOut.textContent = String(Math.round(r.handle));
}

03점에서 끌어 연결하기

보내는 점을 누르면 포인터 이벤트setPointerCapture 가 그 점에 포인터를 묶어, 손가락이 상자 밖으로 나가도 끌기가 끊기지 않습니다. 임시 선 끝은 매 프레임 포인터를 따라오다가 받는 점 중심에서 28 안으로 들어오면 그 점에 달라붙고, 받는 점이 1.7배로 한 번 부풀어 「여기 놓으면 된다」를 알려 줍니다. 키보드만 쓰는 사람을 위해 보내는 점에서 Enter, 받는 점에서 다시 Enter 하는 길을 따로 열어 두었습니다(누적면적 2.515% · 20/23).

pointerdownsetPointerCapturegetBoundingClientRect
function drawTo(p) {
  var far = Math.sqrt((p.x - IN.x) * (p.x - IN.x) + (p.y - IN.y) * (p.y - IN.y));
  snapped = far < SNAP;
  var end = snapped ? IN : p;
  temp.setAttribute('d', wirePath(OUT.x, OUT.y, end.x, end.y));
  inPort.classList.toggle('is-near', snapped);
}

outPort.addEventListener('pointerdown', function (e) {
  e.preventDefault(); wake();
  outPort.setPointerCapture(e.pointerId);        // 손가락이 포트를 벗어나도 이 점이 계속 받는다
  dragging = true; snapped = false;
  temp.style.opacity = '1';
  drawTo(toLocal(e));
});
outPort.addEventListener('pointerup', function () {
  if (!dragging) return;
  if (snapped) commit();
  stop();
});

04연결선 끊기

선은 가운데에서 각 노드로 뻗는 반쪽 두 줄이고, 점선 한 칸을 선 길이(83)로 잡아 두면 stroke-dashoffset 을 키우는 것만으로 가운데부터 양옆으로 갈라집니다. 집히는 영역은 칠이 아니라 투명한 굵은 선 한 겹이라 pointer-events: stroke 로 선 위에서만 집히고, 커서를 올리면 가운데에 가위 단추가 떠오릅니다. 누적면적 0.899% · 강도 104.7 로 화면 대부분은 가만히 있고 끊기는 선만 움직입니다.

stroke-dashoffsetpointer-events: strokearia-label
// ── 연결선: 가운데(200,y)에서 각 노드 쪽으로 뻗는 반쪽 두 개.
// d 의 시작점이 노드, 끝점이 가운데라서 dashoffset 을 키우면 **가운데부터** 비어 간다
.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;
}
// 집는 영역만 굵게 — 칠(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; }

05선을 따라 흐르는 점

점 세 개가 곡선 길을 따라 앞 노드에서 뒤 노드로 흐르며 지금 일하는 갈래를 가리킵니다. 길은 CSS 로 새로 그린 것이 아니라 선의 d 를 그대로 복사한 path() 문자열이고, SVG 요소에 건 offset-path 는 viewBox 좌표계에서 풀리므로 선과 점이 어긋나지 않습니다. 아홉 중 유일하게 움직인 프레임이 23/23 이고 강도도 157.6 으로 가장 높습니다.

offset-pathoffset-distanceanimation-fill-mode: backwards
// ── 흐르는 점: 길은 CSS 가 아니라 위 선의 d 를 그대로 복사한 것이다.
// SVG 요소에 건 offset-path 는 viewBox 사용자 좌표계에서 풀리므로 선과 점이 어긋나지 않는다 (285 Playwright 실측 오차 0.01px)
.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;
}
.ng__dot--2 { animation-delay: -0.6667s; }
.ng__dot--3 { animation-delay: -1.3333s; }

@keyframes ngFlow {
  from { offset-distance: 0%; }
  to { offset-distance: 100%; }
}

06노드 실행 상태

대기·실행 중·성공·실패 네 상태의 칩을 세로로 쌓아 두고 한 칸(20px)의 배수만큼 밀어 한 장만 보여 줍니다. 미는 타이밍이 steps(1, end) 라 반투명하게 섞인 중간 상태가 생기지 않고, data-state 속성 하나만 바꿔도 테두리 색·왼쪽 띠·칩이 한꺼번에 따라옵니다. 상태 카드가 넓은 면을 차지해 누적면적은 4.903%, 움직인 프레임은 19/23 입니다.

steps(1, end)aria-livedata-state
// ── 상태 칩: 네 장을 쌓고 $cell 배수만큼 밀어 한 장만 보인다
.ng__chips { display: block; height: $cell; overflow: hidden; }
.ng__strip { display: grid; }
.ng__chip {
  display: flex; align-items: center; justify-content: flex-end; gap: $sp-1;
  height: $cell; white-space: nowrap;
  font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1;
}
// ── 정적 경로: data-state 가 그대로 화면이다 (미리보기 루프가 없어도 완성 화면)
.ng__card[data-state="wait"] .ng__strip { transform: translateY(0); }
.ng__card[data-state="run"] .ng__strip { transform: translateY(-$cell); }
.ng__card[data-state="done"] .ng__strip { transform: translateY(-$cell * 2); }
.ng__card[data-state="fail"] .ng__strip { transform: translateY(-$cell * 3); }

.ng__card[data-state="run"] { border-color: $run-ink; }
.ng__card[data-state="done"] { border-color: $done-ink; }
.ng__card[data-state="fail"] { border-color: $error; }

07끌어서 여러 개 고르기

빈 바닥을 누른 채 끌면 점선 사각형이 자라고, 사각형에 걸친 노드마다 테두리가 켜지며 배지의 숫자가 바뀝니다. 어느 방향으로 끌어도 되게 두 점 중 작은 쪽을 좌상단으로, 차의 절댓값을 크기로 정규화하는 두 줄이 핵심이고, 이 두 줄이 없으면 왼쪽이나 위로 끌 때 사각형이 아예 그려지지 않습니다. 사각형이 화면을 가로지르므로 누적면적이 17.009% 까지 올라갑니다.

pointermoveMath.minaria-live
canvas.addEventListener('pointerdown', function (e) {
  if (e.target.closest('.ng__node')) return;   // 빈 바닥에서만 사각형이 시작한다
  ng.classList.remove('is-demo');
  var c = canvas.getBoundingClientRect();
  start = { x: e.clientX - c.left, y: e.clientY - c.top };
  canvas.setPointerCapture(e.pointerId);
  marquee.classList.add('is-on');
  place(start.x, start.y, 0, 0);
  e.preventDefault();
});
canvas.addEventListener('pointermove', function (e) {
  if (!start) return;
  var c = canvas.getBoundingClientRect();
  var cx = e.clientX - c.left, cy = e.clientY - c.top;
  // 어느 방향으로 끌어도 되게 정규화한다 — 두 점 중 작은 쪽이 좌상단,
  // 차의 절댓값이 크기다. 이 두 줄이 없으면 왼쪽·위로 끌 때 사각형이 안 그려진다
  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);
});

08격자 바닥 밀고 키우기

바닥의 격자 무늬는 background-position, 노드와 선은 레이어 하나의 translate 로 밀리는데 둘 다 −36px·−20px 로 같은 거리를 줍니다. 확대는 transform-origin50% 50% 로 두어 무대 한가운데를 축 삼아 1.2배까지 올라가고, 되돌아올 때는 88% 안에 제자리로 돌아와 2초 루프가 닫힙니다. 화면 전체가 함께 움직이는 항목이라 누적면적은 54.162% 로 아홉 중 가장 넓고 강도는 41.6 으로 가장 낮습니다.

background-positiontransform: scaletransform-origin
// ⚠ 격자(background-position)와 노드 레이어(translate)는 **같은 키프레임 시각·같은 이징**이어야 한다.
// 한쪽만 ease 를 쓰면 중간 프레임에서 거리가 어긋나 바닥이 미끄러져 보인다 — 그래서 둘 다 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); }
}

09그린 것을 JSON 으로

화면에 놓인 노드를 그대로 훑어 좌표와 연결 목록을 객체로 만들고, JSON.stringify(graph, null, 2) 로 들여쓴 문자열을 오른쪽 칸에 한 줄씩 떠오르게 뿌립니다. 오른쪽 글자는 장식이 아니라 왼쪽 그림에서 방금 뽑아낸 값이고, 복사 단추는 사용자 동작과 권한을 요구하는 Clipboard.writeText 가 성공한 뒤에야 몇 줄을 옮겼는지 세어 말하고, 실패하면 글자를 직접 선택하도록 되돌립니다. 글자만 바뀌는 항목이라 움직인 프레임이 9/23 으로 가장 적고 누적면적은 3.749% 입니다.

JSON.stringifynavigator.clipboard.writeTextaria-live
function build() {
  // 화면에 놓인 노드를 그대로 훑어 객체로 만든다 — 아래 글자는 전부 이 객체에서 나온 것이다
  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);
}

어디서 깨지는가 — 함정

가장 오래 애먹인 것은 05 의 흐르는 점이었습니다. 점 세 개를 0초·0.4초·0.8초로 어긋나게 놓고 주기를 2초로 잡았더니, 2초짜리 캡처 창의 마지막 장에서 둘째 점이 길 한가운데 서 있다가 다음 바퀴 0프레임에서 0% 로 순간이동했습니다. 양수 지연은 그만큼 시작이 늦어지므로 주기의 배수가 아닌 한 캡처 창이 닫힐 때까지 한 바퀴를 못 채웁니다. 지연을 음수로 바꿔 0초·−0.6667초·−1.3333초, 즉 2초를 셋으로 나눈 값을 주자 세 점이 처음부터 등간격으로 자리를 잡았고 움직인 프레임이 23/23 으로 채워졌습니다.

두 번째는 대표 이미지가 엉뚱한 장면으로 뽑힌 04 입니다. poster 는 앞 장과 가장 크게 달라진 장을 고르는데, 선이 끊긴 동안 노드 카드 한 장을 통째로 어둡게 만들어 두었더니, 그 연출을 켜는 프레임과 끄는 프레임이 같은 카드 한 장(가로 108·세로 52)을 똑같이 바꿔 두 후보의 점수가 나란해졌습니다. 면적이 같으니 「끊는 장면」과 「되살아나는 장면」 중 어느 쪽이 뽑힐지 매번 흔들렸고, 실제로 한 번은 이미 이어진 그림이 썸네일로 나왔습니다. 카드째 어둡게 하는 연출을 버리고 반지름 4.5 짜리 받는 점 하나만 비우자 그 경쟁에서 빠져 poster 가 가위 단추와 갈라지는 선으로 고정됐고, 지금 누적면적은 0.899% 입니다.

세 번째는 08 에서 노드 한 장이 무대 가장자리까지 밀려난 일입니다. translatescale 을 한 줄에 쓰면 오른쪽에 적은 scale 이 먼저 적용되므로, 왼쪽 노드의 x 는 중심 200px 에서 1.2배만큼 벌어진 뒤에 −36px 밀립니다. 노드를 왼쪽 18%(400px 바닥에서 72px)에 두었을 때 200+(72−200)×1.2−36 은 10.4px, 바닥 왼쪽 끝까지 10px밖에 안 남습니다. 노드를 22%(88px)로 옮겨 200+(88−200)×1.2−36 = 29.6px 로 되돌리고 나서야 확대가 가장 깊은 구간에서도 여백이 남았습니다. 팬과 확대를 같이 쓰는 캔버스에서는 계산을 한 번 손으로 해 보는 편이 빠릅니다. 세 가지를 다 반영한 아홉 벌의 완성본은 이 글 아래 압축 파일에 들어 있고, 여는 비밀번호는 4y5hy4a5 입니다.

접근성

색만으로 상태를 말하지 않도록 네 상태에 아이콘과 글자를 함께 붙였고, 글자 대비는 WCAG 2.1 상대휘도로 직접 계산했습니다.

어디 비율
노드 이름 잉크 #17141a 크림 카드 #fff7e6 17.11:1
노드 이름 잉크 #17141a 흰 카드 #ffffff 18.24:1
노드 설명 (잉크 74%) #534f4f 크림 카드 7.58:1
범례 (잉크 78%) #4a484c 흰 바닥 9.04:1
곡선 캡션 숫자 옐로 #ffd23f 잉크 무대 #17141a 12.63:1
안내문 (잉크 82%) #413621 옐로 무대 #ffd23f 8.20:1
「실행 중」 파랑 #2a5fd8 흰 카드 5.63:1
「성공」 초록 #0f7050 흰 카드 6.08:1
「실패」 빨강 #c2361a 흰 카드 5.47:1
「대기」 (잉크 62%) #6f6d71 흰 카드 5.12:1
단추 글자 크림 #fff7e6 파랑 #2a5fd8 5.29:1
받는 점 파랑 #2f6df6 크림 카드 4.25:1 — 그림 요소(3:1) 통과, 글자로는 안 씀
보내는 점 오렌지 #ff4d1f 크림 카드 3.11:1 — 그림 요소 통과, 글자로는 안 씀
(미달) 크림 글자 #fff7e6 오렌지 무대 #ff4d1f 3.11:1 — 그래서 오렌지 무대에는 글자를 두지 않고 전부 크림 카드 안으로 넣었습니다
(아슬아슬) 파랑 #2f6df6 흰 바탕 4.53:1 — 상태 글자는 #2a5fd8 로 한 단 어둡게 바꿨습니다

prefers-reduced-motion: reduce 에서는 아홉 개가 저마다 다른 것을 남깁니다. 01 은 점의 맥박과 커지는 전환을 끄고 점을 제 크기로 세워 둡니다. 02 는 선 그리기를 끄고 stroke-dashoffset: 0, 즉 완전히 이어진 선을 남깁니다. 03 은 가짜 포인터와 임시 선을 지우고 이어진 굵은 선만 보이게 합니다. 04 는 선·가위 단추·포트의 애니메이션과 전환을 모두 끄고, 가위는 커서를 올리거나 초점이 갔을 때만 나타납니다. 05 는 점이 선 위 0%·40%·75% 지점에 멈춰 서고 선과 노드는 그대로 남습니다. 06 은 도는 아이콘과 자라는 띠를 멈추되 실행 중인 카드의 띠는 꽉 찬 상태로 고정해 진행 중이라는 사실은 남기고, 07 은 사각형을 완전한 크기로 세워 세 노드가 골라진 최종 장면을 보여 줍니다. 08 은 팬과 확대를 원점으로 되돌려 100% 화면을 남기고, 09 는 JSON 줄이 떠오르는 연출을 지운 채 글자를 전부 세워 두고 복사 배지도 띄운 상태로 남깁니다.

스크린 리더에는 그림이 아니라 사건을 읽힙니다. 01 의 두 점은 aria-label 로 「받는 점」·「보내는 점」이 되고 aria-describedby 로 아래 범례 문장과 이어집니다. 03 의 안내 글자에는 aria-live="polite" 가 걸려 있어 연결이 끝나면 「이어졌습니다」가 읽히고, 04 의 가위는 role="button"aria-label="연결 끊기" 를 가진 SVG 그룹이라 탭으로 닿습니다. 06 은 카드 위쪽의 「대기 중 · 3단계」 줄이, 07 은 화면 밖 문단의 「노드 N개 선택」이 상태를 알리고, 09 의 배지는 「N줄 복사했어요」를 같은 방식으로 말합니다. 나머지 장식용 도형에는 전부 aria-hidden="true" 를 걸어 두 번 읽히지 않게 했습니다. 부품을 더 찾는다면 JS 카테고리 에 같은 규격으로 만든 것들이 모여 있습니다.

FAQ

라이브러리 없이 이 정도 편집기를 만들 수 있나요

아홉 벌 모두 외부 의존성이 없는 HTML·SCSS·바닐라 JS 입니다. 곡선은 SVG path 한 줄, 끌기는 포인터 이벤트, 상태는 data-state 속성으로 끝나기 때문에 빌드 도구도 필요하지 않습니다. 노드가 수백 개로 늘어나면 그때 가상화가 필요하지만, 화면 하나에 열 개 안팎이면 이 정도로 충분합니다.

선을 왜 SVG 로 그리나요

div 를 회전시켜 만든 선은 두 점 사이가 직선일 때만 맞고 곡선이 되는 순간 무너집니다. SVG path 는 곡선 하나를 문자열 한 줄로 적을 수 있고 getTotalLength() 로 길이까지 재 주므로, 그려지는 연출과 끊기는 연출이 같은 속성 하나로 해결됩니다. 경로 문법은 MDN SVG d 속성 문서가 기준입니다.

휴대폰 터치에서도 끌기가 되나요

됩니다. 마우스·터치·펜을 나누지 않는 포인터 이벤트만 쓰고, 끌기가 일어나는 바닥에는 touch-action: none 을 걸어 브라우저의 스크롤 제스처가 끌기를 가로채지 않게 막아 두었습니다. 다만 포트 원은 작으므로 손가락으로 쓸 화면이라면 반지름을 키우거나 주변 여백을 넓히는 편이 좋습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.