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.
01 node-box-ports
02 node-bezier-wire
03 node-drag-connect
04 node-delete-wire
05 node-flow-animation
06 node-run-status
07 node-select-multi
08 node-pan-zoom-canvas
09 node-save-json
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 node based ui pieces 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. Push the same trick — pointer coordinates turned into a path — toward freehand strokes and you land in signature pad canvas drawing.
01Node box with ports
What this node editor ui design shows
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<div class="ng__canvas">
<div class="ng__node">
<div class="ng__head">
<span class="ng__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,80c0,26.51-39.4,48-88,48S40,106.51,40,80s39.4-48,88-48S216,53.49,216,80Z" opacity="0.2"/><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg></span>
<span class="ng__name">Fetch orders</span>
</div>
<p class="ng__body">Fetches the list when a new order arrives</p>
<button type="button" class="ng__port ng__port--in" aria-label="Input port" aria-describedby="ngHintIn"></button>
<button type="button" class="ng__port ng__port--out" aria-label="Output port" aria-describedby="ngHintOut"></button>
</div>
<div class="ng__node">
<div class="ng__head">
<span class="ng__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M221.9,61.38,152,136v58.65a8,8,0,0,1-3.56,6.66l-32,21.33A8,8,0,0,1,104,216V136L34.1,61.38A8,8,0,0,1,40,48H216A8,8,0,0,1,221.9,61.38Z" opacity="0.2"/><path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"/></svg></span>
<span class="ng__name">Filter by amount</span>
</div>
<p class="ng__body">Only orders over 10,000 KRW pass through</p>
<button type="button" class="ng__port ng__port--in" aria-label="Input port" aria-describedby="ngHintIn"></button>
<button type="button" class="ng__port ng__port--out" aria-label="Output port" aria-describedby="ngHintOut"></button>
</div>
</div>
<ul class="ng__legend">
<li class="ng__hint" id="ngHintIn"><i class="ng__dot ng__dot--in" aria-hidden="true"></i>Input port — the previous step's result arrives here</li>
<li class="ng__hint" id="ngHintOut"><i class="ng__dot ng__dot--out" aria-hidden="true"></i>Output port — this step sends its result out</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.ng { box-sizing: border-box; width: min(100%, 432px); display: flex; flex-direction: column; gap: $sp-3; }
// 격자 바닥 — 노드가 놓인 캔버스
.ng__canvas {
position: relative; box-sizing: border-box;
display: flex; align-items: center; justify-content: center;
gap: $sp-10; padding: $sp-4;
border: 1px solid rgba(23, 20, 26, .14); border-radius: $r-card;
background-color: $stage-paper;
background-image: linear-gradient(to right, rgba(23, 20, 26, .09) 1px, transparent 1px),
linear-gradient(to bottom, rgba(23, 20, 26, .09) 1px, transparent 1px);
background-size: 16px 16px;
}
.ng__node {
position: relative; box-sizing: border-box;
width: clamp(104px, 36vw, 168px);
padding: $sp-3;
background: $subject-cream;
border: 1px solid rgba(23, 20, 26, .22);
border-radius: $r-card;
box-shadow: $shadow-press;
}
// 머리는 [아이콘 | 이름] 두 칸 — 이름이 길어져도 아이콘 칸은 안 눌린다
.ng__head { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: $sp-2; }
.ng__ico {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: $r-sm;
background: $color; color: $subject-cream; flex: 0 0 auto;
}
.ng__ico svg { width: 64%; height: 64%; }
.ng__name { font-weight: 800; font-size: clamp(10px, 3vw, 12px); line-height: 1.2; color: $subject-ink; }
.ng__body {
margin: $sp-2 0 0; font-weight: 500; font-size: clamp(8px, 2.4vw, 10px);
line-height: 1.35; color: rgba(23, 20, 26, .74);
}
// 포트 — 왼쪽 변에 받는 점, 오른쪽 변에 보내는 점
.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); }
.ng__legend { list-style: none; margin: 0; padding: 0; display: grid; gap: $sp-1; }
.ng__hint {
display: flex; align-items: center; gap: $sp-2;
font-weight: 600; font-size: clamp(8px, 2.4vw, 10px); line-height: 1.3;
color: rgba(23, 20, 26, .78);
}
.ng__dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.ng__dot--in { background: $color; }
.ng__dot--out { background: $stage-orange; }
// ── 미리보기 루프: 네 점이 차례로 부푼다 (2s 주기, 마지막 점은 1.12s 에 끝난다)
.ng.is-demo .ng__port {
animation-name: ngPortPulse; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__node:nth-child(1) .ng__port--in { animation-delay: 0ms; }
.ng.is-demo .ng__node:nth-child(1) .ng__port--out { animation-delay: 200ms; }
.ng.is-demo .ng__node:nth-child(2) .ng__port--in { animation-delay: 400ms; }
.ng.is-demo .ng__node:nth-child(2) .ng__port--out { animation-delay: 600ms; }
@keyframes ngPortPulse {
0% { transform: translateY(-50%) scale(1); }
12% { transform: translateY(-50%) scale(1.55); }
26% { transform: translateY(-50%) scale(1); }
100% { transform: translateY(-50%) scale(1); }
}
// ── 320×200 폰 (값은 내리기만 한다 — 올리면 세로가 넘친다)
@media (max-width: 340px), (max-height: 220px) {
.ng { gap: $sp-2; }
.ng__canvas { gap: $sp-2; padding: $sp-2; }
.ng__node { padding: $sp-2; }
.ng__ico { width: 18px; height: 18px; }
.ng__body { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__port { animation-name: none !important; transform: translateY(-50%) scale(1) !important; }
.ng__port { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
box-sizing: border-box;
width: min(100%, 432px);
display: flex;
flex-direction: column;
gap: 12px;
}
.ng__canvas {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
padding: 16px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background-color: #ffffff;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.09) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.09) 1px, transparent 1px);
background-size: 16px 16px;
}
.ng__node {
position: relative;
box-sizing: border-box;
width: clamp(104px, 36vw, 168px);
padding: 12px;
background: #fff7e6;
border: 1px solid rgba(23, 20, 26, 0.22);
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ng__head {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
}
.ng__ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 8px;
background: #2f6df6;
color: #fff7e6;
flex: 0 0 auto;
}
.ng__ico svg {
width: 64%;
height: 64%;
}
.ng__name {
font-weight: 800;
font-size: clamp(10px, 3vw, 12px);
line-height: 1.2;
color: #17141a;
}
.ng__body {
margin: 8px 0 0;
font-weight: 500;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1.35;
color: rgba(23, 20, 26, 0.74);
}
.ng__port {
position: absolute;
top: 50%;
width: 14px;
height: 14px;
padding: 0;
border: 2px solid #ffffff;
border-radius: 50%;
cursor: pointer;
transform-origin: 50% 50%;
transform: translateY(-50%) scale(1);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ng__port--in {
left: -7px;
background: #2f6df6;
}
.ng__port--out {
right: -7px;
background: #ff4d1f;
}
.ng__port:hover,
.ng__port:focus-visible {
transform: translateY(-50%) scale(1.5);
}
.ng__legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.ng__hint {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: clamp(8px, 2.4vw, 10px);
line-height: 1.3;
color: rgba(23, 20, 26, 0.78);
}
.ng__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.ng__dot--in {
background: #2f6df6;
}
.ng__dot--out {
background: #ff4d1f;
}
.ng.is-demo .ng__port {
animation-name: ngPortPulse;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__node:nth-child(1) .ng__port--in {
animation-delay: 0ms;
}
.ng.is-demo .ng__node:nth-child(1) .ng__port--out {
animation-delay: 200ms;
}
.ng.is-demo .ng__node:nth-child(2) .ng__port--in {
animation-delay: 400ms;
}
.ng.is-demo .ng__node:nth-child(2) .ng__port--out {
animation-delay: 600ms;
}
@keyframes ngPortPulse {
0% {
transform: translateY(-50%) scale(1);
}
12% {
transform: translateY(-50%) scale(1.55);
}
26% {
transform: translateY(-50%) scale(1);
}
100% {
transform: translateY(-50%) scale(1);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
gap: 8px;
}
.ng__canvas {
gap: 8px;
padding: 8px;
}
.ng__node {
padding: 8px;
}
.ng__ico {
width: 18px;
height: 18px;
}
.ng__body {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__port {
animation-name: none !important;
transform: translateY(-50%) scale(1) !important;
}
.ng__port {
transition: none !important;
}
}// 사람이 점을 만지는 순간 미리보기 루프를 끄고 hover/:focus-visible 경로로 넘긴다
var ng = document.getElementById('ng');
var ports = document.querySelectorAll('.ng__port');
function wake() { ng.classList.remove('is-demo'); }
for (var i = 0; i < ports.length; i++) {
ports[i].addEventListener('pointerenter', wake);
ports[i].addEventListener('focus', wake);
ports[i].addEventListener('click', wake);
}import { useState } from "react";
import styles from "./NodeBoxPorts.module.scss";
export type NodeBoxPortsProps = { duration?: number; easing?: string; color?: string };
type NodeSpec = { name: string; body: string; icon: string };
type PortSpec = { kind: string; label: string; hintId: string };
const NODES: NodeSpec[] = [
{ name: "주문 불러오기", body: "새 주문이 들어오면 목록을 가져옵니다", icon: "database" },
{ name: "금액 거르기", body: "1만 원이 넘는 주문만 통과시킵니다", icon: "funnel" },
];
const PORTS: PortSpec[] = [
{ kind: "in", label: "받는 점", hintId: "ngHintIn" },
{ kind: "out", label: "보내는 점", hintId: "ngHintOut" },
];
const OUT_COLOR = "#ff4d1f";
function glyph(kind: string) {
if (kind === "database") {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M216,80c0,26.51-39.4,48-88,48S40,106.51,40,80s39.4-48,88-48S216,53.49,216,80Z" opacity="0.2"/><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg>
);
}
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M221.9,61.38,152,136v58.65a8,8,0,0,1-3.56,6.66l-32,21.33A8,8,0,0,1,104,216V136L34.1,61.38A8,8,0,0,1,40,48H216A8,8,0,0,1,221.9,61.38Z" opacity="0.2"/><path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"/></svg>
);
}
export default function NodeBoxPorts({
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: NodeBoxPortsProps) {
const [demo, setDemo] = useState(true);
const wake = () => setDemo(false);
return (
<main className={styles.stage}>
<div className={styles.ng}>
<div className={styles.canvas}>
{NODES.map((n, i) => (
<div className={styles.node} key={n.name}>
<div className={styles.head}>
<span className={styles.ico} aria-hidden="true" style={{ background: color }}>{glyph(n.icon)}</span>
<span className={styles.name}>{n.name}</span>
</div>
<p className={styles.body}>{n.body}</p>
{PORTS.map((p, j) => (
<button
key={p.kind}
type="button"
className={`${styles.port} ${p.kind === "in" ? styles.portIn : styles.portOut}`}
aria-label={p.label}
aria-describedby={p.hintId}
style={{
background: p.kind === "in" ? color : OUT_COLOR,
...(demo
? {
animationName: "ngPortPulse",
animationDuration: `${duration}ms`,
animationTimingFunction: easing,
animationIterationCount: "infinite",
animationFillMode: "backwards",
animationDelay: `${(i * 2 + j) * 200}ms`,
}
: {}),
}}
onPointerEnter={wake}
onFocus={wake}
onClick={wake}
/>
))}
</div>
))}
</div>
<ul className={styles.legend}>
<li className={styles.hint} id="ngHintIn">
<i className={styles.dot} style={{ background: color }} aria-hidden="true" />받는 점 — 앞 단계의 결과가 들어옵니다
</li>
<li className={styles.hint} id="ngHintOut">
<i className={styles.dot} style={{ background: OUT_COLOR }} aria-hidden="true" />보내는 점 — 이 단계의 결과가 나갑니다
</li>
</ul>
</div>
</main>
);
}01 · Node box with ports — Measured area 0.79% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
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
What happens on screen
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<svg class="ng__svg" id="ngSvg" viewBox="0 0 400 180" role="group" aria-label="One curve joining two nodes">
<!-- 연결선 — 노드보다 먼저 그려 상자 뒤로 깔린다 -->
<path class="ng__wire" id="ngWire" fill="none" d="M140,46 C201,46 201,124 262,124"/>
<!-- 앞 노드 -->
<g class="ng__node">
<rect x="12" y="20" width="128" height="52" rx="12"/>
<svg x="26" y="36" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M216,80c0,26.51-39.4,48-88,48S40,106.51,40,80s39.4-48,88-48S216,53.49,216,80Z" opacity="0.2"/><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg>
<text class="ng__nm" x="54" y="46">Customers</text>
<circle class="ng__port ng__port--out" cx="140" cy="46" r="6"/>
</g>
<!-- 뒤 노드 — 통째로 밀어 두 점의 가로 거리를 바꾼다 -->
<g class="ng__node" id="ngNodeB" transform="translate(66,0)">
<rect x="196" y="98" width="120" height="52" rx="12"/>
<svg x="210" y="114" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg>
<text class="ng__nm" x="238" y="124">Send email</text>
<circle class="ng__port ng__port--in" cx="196" cy="124" r="6"/>
</g>
<text class="ng__cap" x="14" y="170"><tspan class="ng__capk">Horizontal gap</tspan><tspan class="ng__capv" id="ngDx" dx="8">122</tspan><tspan class="ng__capk" dx="14">Handle</tspan><tspan class="ng__capv" id="ngH" dx="8">61</tspan></text>
</svg>
<div class="ng__bar">
<button type="button" class="ng__btn" id="ngNear" aria-pressed="false">Closer</button>
<button type="button" class="ng__btn is-on" id="ngFar" aria-pressed="true">Farther</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-ink);
.ng { box-sizing: border-box; width: min(100%, 432px); display: flex; flex-direction: column; gap: $sp-3; }
.ng__svg { display: block; width: 100%; height: auto; }
// 노드 상자 — 어두운 무대 위 크림 카드
.ng__node rect { fill: $subject-cream; stroke: rgba(23, 20, 26, .35); stroke-width: 1; }
.ng__node .ng__ico { color: $subject-ink; }
.ng__nm { font-weight: 800; font-size: 12px; fill: $subject-ink; dominant-baseline: central; }
.ng__port { stroke: $subject-cream; stroke-width: 2; }
.ng__port--out { fill: $stage-orange; }
.ng__port--in { fill: $subject-blue; }
// 연결선 — 끝을 둥글게 잘라 포트 원과 이가 맞는다
.ng__wire {
stroke: $color; stroke-width: 6; stroke-linecap: round;
// 점선 한 칸 = 선 전체 길이. JS 가 getTotalLength() 로 잰 값을 --len 에 넣는다
stroke-dasharray: var(--len);
stroke-dashoffset: 0;
}
.ng__cap { font-size: 12px; }
.ng__capk { font-weight: 600; fill: rgba(255, 247, 230, .78); }
.ng__capv { font-weight: 800; fill: $color; }
.ng__bar { display: flex; justify-content: center; gap: $sp-2; }
.ng__btn {
font-family: inherit; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.6;
padding: $sp-1 $sp-4; border-radius: $r-pill; cursor: pointer;
background: none; border: 1px solid rgba(255, 247, 230, .55); color: $subject-cream;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.ng__btn.is-on { background: $subject-cream; border-color: $subject-cream; color: $subject-ink; }
// ── 미리보기 루프: 선이 왼쪽에서 오른쪽으로 그려졌다가 88% 에 도로 감긴다
// --len 은 JS 가 getTotalLength() 로 잰 실제 선 길이 (점선 한 칸 = 선 전체)
.ng.is-demo .ng__wire {
animation-name: ngWireDraw; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ngWireDraw {
0% { stroke-dashoffset: var(--len); }
60% { stroke-dashoffset: 0; }
78% { stroke-dashoffset: 0; }
88% { stroke-dashoffset: var(--len); }
100% { stroke-dashoffset: var(--len); }
}
// ── 320×200 폰 (값은 내리기만 한다)
@media (max-width: 340px), (max-height: 220px) {
.ng { gap: $sp-1; }
.ng__btn { padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__wire { animation-name: none !important; stroke-dashoffset: 0 !important; }
.ng__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
box-sizing: border-box;
width: min(100%, 432px);
display: flex;
flex-direction: column;
gap: 12px;
}
.ng__svg {
display: block;
width: 100%;
height: auto;
}
.ng__node rect {
fill: #fff7e6;
stroke: rgba(23, 20, 26, 0.35);
stroke-width: 1;
}
.ng__node .ng__ico {
color: #17141a;
}
.ng__nm {
font-weight: 800;
font-size: 12px;
fill: #17141a;
dominant-baseline: central;
}
.ng__port {
stroke: #fff7e6;
stroke-width: 2;
}
.ng__port--out {
fill: #ff4d1f;
}
.ng__port--in {
fill: #2f6df6;
}
.ng__wire {
stroke: #ffd23f;
stroke-width: 6;
stroke-linecap: round;
stroke-dasharray: var(--len);
stroke-dashoffset: 0;
}
.ng__cap {
font-size: 12px;
}
.ng__capk {
font-weight: 600;
fill: rgba(255, 247, 230, 0.78);
}
.ng__capv {
font-weight: 800;
fill: #ffd23f;
}
.ng__bar {
display: flex;
justify-content: center;
gap: 8px;
}
.ng__btn {
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.6;
padding: 4px 16px;
border-radius: 999px;
cursor: pointer;
background: none;
border: 1px solid rgba(255, 247, 230, 0.55);
color: #fff7e6;
transition: background 220ms ease-out, color 220ms ease-out;
}
.ng__btn.is-on {
background: #fff7e6;
border-color: #fff7e6;
color: #17141a;
}
.ng.is-demo .ng__wire {
animation-name: ngWireDraw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ngWireDraw {
0% {
stroke-dashoffset: var(--len);
}
60% {
stroke-dashoffset: 0;
}
78% {
stroke-dashoffset: 0;
}
88% {
stroke-dashoffset: var(--len);
}
100% {
stroke-dashoffset: var(--len);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
gap: 4px;
}
.ng__btn {
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__wire {
animation-name: none !important;
stroke-dashoffset: 0 !important;
}
.ng__btn {
transition: none !important;
}
}var ng = document.getElementById('ng');
var wire = document.getElementById('ngWire');
var nodeB = document.getElementById('ngNodeB');
var dxOut = document.getElementById('ngDx');
var handleOut = document.getElementById('ngH');
var nearBtn = document.getElementById('ngNear');
var farBtn = document.getElementById('ngFar');
var OUT = { x: 140, y: 46 }; // Output port
var BASE_IN = { x: 196, y: 124 }; // 뒤 노드가 「Closer」 자리에 있을 때의 Input port
var SHIFT = { near: 0, far: 66 }; // 뒤 노드를 오른쪽으로 미는 거리
// 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));
}
function pick(kind) {
ng.classList.remove('is-demo');
layout(SHIFT[kind]);
nearBtn.classList.toggle('is-on', kind === 'near');
farBtn.classList.toggle('is-on', kind === 'far');
nearBtn.setAttribute('aria-pressed', String(kind === 'near'));
farBtn.setAttribute('aria-pressed', String(kind === 'far'));
}
nearBtn.addEventListener('click', function () { pick('near'); });
farBtn.addEventListener('click', function () { pick('far'); });
layout(SHIFT.far);import { useEffect, useRef, useState } from "react";
import styles from "./BezierWire.module.scss";
export type BezierWireProps = { duration?: number; easing?: string; color?: string };
const OUT = { x: 140, y: 46 }; // 보내는 점
const BASE_IN = { x: 196, y: 124 }; // 뒤 노드가 「가깝게」 자리에 있을 때의 받는 점
const SHIFT = { near: 0, far: 66 }; // 뒤 노드를 오른쪽으로 미는 거리
// 3차 베지어 — 손잡이는 두 점의 가로 거리의 절반, 최소 40.
// 손잡이가 두 점에서 수평으로 뻗으므로 선은 항상 포트 옆구리로 빠진다.
function wirePath(x1: number, y1: number, x2: number, y2: number): { d: string; handle: number } {
const 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 };
}
export default function BezierWire({
duration = 2000,
easing = "ease-out",
color = "#ffd23f",
}: BezierWireProps) {
const [demo, setDemo] = useState(true);
const [kind, setKind] = useState("far");
const [len, setLen] = useState(0);
const wireRef = useRef<SVGPathElement>(null);
const shift = kind === "far" ? SHIFT.far : SHIFT.near;
const inX = BASE_IN.x + shift;
const wire = wirePath(OUT.x, OUT.y, inX, BASE_IN.y);
useEffect(() => {
const el = wireRef.current;
if (el) setLen(el.getTotalLength()); // 선 길이를 재서 점선 한 칸으로 만든다
}, [wire.d]);
const pick = (next: string) => { setDemo(false); setKind(next); };
return (
<main className={styles.stage}>
<div className={styles.ng}>
<svg className={styles.svg} viewBox="0 0 400 180" role="group" aria-label="두 노드를 잇는 곡선 한 줄">
<path
ref={wireRef}
className={styles.wire}
fill="none"
d={wire.d}
style={{
stroke: color,
"--len": len,
...(demo
? {
animationName: "ngWireDraw",
animationDuration: `${duration}ms`,
animationTimingFunction: easing,
animationIterationCount: "infinite",
animationFillMode: "backwards",
}
: {}),
}}
/>
<g className={styles.node}>
<rect x="12" y="20" width="128" height="52" rx="12"/>
<svg x="26" y="36" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M216,80c0,26.51-39.4,48-88,48S40,106.51,40,80s39.4-48,88-48S216,53.49,216,80Z" opacity="0.2"/><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg>
<text className={styles.nm} x="54" y="46">고객 목록</text>
<circle className={styles.portOut} cx="140" cy="46" r="6"/>
</g>
<g className={styles.node} transform={`translate(${shift},0)`}>
<rect x="196" y="98" width="120" height="52" rx="12"/>
<svg x="210" y="114" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg>
<text className={styles.nm} x="238" y="124">메일 보내기</text>
<circle className={styles.portIn} cx="196" cy="124" r="6"/>
</g>
<text className={styles.cap} x="14" y="170">
<tspan className={styles.capk}>가로 거리</tspan>
<tspan className={styles.capv} dx="8">{inX - OUT.x}</tspan>
<tspan className={styles.capk} dx="14">손잡이</tspan>
<tspan className={styles.capv} dx="8">{Math.round(wire.handle)}</tspan>
</text>
</svg>
<div className={styles.bar}>
<button
type="button"
className={`${styles.btn} ${kind === "near" ? styles.isOn : ""}`}
aria-pressed={kind === "near"}
onClick={() => pick("near")}
>가깝게</button>
<button
type="button"
className={`${styles.btn} ${kind === "far" ? styles.isOn : ""}`}
aria-pressed={kind === "far"}
onClick={() => pick("far")}
>멀게</button>
</div>
</div>
</main>
);
}02 · Curved wire between two ports — Measured area 0.802% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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
What moves
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<svg class="ng__svg" id="ngSvg" viewBox="0 0 400 176" role="group" aria-label="Two nodes wired by dragging">
<!-- 확정된 연결선 -->
<path class="ng__wire" id="ngWire" fill="none" d="M140,53 C194,53 194,123 248,123"/>
<!-- 끌고 있는 동안의 임시 선 -->
<path class="ng__temp" id="ngTemp" fill="none" d="M140,53 C194,53 194,123 248,123"/>
<g class="ng__node">
<rect x="16" y="26" width="124" height="54" rx="12"/>
<svg x="30" y="43" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M200,56H56A24,24,0,0,0,32,80V192a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V80A24,24,0,0,0,200,56ZM164,184H92a20,20,0,0,1,0-40h72a20,20,0,0,1,0,40Z" opacity="0.2"/><path d="M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Zm4,28H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24Z"/></svg>
<text class="ng__nm" x="58" y="53">AI recap</text>
</g>
<g class="ng__node">
<rect x="248" y="96" width="124" height="54" rx="12"/>
<svg x="262" y="113" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg>
<text class="ng__nm" x="288" y="123">Send alert</text>
</g>
<circle class="ng__port ng__port--out" id="ngOut" cx="140" cy="53" r="7" role="button" tabindex="0" aria-label="Output port"/>
<circle class="ng__port ng__port--in" id="ngIn" cx="248" cy="123" r="7" role="button" tabindex="0" aria-label="Input port"/>
<!-- 미리보기 루프의 가짜 포인터 -->
<g class="ng__cursor" aria-hidden="true">
<svg x="0" y="0" width="26" height="26" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,201,201,213.66a8,8,0,0,1-11.31,0l-51.31-51.31a8,8,0,0,0-13,2.46l-17.82,46.41a8,8,0,0,1-14.85-.71L40.41,50.44a8,8,0,0,1,10-10L210.51,92.68a8,8,0,0,1,.71,14.85l-46.41,17.82a8,8,0,0,0-2.46,13l51.31,51.31A8,8,0,0,1,213.66,201Z" opacity="0.2"/><path d="M168,132.69,214.08,115l.33-.13A16,16,0,0,0,213,85.07L52.92,32.8A15.95,15.95,0,0,0,32.8,52.92L85.07,213a15.82,15.82,0,0,0,14.41,11l.78,0a15.84,15.84,0,0,0,14.61-9.59l.13-.33L132.69,168,184,219.31a16,16,0,0,0,22.63,0l12.68-12.68a16,16,0,0,0,0-22.63ZM195.31,208,144,156.69a16,16,0,0,0-26,4.93c0,.11-.09.22-.13.32l-17.65,46L48,48l159.85,52.2-45.95,17.64-.32.13a16,16,0,0,0-4.93,26h0L208,195.31Z"/></svg>
</g>
<text class="ng__hint" id="ngHint" x="200" y="168" text-anchor="middle" aria-live="polite">Drag from a dot to connect</text>
</svg>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ng { box-sizing: border-box; width: min(100%, 432px); }
.ng__svg { display: block; width: 100%; height: auto; touch-action: none; }
.ng__node rect { fill: $subject-ink; }
.ng__node .ng__ico { color: $subject-cream; }
.ng__nm { font-weight: 800; font-size: 13px; fill: $subject-cream; dominant-baseline: central; }
// 포트 — SVG 도형은 transform-origin 기준이 뷰박스 원점이라 fill-box 로 바꿔야 제자리에서 커진다
.ng__port {
stroke: $subject-cream; stroke-width: 2; cursor: crosshair;
transform-box: fill-box; transform-origin: 50% 50%;
transition: transform $dur-quick $easing;
}
.ng__port--out { fill: $stage-orange; }
.ng__port--in { fill: $subject-blue; }
.ng__port:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ng__port--in.is-near,
.ng__port--out.is-armed { transform: scale(1.7); }
.ng__wire { stroke: $color; stroke-width: 6; stroke-linecap: round; opacity: 0; pointer-events: none; }
.ng__temp { stroke: rgba(23, 20, 26, .55); stroke-width: 4; stroke-linecap: round; opacity: 0; pointer-events: none; }
.ng__cursor { opacity: 0; color: $subject-ink; pointer-events: none; }
// 포인터가 어두운 노드 위로 올라가면 먹색끼리 겹쳐 사라진다 — 크림 테두리를 먼저 칠해 띄운다
.ng__cursor path { stroke: $subject-cream; stroke-width: 16; stroke-linejoin: round; paint-order: stroke; }
.ng__hint { font-weight: 700; font-size: 13px; fill: rgba(23, 20, 26, .82); }
// ── 미리보기 루프: 가짜 포인터가 보내는 점 → 받는 점으로 이어 달리고,
// 58% 에 받는 점이 부풀며 스냅, 61% 에 굵은 선으로 컷, 87% 안에 처음으로 돌아온다.
// 포인터는 안 보이는 동안(78~79%) 출발점으로 되돌아간다 — 화면에 텔레포트가 안 보인다
.ng.is-demo .ng__cursor {
animation-name: ngCursorMove; animation-duration: $duration;
animation-timing-function: linear; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__temp {
stroke-dasharray: var(--len);
animation-name: ngTempDraw; animation-duration: $duration;
animation-timing-function: linear; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__wire {
animation-name: ngCommit; animation-duration: $duration;
animation-timing-function: steps(1, end); animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__port--in {
animation-name: ngSnap; animation-duration: $duration;
animation-timing-function: $ease-pop; animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 포인터 좌표는 3차 베지어 위의 t = 0 / .25 / .5 / .75 / 1 지점에서 아이콘 끝(4,4)만큼 뺀 값
@keyframes ngCursorMove {
0% { opacity: 0; transform: translate(136px, 49px); }
6% { opacity: 1; transform: translate(136px, 49px); }
8% { opacity: 1; transform: translate(136px, 49px); }
20.5% { opacity: 1; transform: translate(168px, 60px); }
33% { opacity: 1; transform: translate(190px, 84px); }
45.5% { opacity: 1; transform: translate(212px, 108px); }
58% { opacity: 1; transform: translate(244px, 119px); }
70% { opacity: 1; transform: translate(244px, 119px); }
78% { opacity: 0; transform: translate(244px, 119px); }
79% { opacity: 0; transform: translate(136px, 49px); }
100% { opacity: 0; transform: translate(136px, 49px); }
}
@keyframes ngTempDraw {
0%, 7% { opacity: 0; stroke-dashoffset: var(--len); }
8% { opacity: 1; stroke-dashoffset: var(--len); }
58% { opacity: 1; stroke-dashoffset: 0; }
61% { opacity: 1; stroke-dashoffset: 0; }
62% { opacity: 0; stroke-dashoffset: 0; }
100% { opacity: 0; stroke-dashoffset: 0; }
}
@keyframes ngCommit {
0%, 60% { opacity: 0; }
61% { opacity: 1; }
86% { opacity: 1; }
87% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ngSnap {
0%, 52% { transform: scale(1); }
60% { transform: scale(1.7); }
72% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__cursor,
.ng.is-demo .ng__temp,
.ng.is-demo .ng__wire,
.ng.is-demo .ng__port--in { animation-name: none !important; }
.ng.is-demo .ng__wire { opacity: 1 !important; }
.ng.is-demo .ng__temp { opacity: 0 !important; }
.ng__port { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
box-sizing: border-box;
width: min(100%, 432px);
}
.ng__svg {
display: block;
width: 100%;
height: auto;
touch-action: none;
}
.ng__node rect {
fill: #17141a;
}
.ng__node .ng__ico {
color: #fff7e6;
}
.ng__nm {
font-weight: 800;
font-size: 13px;
fill: #fff7e6;
dominant-baseline: central;
}
.ng__port {
stroke: #fff7e6;
stroke-width: 2;
cursor: crosshair;
transform-box: fill-box;
transform-origin: 50% 50%;
transition: transform 220ms ease-out;
}
.ng__port--out {
fill: #ff4d1f;
}
.ng__port--in {
fill: #2f6df6;
}
.ng__port:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ng__port--in.is-near,
.ng__port--out.is-armed {
transform: scale(1.7);
}
.ng__wire {
stroke: #17141a;
stroke-width: 6;
stroke-linecap: round;
opacity: 0;
pointer-events: none;
}
.ng__temp {
stroke: rgba(23, 20, 26, 0.55);
stroke-width: 4;
stroke-linecap: round;
opacity: 0;
pointer-events: none;
}
.ng__cursor {
opacity: 0;
color: #17141a;
pointer-events: none;
}
.ng__cursor path {
stroke: #fff7e6;
stroke-width: 16;
stroke-linejoin: round;
paint-order: stroke;
}
.ng__hint {
font-weight: 700;
font-size: 13px;
fill: rgba(23, 20, 26, 0.82);
}
.ng.is-demo .ng__cursor {
animation-name: ngCursorMove;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__temp {
stroke-dasharray: var(--len);
animation-name: ngTempDraw;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__wire {
animation-name: ngCommit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__port--in {
animation-name: ngSnap;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ngCursorMove {
0% {
opacity: 0;
transform: translate(136px, 49px);
}
6% {
opacity: 1;
transform: translate(136px, 49px);
}
8% {
opacity: 1;
transform: translate(136px, 49px);
}
20.5% {
opacity: 1;
transform: translate(168px, 60px);
}
33% {
opacity: 1;
transform: translate(190px, 84px);
}
45.5% {
opacity: 1;
transform: translate(212px, 108px);
}
58% {
opacity: 1;
transform: translate(244px, 119px);
}
70% {
opacity: 1;
transform: translate(244px, 119px);
}
78% {
opacity: 0;
transform: translate(244px, 119px);
}
79% {
opacity: 0;
transform: translate(136px, 49px);
}
100% {
opacity: 0;
transform: translate(136px, 49px);
}
}
@keyframes ngTempDraw {
0%, 7% {
opacity: 0;
stroke-dashoffset: var(--len);
}
8% {
opacity: 1;
stroke-dashoffset: var(--len);
}
58% {
opacity: 1;
stroke-dashoffset: 0;
}
61% {
opacity: 1;
stroke-dashoffset: 0;
}
62% {
opacity: 0;
stroke-dashoffset: 0;
}
100% {
opacity: 0;
stroke-dashoffset: 0;
}
}
@keyframes ngCommit {
0%, 60% {
opacity: 0;
}
61% {
opacity: 1;
}
86% {
opacity: 1;
}
87% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ngSnap {
0%, 52% {
transform: scale(1);
}
60% {
transform: scale(1.7);
}
72% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__cursor,
.ng.is-demo .ng__temp,
.ng.is-demo .ng__wire,
.ng.is-demo .ng__port--in {
animation-name: none !important;
}
.ng.is-demo .ng__wire {
opacity: 1 !important;
}
.ng.is-demo .ng__temp {
opacity: 0 !important;
}
.ng__port {
transition: none !important;
}
}var VIEW_W = 400; // viewBox 가로 — 화면 px 를 이 좌표계로 되돌릴 때 쓴다
var SNAP = 28; // Input port 중심에서 이 거리 안이면 달라붙는다
var ng = document.getElementById('ng');
var svg = document.getElementById('ngSvg');
var temp = document.getElementById('ngTemp');
var wire = document.getElementById('ngWire');
var outPort = document.getElementById('ngOut');
var inPort = document.getElementById('ngIn');
var hint = document.getElementById('ngHint');
var OUT = { x: 140, y: 53 };
var IN = { x: 248, y: 123 };
var dragging = false, snapped = false, armed = false;
// 화면 좌표 → viewBox 좌표. 무대가 줄어도 Zoom level 하나로 되돌린다
function toLocal(e) {
var box = svg.getBoundingClientRect();
var k = VIEW_W / box.width;
return { x: (e.clientX - box.left) * k, y: (e.clientY - box.top) * k };
}
// 손잡이는 두 점의 가로 거리의 절반, 최소 40
function wirePath(x1, y1, x2, y2) {
var h = Math.max(40, Math.abs(x2 - x1) * 0.5);
return 'M' + x1 + ',' + y1 + ' C' + (x1 + h) + ',' + y1 + ' ' + (x2 - h) + ',' + y2 + ' ' + x2 + ',' + y2;
}
function wake() { ng.classList.remove('is-demo'); }
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);
}
function commit() {
wire.setAttribute('d', wirePath(OUT.x, OUT.y, IN.x, IN.y));
wire.style.opacity = '1';
hint.textContent = 'Connected';
}
function stop() {
dragging = false; armed = false;
temp.style.opacity = '0';
inPort.classList.remove('is-near');
outPort.classList.remove('is-armed');
}
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('pointermove', function (e) { if (dragging) drawTo(toLocal(e)); });
outPort.addEventListener('pointerup', function () {
if (!dragging) return;
if (snapped) commit();
stop();
});
outPort.addEventListener('pointercancel', stop);
// 키보드: 보내는 점에서 Enter → 받는 점에서 Enter
outPort.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault(); wake();
armed = true;
outPort.classList.add('is-armed');
inPort.classList.add('is-near');
inPort.focus({ preventScroll: true });
});
inPort.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (armed) { commit(); stop(); }
});
outPort.addEventListener('focus', wake);
inPort.addEventListener('focus', wake);
// 미리보기 루프가 쓸 선 길이 — 점선 한 칸이 선 전체가 되게 한다
temp.style.setProperty('--len', temp.getTotalLength());import { useEffect, useRef, useState } from "react";
import styles from "./DragConnect.module.scss";
export type DragConnectProps = { duration?: number; easing?: string; color?: string };
// ambient 에 React 이벤트 제네릭이 없다 — 쓰는 필드만 적은 로컬 타입
type PointerEvt = {
clientX: number; clientY: number; pointerId: number;
currentTarget: { setPointerCapture(id: number): void };
preventDefault(): void;
};
type KeyEvt = { key: string; preventDefault(): void };
type Point = { x: number; y: number };
const VIEW_W = 400; // viewBox 가로 — 화면 px 를 이 좌표계로 되돌릴 때 쓴다
const SNAP = 28; // 받는 점 중심에서 이 거리 안이면 달라붙는다
const OUT: Point = { x: 140, y: 53 };
const IN: Point = { x: 248, y: 123 };
// 손잡이는 두 점의 가로 거리의 절반, 최소 40
function wirePath(x1: number, y1: number, x2: number, y2: number): string {
const h = Math.max(40, Math.abs(x2 - x1) * 0.5);
return `M${x1},${y1} C${x1 + h},${y1} ${x2 - h},${y2} ${x2},${y2}`;
}
const FULL = wirePath(OUT.x, OUT.y, IN.x, IN.y);
export default function DragConnect({
duration = 2000,
easing = "linear",
color = "#17141a",
}: DragConnectProps) {
const [demo, setDemo] = useState(true);
const [temp, setTemp] = useState("");
const [near, setNear] = useState(false);
const [armed, setArmed] = useState(false);
const [connected, setConnected] = useState(false);
const [len, setLen] = useState(0);
const svgRef = useRef<SVGSVGElement>(null);
const tempRef = useRef<SVGPathElement>(null);
const inRef = useRef<SVGCircleElement>(null);
const dragRef = useRef<boolean>(false);
const snapRef = useRef<boolean>(false);
useEffect(() => {
const el = tempRef.current;
if (el) setLen(el.getTotalLength()); // 미리보기 루프가 쓸 선 길이
}, []);
// 화면 좌표 → viewBox 좌표. 무대가 줄어도 배율 하나로 되돌린다
const local = (e: PointerEvt): Point => {
const el = svgRef.current;
if (!el) return { x: 0, y: 0 };
const box = el.getBoundingClientRect();
const k = VIEW_W / box.width;
return { x: (e.clientX - box.left) * k, y: (e.clientY - box.top) * k };
};
const drawTo = (p: Point) => {
const gap = Math.sqrt((p.x - IN.x) * (p.x - IN.x) + (p.y - IN.y) * (p.y - IN.y));
const hit = gap < SNAP;
snapRef.current = hit;
setNear(hit);
const end = hit ? IN : p;
setTemp(wirePath(OUT.x, OUT.y, end.x, end.y));
};
const stop = () => {
dragRef.current = false;
setTemp(""); setNear(false); setArmed(false);
};
const onDown = (e: PointerEvt) => {
e.preventDefault();
setDemo(false);
e.currentTarget.setPointerCapture(e.pointerId); // 손가락이 포트를 벗어나도 이 점이 계속 받는다
dragRef.current = true; snapRef.current = false;
drawTo(local(e));
};
const onMove = (e: PointerEvt) => { if (dragRef.current) drawTo(local(e)); };
const onUp = () => {
if (!dragRef.current) return;
if (snapRef.current) setConnected(true);
stop();
};
const onOutKey = (e: KeyEvt) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
setDemo(false); setArmed(true); setNear(true);
const el = inRef.current;
if (el) el.focus({ preventScroll: true });
};
const onInKey = (e: KeyEvt) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
if (armed) { setConnected(true); stop(); }
};
const loop = (name: string, timing: string) => (demo
? {
animationName: name,
animationDuration: `${duration}ms`,
animationTimingFunction: timing,
animationIterationCount: "infinite",
animationFillMode: "backwards",
}
: {});
return (
<main className={styles.stage}>
<div className={styles.ng}>
<svg ref={svgRef} className={styles.svg} viewBox="0 0 400 176" role="group" aria-label="두 노드를 끌어서 잇는 화면">
<path
className={styles.wire}
fill="none"
d={FULL}
style={{ stroke: color, opacity: demo ? undefined : (connected ? 1 : 0), ...loop("ngCommit", "steps(1, end)") }}
/>
<path
ref={tempRef}
className={styles.temp}
fill="none"
d={demo ? FULL : (temp || FULL)}
style={{
opacity: demo ? undefined : (temp ? 1 : 0),
...(demo ? { strokeDasharray: len || undefined, "--len": len } : {}),
...loop("ngTempDraw", "linear"),
}}
/>
<g className={styles.node}>
<rect x="16" y="26" width="124" height="54" rx="12"/>
<svg x="30" y="43" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M200,56H56A24,24,0,0,0,32,80V192a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V80A24,24,0,0,0,200,56ZM164,184H92a20,20,0,0,1,0-40h72a20,20,0,0,1,0,40Z" opacity="0.2"/><path d="M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Zm4,28H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24Z"/></svg>
<text className={styles.nm} x="58" y="53">AI 요약</text>
</g>
<g className={styles.node}>
<rect x="248" y="96" width="124" height="54" rx="12"/>
<svg x="262" y="113" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg>
<text className={styles.nm} x="288" y="123">알림 보내기</text>
</g>
<circle
className={`${styles.port} ${styles.portOut} ${armed ? styles.isArmed : ""}`}
cx="140" cy="53" r="7" role="button" tabIndex={0} aria-label="보내는 점"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={stop}
onKeyDown={onOutKey}
onFocus={() => setDemo(false)}
/>
<circle
ref={inRef}
className={`${styles.port} ${styles.portIn} ${near ? styles.isNear : ""}`}
cx="248" cy="123" r="7" role="button" tabIndex={0} aria-label="받는 점"
style={loop("ngSnap", "cubic-bezier(.2, 1.4, .4, 1)")}
onKeyDown={onInKey}
onFocus={() => setDemo(false)}
/>
<g className={styles.cursor} aria-hidden="true" style={loop("ngCursorMove", easing)}>
<svg x="0" y="0" width="26" height="26" viewBox="0 0 256 256" fill="currentColor"><path d="M213.66,201,201,213.66a8,8,0,0,1-11.31,0l-51.31-51.31a8,8,0,0,0-13,2.46l-17.82,46.41a8,8,0,0,1-14.85-.71L40.41,50.44a8,8,0,0,1,10-10L210.51,92.68a8,8,0,0,1,.71,14.85l-46.41,17.82a8,8,0,0,0-2.46,13l51.31,51.31A8,8,0,0,1,213.66,201Z" opacity="0.2"/><path d="M168,132.69,214.08,115l.33-.13A16,16,0,0,0,213,85.07L52.92,32.8A15.95,15.95,0,0,0,32.8,52.92L85.07,213a15.82,15.82,0,0,0,14.41,11l.78,0a15.84,15.84,0,0,0,14.61-9.59l.13-.33L132.69,168,184,219.31a16,16,0,0,0,22.63,0l12.68-12.68a16,16,0,0,0,0-22.63ZM195.31,208,144,156.69a16,16,0,0,0-26,4.93c0,.11-.09.22-.13.32l-17.65,46L48,48l159.85,52.2-45.95,17.64-.32.13a16,16,0,0,0-4.93,26h0L208,195.31Z"/></svg>
</g>
<text className={styles.hint} x="200" y="168" textAnchor="middle" aria-live="polite">
{connected ? "이어졌습니다" : "점을 끌어 이어 보세요"}
</text>
</svg>
</div>
</main>
);
}03 · Drag from a port to connect — Measured area 2.532% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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
What you see
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<svg class="ng__canvas" viewBox="0 0 400 170" role="group" aria-label="Two wires joining three nodes">
<!-- 위 연결선: 루프 내내 그대로 둔다 (끊겨도 화면이 비지 않게) -->
<g class="ng__wirewrap ng__wirewrap--t">
<path class="ng__wire" d="M122,85 C149,85 174.5,73.25 200,61.5"/>
<path class="ng__wire" d="M278,38 C251,38 225.5,49.75 200,61.5"/>
<path class="ng__hit" d="M122,85 C176,85 224,38 278,38"/>
<g class="ng__cutpos" transform="translate(200 61.5)">
<g class="ng__cut" role="button" tabindex="0" aria-pressed="false" aria-label="Cut wire">
<circle class="ng__cutbg" r="15"/>
<svg class="ng__cutico" x="-9" y="-9" width="18" height="18" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M40.2,95.8a28,28,0,1,1,39.6,0A28,28,0,0,1,40.2,95.8Zm0,64.4a28,28,0,1,0,39.6,0A28,28,0,0,0,40.2,160.2Z" opacity="0.2"/><path d="M157.73,113.13A8,8,0,0,1,159.82,102L227.48,55.7a8,8,0,0,1,9,13.21l-67.67,46.3a7.92,7.92,0,0,1-4.51,1.4A8,8,0,0,1,157.73,113.13Zm80.87,85.09a8,8,0,0,1-11.12,2.08L136,137.7,93.49,166.78a36,36,0,1,1-9-13.19L121.83,128,84.44,102.41a35.86,35.86,0,1,1,9-13.19l143,97.87A8,8,0,0,1,238.6,198.22ZM80,180a20,20,0,1,0-5.86,14.14A19.85,19.85,0,0,0,80,180ZM74.14,90.13a20,20,0,1,0-28.28,0A19.85,19.85,0,0,0,74.14,90.13Z"/></svg>
</g>
</g>
</g>
<!-- 아래 연결선: 미리보기 루프에서 끊겼다 다시 이어진다 -->
<g class="ng__wirewrap ng__wirewrap--b">
<path class="ng__wire" d="M122,85 C149,85 174.5,96.75 200,108.5"/>
<path class="ng__wire" d="M278,132 C251,132 225.5,120.25 200,108.5"/>
<path class="ng__hit" d="M122,85 C176,85 224,132 278,132"/>
<g class="ng__cutpos" transform="translate(200 108.5)">
<g class="ng__cut" role="button" tabindex="0" aria-pressed="false" aria-label="Cut wire">
<circle class="ng__cutbg" r="15"/>
<svg class="ng__cutico" x="-9" y="-9" width="18" height="18" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M40.2,95.8a28,28,0,1,1,39.6,0A28,28,0,0,1,40.2,95.8Zm0,64.4a28,28,0,1,0,39.6,0A28,28,0,0,0,40.2,160.2Z" opacity="0.2"/><path d="M157.73,113.13A8,8,0,0,1,159.82,102L227.48,55.7a8,8,0,0,1,9,13.21l-67.67,46.3a7.92,7.92,0,0,1-4.51,1.4A8,8,0,0,1,157.73,113.13Zm80.87,85.09a8,8,0,0,1-11.12,2.08L136,137.7,93.49,166.78a36,36,0,1,1-9-13.19L121.83,128,84.44,102.41a35.86,35.86,0,1,1,9-13.19l143,97.87A8,8,0,0,1,238.6,198.22ZM80,180a20,20,0,1,0-5.86,14.14A19.85,19.85,0,0,0,80,180ZM74.14,90.13a20,20,0,1,0-28.28,0A19.85,19.85,0,0,0,74.14,90.13Z"/></svg>
</g>
</g>
</g>
<g class="ng__node ng__node--a">
<rect class="ng__box" x="14" y="59" width="108" height="52" rx="12"/>
<svg class="ng__ico" x="58" y="68" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M96,240l16-80L48,136,160,16,144,96l64,24Z" opacity="0.2"/><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z"/></svg>
<text class="ng__label" x="68" y="103" text-anchor="middle">Webhook</text>
<circle class="ng__port" cx="122" cy="85" r="4.5"/>
</g>
<g class="ng__node ng__node--t">
<rect class="ng__box" x="278" y="12" width="108" height="52" rx="12"/>
<svg class="ng__ico" x="322" y="21" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2"/><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z"/></svg>
<text class="ng__label" x="332" y="56" text-anchor="middle">Send email</text>
<circle class="ng__port" cx="278" cy="38" r="4.5"/>
</g>
<g class="ng__node ng__node--c">
<rect class="ng__box" x="278" y="106" width="108" height="52" rx="12"/>
<svg class="ng__ico" x="322" y="115" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M216,80c0,26.51-39.4,48-88,48S40,106.51,40,80s39.4-48,88-48S216,53.49,216,80Z" opacity="0.2"/><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z"/></svg>
<text class="ng__label" x="332" y="150" text-anchor="middle">Save to DB</text>
<circle class="ng__port" cx="278" cy="132" r="4.5"/>
</g>
</svg>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 셋만 바꾼다
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
// 반쪽 곡선 한 줄의 실제 길이는 82.14 (3차 베지에 수치적분) — 1 만 여유를 준 83 으로 잡는다.
// dasharray 를 길이보다 크게 잡으면 dashoffset 이 길이를 넘는 구간이 무동작이 되므로 딱 붙여 쓴다
$wire-len: 83;
.ng { width: min(100%, 400px); }
.ng__canvas { display: block; width: 100%; height: auto; }
// ── 노드
.ng__box { fill: $subject-cream; }
.ng__ico { color: $subject-ink; opacity: .72; }
.ng__label { font-weight: 700; font-size: 13px; fill: $subject-ink; }
.ng__port { fill: $subject-cream; stroke: $stage-orange; stroke-width: 2; }
// ── 연결선: 가운데(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; }
// ── 가위 단추: 자기 중심에서 커진다 (transform-box: fill-box 가 있어야 50% 50% 가 제 bbox 중심)
.ng__cut {
transform-box: fill-box; transform-origin: 50% 50%;
opacity: 0; transform: scale(.6); pointer-events: none; cursor: pointer;
transition: opacity $dur-quick $easing, transform $dur-quick $ease-pop;
}
.ng__wirewrap:hover .ng__cut,
.ng__wirewrap:focus-within .ng__cut,
.ng__wirewrap.is-cut .ng__cut { opacity: 1; transform: scale(1); pointer-events: auto; }
.ng__cutbg { fill: $subject-ink; }
.ng__cutico { color: $subject-cream; }
// SVG <g role="button"> 는 마우스 클릭에도 UA 포커스 사각형이 뜬다 (285 Playwright 실측) — 링은 우리가 그린다
.ng__cut:focus { outline: none; }
.ng__cut:focus-visible .ng__cutbg { stroke: $subject-cream; stroke-width: 3; }
// ── 미리보기 루프 (2s): 아래 선만 가위가 떠올랐다 → 끊겼다 → 88% 안에 다시 이어진다.
// 위 선과 노드 세 장은 루프 내내 그대로라 대표 이미지가 절대 비지 않는다
.ng.is-demo .ng__wirewrap--b .ng__wire {
animation-name: ngCut; animation-duration: 2s; animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ng.is-demo .ng__wirewrap--b .ng__cut {
animation-name: ngCutBtn; animation-duration: 2s; animation-timing-function: $easing; animation-iteration-count: infinite;
}
// 선이 끊긴 동안 받는 점만 비워 둔다. 카드째 어둡게 하면 poster 뽑기가 「끊는 장면」이 아니라
// 「되살아나는 장면」으로 넘어간다 — 면적이 같아 tie 가 나므로 대표 이미지가 흔들린다 (285 실측)
.ng.is-demo .ng__node--c .ng__port {
animation-name: ngPortOff; animation-duration: 2s; animation-timing-function: steps(1, end); animation-iteration-count: infinite;
}
@keyframes ngCut {
0%, 34% { stroke-dashoffset: 0; }
56%, 68% { stroke-dashoffset: $wire-len; }
88%, 100% { stroke-dashoffset: 0; }
}
@keyframes ngCutBtn {
0% { opacity: 0; transform: scale(.6) rotate(-18deg); }
14%, 30% { opacity: 1; transform: scale(1) rotate(0deg); }
34% { opacity: 1; transform: scale(.82) rotate(-8deg); }
40%, 60% { opacity: 1; transform: scale(1) rotate(0deg); }
// 단추는 선이 다시 이어지기(68%) 전에 사라진다 — 반투명 단추가 선 위에 겹친 유령 프레임을 막는다
68%, 100% { opacity: 0; transform: scale(.6) rotate(-18deg); }
}
// 상태 교체는 컷 — 크로스페이드 중간 장(반투명 유령)을 만들지 않는다
@keyframes ngPortOff {
0%, 55.9% { fill: $subject-cream; }
56%, 87.9% { fill: $stage-orange; }
88%, 100% { fill: $subject-cream; }
}
// ── 320×200 폰 (BD9): 글자만 줄인다. 여기서 간격을 올리면 세로가 넘친다
@media (max-width: 340px), (max-height: 220px) {
.ng__label { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ng__wire,
.ng__cut,
.ng__port { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
width: min(100%, 400px);
}
.ng__canvas {
display: block;
width: 100%;
height: auto;
}
.ng__box {
fill: #fff7e6;
}
.ng__ico {
color: #17141a;
opacity: 0.72;
}
.ng__label {
font-weight: 700;
font-size: 13px;
fill: #17141a;
}
.ng__port {
fill: #fff7e6;
stroke: #ff4d1f;
stroke-width: 2;
}
.ng__wire {
fill: none;
stroke: #fff7e6;
stroke-width: 3;
stroke-linecap: round;
pointer-events: none;
stroke-dasharray: 83;
stroke-dashoffset: 0;
transition: stroke-dashoffset 300ms cubic-bezier(0.2, 0.8, 0.2, 1), stroke-width 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.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: 83;
}
.ng__cut {
transform-box: fill-box;
transform-origin: 50% 50%;
opacity: 0;
transform: scale(0.6);
pointer-events: none;
cursor: pointer;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ng__wirewrap:hover .ng__cut,
.ng__wirewrap:focus-within .ng__cut,
.ng__wirewrap.is-cut .ng__cut {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.ng__cutbg {
fill: #17141a;
}
.ng__cutico {
color: #fff7e6;
}
.ng__cut:focus {
outline: none;
}
.ng__cut:focus-visible .ng__cutbg {
stroke: #fff7e6;
stroke-width: 3;
}
.ng.is-demo .ng__wirewrap--b .ng__wire {
animation-name: ngCut;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ng.is-demo .ng__wirewrap--b .ng__cut {
animation-name: ngCutBtn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ng.is-demo .ng__node--c .ng__port {
animation-name: ngPortOff;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ngCut {
0%, 34% {
stroke-dashoffset: 0;
}
56%, 68% {
stroke-dashoffset: 83;
}
88%, 100% {
stroke-dashoffset: 0;
}
}
@keyframes ngCutBtn {
0% {
opacity: 0;
transform: scale(0.6) rotate(-18deg);
}
14%, 30% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
34% {
opacity: 1;
transform: scale(0.82) rotate(-8deg);
}
40%, 60% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
68%, 100% {
opacity: 0;
transform: scale(0.6) rotate(-18deg);
}
}
@keyframes ngPortOff {
0%, 55.9% {
fill: #fff7e6;
}
56%, 87.9% {
fill: #ff4d1f;
}
88%, 100% {
fill: #fff7e6;
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng__label {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng__wire,
.ng__cut,
.ng__port {
animation: none !important;
transition: none !important;
}
}var root = document.getElementById('ng');
var cutButtons = document.querySelectorAll('.ng__cut');
function toggleWire(button) {
root.classList.remove('is-demo');
var wrap = button.closest('.ng__wirewrap');
var isCut = wrap.classList.toggle('is-cut');
button.setAttribute('aria-pressed', String(isCut));
button.setAttribute('aria-label', isCut ? 'Reconnect' : 'Cut wire');
}
for (var i = 0; i < cutButtons.length; i++) {
cutButtons[i].addEventListener('click', function () { toggleWire(this); });
cutButtons[i].addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleWire(this); }
});
}import { useState } from "react";
import styles from "./CutWire.module.scss";
export type CutWireProps = { duration?: number; easing?: string; color?: string };
/** ambient 에 React 이벤트 제네릭이 없다 — 파일 안에서 쓰는 만큼만 로컬 타입으로 선언한다 */
type KeyEvt = { key: string; preventDefault: () => void };
const SCISSORS = (
<svg className={styles.cutico} x="-9" y="-9" width="18" height="18" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M40.2,95.8a28,28,0,1,1,39.6,0A28,28,0,0,1,40.2,95.8Zm0,64.4a28,28,0,1,0,39.6,0A28,28,0,0,0,40.2,160.2Z" opacity="0.2" />
<path d="M157.73,113.13A8,8,0,0,1,159.82,102L227.48,55.7a8,8,0,0,1,9,13.21l-67.67,46.3a7.92,7.92,0,0,1-4.51,1.4A8,8,0,0,1,157.73,113.13Zm80.87,85.09a8,8,0,0,1-11.12,2.08L136,137.7,93.49,166.78a36,36,0,1,1-9-13.19L121.83,128,84.44,102.41a35.86,35.86,0,1,1,9-13.19l143,97.87A8,8,0,0,1,238.6,198.22ZM80,180a20,20,0,1,0-5.86,14.14A19.85,19.85,0,0,0,80,180ZM74.14,90.13a20,20,0,1,0-28.28,0A19.85,19.85,0,0,0,74.14,90.13Z" />
</svg>
);
/** 가운데(200, y)에서 노드 쪽으로 뻗는 반쪽 두 개 + 집는 영역이 되는 전체 곡선 */
const WIRES = [
{ id: "t", halfA: "M122,85 C149,85 174.5,73.25 200,61.5", halfB: "M278,38 C251,38 225.5,49.75 200,61.5", hit: "M122,85 C176,85 224,38 278,38", mid: "translate(200 61.5)" },
{ id: "b", halfA: "M122,85 C149,85 174.5,96.75 200,108.5", halfB: "M278,132 C251,132 225.5,120.25 200,108.5", hit: "M122,85 C176,85 224,132 278,132", mid: "translate(200 108.5)" },
];
export default function CutWire({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#fff7e6" }: CutWireProps) {
const [demo, setDemo] = useState(true);
const [cut, setCut] = useState<string[]>([]);
const toggleWire = (id: string) => {
setDemo(false);
setCut((prev) => (prev.indexOf(id) >= 0 ? prev.filter((v) => v !== id) : prev.concat([id])));
};
return (
<main className={styles.stage}>
<div
className={`${styles.ng} ${demo ? styles.isDemo : ""}`}
style={{ "--ng-duration": `${duration}ms`, "--ng-easing": easing, "--ng-color": color }}
>
<svg className={styles.canvas} viewBox="0 0 400 170" role="group" aria-label="노드 세 개를 잇는 연결선 두 개">
{WIRES.map((w) => {
const isCut = cut.indexOf(w.id) >= 0;
return (
<g key={w.id} className={`${styles.wirewrap} ${w.id === "b" ? styles.wireB : ""} ${isCut ? styles.isCut : ""}`}>
<path className={styles.wire} d={w.halfA} />
<path className={styles.wire} d={w.halfB} />
<path className={styles.hit} d={w.hit} />
<g transform={w.mid}>
<g
className={styles.cut}
role="button"
tabIndex={0}
aria-pressed={isCut}
aria-label={isCut ? "다시 잇기" : "연결 끊기"}
onClick={() => toggleWire(w.id)}
onKeyDown={(e: KeyEvt) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleWire(w.id); } }}
>
<circle className={styles.cutbg} r="15" />
{SCISSORS}
</g>
</g>
</g>
);
})}
<g className={styles.node}>
<rect className={styles.box} x="14" y="59" width="108" height="52" rx="12" />
<svg className={styles.ico} x="58" y="68" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M96,240l16-80L48,136,160,16,144,96l64,24Z" opacity="0.2" /><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z" /></svg>
<text className={styles.label} x="68" y="103" textAnchor="middle">웹훅</text>
<circle className={styles.port} cx="122" cy="85" r="4.5" />
</g>
<g className={styles.node}>
<rect className={styles.box} x="278" y="12" width="108" height="52" rx="12" />
<svg className={styles.ico} x="322" y="21" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M223.69,42.18l-58.22,192a8,8,0,0,1-14.92,1.25L108,148,20.58,105.45a8,8,0,0,1,1.25-14.92l192-58.22A8,8,0,0,1,223.69,42.18Z" opacity="0.2" /><path d="M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z" /></svg>
<text className={styles.label} x="332" y="56" textAnchor="middle">메일 보내기</text>
<circle className={styles.port} cx="278" cy="38" r="4.5" />
</g>
<g className={`${styles.node} ${styles.nodeC}`}>
<rect className={styles.box} x="278" y="106" width="108" height="52" rx="12" />
<svg className={styles.ico} x="322" y="115" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M216,80c0,26.51-39.4,48-88,48S40,106.51,40,80s39.4-48,88-48S216,53.49,216,80Z" opacity="0.2" /><path d="M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z" /></svg>
<text className={styles.label} x="332" y="150" textAnchor="middle">DB 저장</text>
<circle className={styles.port} cx="278" cy="132" r="4.5" />
</g>
</svg>
</div>
</main>
);
}04 · Cutting a wire — Measured area 0.899% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
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
What happens on screen
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<svg class="ng__canvas" viewBox="0 0 400 170" role="group" aria-label="A graph showing which branch is live">
<!-- 선 두 개. 아래 갈래만 실행 중이라 색이 다르다 -->
<path class="ng__wire" d="M122,85 C176,85 224,38 278,38"/>
<path class="ng__wire ng__wire--run" d="M122,85 C176,85 224,132 278,132"/>
<!-- 도착 표시: 점이 닿는 0.667s 주기에 맞춰 퍼진다 -->
<circle class="ng__ring" cx="278" cy="132" r="6"/>
<!-- 흐르는 점 셋. offset-path 의 좌표는 위 선의 d 와 **글자 그대로 같은 문자열**이라 좌표계가 어긋날 수 없다 -->
<circle class="ng__dot ng__dot--1" cx="0" cy="0" r="4.5"/>
<circle class="ng__dot ng__dot--2" cx="0" cy="0" r="4.5"/>
<circle class="ng__dot ng__dot--3" cx="0" cy="0" r="4.5"/>
<g class="ng__node">
<rect class="ng__box" x="14" y="59" width="108" height="52" rx="12"/>
<svg class="ng__ico" x="58" y="68" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M96,240l16-80L48,136,160,16,144,96l64,24Z" opacity="0.2"/><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z"/></svg>
<text class="ng__label" x="68" y="103" text-anchor="middle">Form intake</text>
<circle class="ng__port" cx="122" cy="85" r="4.5"/>
</g>
<g class="ng__node">
<rect class="ng__box" x="278" y="12" width="108" height="52" rx="12"/>
<svg class="ng__ico" x="322" y="21" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M221.9,61.38,152,136v58.65a8,8,0,0,1-3.56,6.66l-32,21.33A8,8,0,0,1,104,216V136L34.1,61.38A8,8,0,0,1,40,48H216A8,8,0,0,1,221.9,61.38Z" opacity="0.2"/><path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"/></svg>
<text class="ng__label" x="332" y="56" text-anchor="middle">Filter</text>
<circle class="ng__port" cx="278" cy="38" r="4.5"/>
</g>
<g class="ng__node">
<rect class="ng__box ng__box--run" x="278" y="106" width="108" height="52" rx="12"/>
<svg class="ng__ico" x="322" y="115" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M200,56H56A24,24,0,0,0,32,80V192a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V80A24,24,0,0,0,200,56ZM164,184H92a20,20,0,0,1,0-40h72a20,20,0,0,1,0,40Z" opacity="0.2"/><path d="M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Zm4,28H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24Z"/></svg>
<text class="ng__label" x="332" y="150" text-anchor="middle">AI recap</text>
<circle class="ng__port ng__port--run" cx="278" cy="132" r="4.5"/>
</g>
</svg>
<div class="ng__bar">
<button type="button" class="ng__toggle" id="ngToggle" aria-pressed="false">Pause flow</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 셋만 바꾼다
$duration: $dur-loop;
$easing: linear;
$color: $subject-mint;
// 점 세 개가 2s 를 셋으로 나눠 도착한다 → 도착 표시의 주기이자 지연 (2s 의 약수)
$arrive: 0.6667s;
@include stage($stage-ink);
.ng { width: min(100%, 400px); display: grid; gap: $sp-2; justify-items: center; }
.ng__canvas { display: block; width: 100%; height: auto; }
// ── 노드
.ng__box { fill: $subject-cream; }
.ng__box--run { stroke: $color; stroke-width: 2; }
.ng__ico { color: $subject-ink; opacity: .72; }
.ng__label { font-weight: 700; font-size: 13px; fill: $subject-ink; }
.ng__port { fill: $subject-cream; stroke: $stage-ink; stroke-width: 2; }
.ng__port--run { fill: $color; }
// ── 선: 실행 중인 갈래만 색이 다르다
.ng__wire { fill: none; stroke: rgba(255, 247, 230, .3); stroke-width: 2.5; stroke-linecap: round; }
.ng__wire--run { stroke: $color; stroke-width: 3; }
// ── 흐르는 점: 길은 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;
}
// 지연은 **음수** 다. 양수 지연(0.4s·0.8s)을 주면 2초 캡처 창의 마지막 장에서 점이 길 중간에 서 있다가
// 다음 바퀴 0프레임에 0% 로 순간이동한다 — 양수 지연은 주기의 배수일 때만 루프가 닫힌다 (285 실측)
.ng__dot--2 { animation-delay: -0.6667s; }
.ng__dot--3 { animation-delay: -1.3333s; }
// ── 도착 표시: 점이 닿는 주기(2s / 3)에 맞춰 퍼진다.
// 지연이 걸려 있으므로 backwards 가 없으면 0~0.667s 구간에 작성 상태(그냥 놓인 원)가 그대로 찍힌다
.ng__ring {
fill: none; stroke: $color; stroke-width: 2;
transform-box: fill-box; transform-origin: 50% 50%;
animation-name: ngArrive; animation-duration: $arrive; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-delay: $arrive; animation-fill-mode: backwards;
}
@keyframes ngFlow {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}
@keyframes ngArrive {
0% { transform: scale(.5); opacity: .9; }
100% { transform: scale(2.4); opacity: 0; }
}
// ── 멈춤: 선과 노드는 그대로 두고 흐름만 끈다
.ng.is-paused .ng__dot,
.ng.is-paused .ng__ring { animation-name: none; opacity: 0; }
.ng.is-paused .ng__wire--run { stroke: rgba(255, 247, 230, .3); stroke-width: 2.5; }
.ng.is-paused .ng__box--run { stroke: none; }
.ng.is-paused .ng__port--run { fill: $subject-cream; }
// ── 단추
.ng__bar { display: flex; justify-content: center; }
.ng__toggle {
font-family: inherit; font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1.6;
color: $subject-ink; background: $subject-cream; border: none; border-radius: $r-pill;
padding: $sp-1 $sp-4; cursor: pointer;
}
.ng__toggle:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 320×200 폰 (BD9): 글자만 줄인다. 여기서 간격을 올리면 세로가 넘친다
@media (max-width: 340px), (max-height: 220px) {
.ng__label { font-size: 14px; }
.ng__toggle { padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ng__dot,
.ng__ring { animation: none !important; }
.ng__dot--2 { offset-distance: 40% !important; }
.ng__dot--3 { offset-distance: 75% !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
width: min(100%, 400px);
display: grid;
gap: 8px;
justify-items: center;
}
.ng__canvas {
display: block;
width: 100%;
height: auto;
}
.ng__box {
fill: #fff7e6;
}
.ng__box--run {
stroke: #4cd4a6;
stroke-width: 2;
}
.ng__ico {
color: #17141a;
opacity: 0.72;
}
.ng__label {
font-weight: 700;
font-size: 13px;
fill: #17141a;
}
.ng__port {
fill: #fff7e6;
stroke: #17141a;
stroke-width: 2;
}
.ng__port--run {
fill: #4cd4a6;
}
.ng__wire {
fill: none;
stroke: rgba(255, 247, 230, 0.3);
stroke-width: 2.5;
stroke-linecap: round;
}
.ng__wire--run {
stroke: #4cd4a6;
stroke-width: 3;
}
.ng__dot {
offset-path: path("M122,85 C176,85 224,132 278,132");
offset-distance: 0%;
offset-rotate: 0deg;
fill: #fff7e6;
animation-name: ngFlow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng__dot--2 {
animation-delay: -0.6667s;
}
.ng__dot--3 {
animation-delay: -1.3333s;
}
.ng__ring {
fill: none;
stroke: #4cd4a6;
stroke-width: 2;
transform-box: fill-box;
transform-origin: 50% 50%;
animation-name: ngArrive;
animation-duration: 0.6667s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0.6667s;
animation-fill-mode: backwards;
}
@keyframes ngFlow {
from {
offset-distance: 0%;
}
to {
offset-distance: 100%;
}
}
@keyframes ngArrive {
0% {
transform: scale(0.5);
opacity: 0.9;
}
100% {
transform: scale(2.4);
opacity: 0;
}
}
.ng.is-paused .ng__dot,
.ng.is-paused .ng__ring {
animation-name: none;
opacity: 0;
}
.ng.is-paused .ng__wire--run {
stroke: rgba(255, 247, 230, 0.3);
stroke-width: 2.5;
}
.ng.is-paused .ng__box--run {
stroke: none;
}
.ng.is-paused .ng__port--run {
fill: #fff7e6;
}
.ng__bar {
display: flex;
justify-content: center;
}
.ng__toggle {
font-family: inherit;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.6;
color: #17141a;
background: #fff7e6;
border: none;
border-radius: 999px;
padding: 4px 16px;
cursor: pointer;
}
.ng__toggle:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
@media (max-width: 340px), (max-height: 220px) {
.ng__label {
font-size: 14px;
}
.ng__toggle {
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng__dot,
.ng__ring {
animation: none !important;
}
.ng__dot--2 {
offset-distance: 40% !important;
}
.ng__dot--3 {
offset-distance: 75% !important;
}
}var root = document.getElementById('ng');
var toggle = document.getElementById('ngToggle');
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
var paused = root.classList.toggle('is-paused');
toggle.setAttribute('aria-pressed', String(paused));
toggle.textContent = paused ? 'Play flow' : 'Pause flow';
});import { useState } from "react";
import styles from "./FlowDots.module.scss";
export type FlowDotsProps = { duration?: number; easing?: string; color?: string };
/** 실행 중인 갈래. offset-path 의 path() 와 **글자 그대로 같은 문자열**이라 좌표계가 어긋날 수 없다 */
const RUN_WIRE = "M122,85 C176,85 224,132 278,132";
const IDLE_WIRE = "M122,85 C176,85 224,38 278,38";
export default function FlowDots({ duration = 2000, easing = "linear", color = "#4cd4a6" }: FlowDotsProps) {
const [paused, setPaused] = useState(false);
return (
<main className={styles.stage}>
<div
className={`${styles.ng} ${paused ? styles.isPaused : ""}`}
style={{ "--ng-duration": `${duration}ms`, "--ng-easing": easing, "--ng-color": color }}
>
<svg className={styles.canvas} viewBox="0 0 400 170" role="group" aria-label="실행 중인 갈래를 점으로 보여 주는 연결도">
<path className={styles.wire} d={IDLE_WIRE} />
<path className={`${styles.wire} ${styles.wireRun}`} d={RUN_WIRE} />
<circle className={styles.ring} cx="278" cy="132" r="6" />
<circle className={styles.dot} cx="0" cy="0" r="4.5" />
<circle className={`${styles.dot} ${styles.dot2}`} cx="0" cy="0" r="4.5" />
<circle className={`${styles.dot} ${styles.dot3}`} cx="0" cy="0" r="4.5" />
<g className={styles.node}>
<rect className={styles.box} x="14" y="59" width="108" height="52" rx="12" />
<svg className={styles.ico} x="58" y="68" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M96,240l16-80L48,136,160,16,144,96l64,24Z" opacity="0.2" /><path d="M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z" /></svg>
<text className={styles.label} x="68" y="103" textAnchor="middle">폼 접수</text>
<circle className={styles.port} cx="122" cy="85" r="4.5" />
</g>
<g className={styles.node}>
<rect className={styles.box} x="278" y="12" width="108" height="52" rx="12" />
<svg className={styles.ico} x="322" y="21" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M221.9,61.38,152,136v58.65a8,8,0,0,1-3.56,6.66l-32,21.33A8,8,0,0,1,104,216V136L34.1,61.38A8,8,0,0,1,40,48H216A8,8,0,0,1,221.9,61.38Z" opacity="0.2" /><path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z" /></svg>
<text className={styles.label} x="332" y="56" textAnchor="middle">걸러내기</text>
<circle className={styles.port} cx="278" cy="38" r="4.5" />
</g>
<g className={styles.node}>
<rect className={`${styles.box} ${styles.boxRun}`} x="278" y="106" width="108" height="52" rx="12" />
<svg className={styles.ico} x="322" y="115" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M200,56H56A24,24,0,0,0,32,80V192a24,24,0,0,0,24,24H200a24,24,0,0,0,24-24V80A24,24,0,0,0,200,56ZM164,184H92a20,20,0,0,1,0-40h72a20,20,0,0,1,0,40Z" opacity="0.2" /><path d="M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Zm4,28H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24Z" /></svg>
<text className={styles.label} x="332" y="150" textAnchor="middle">AI 요약</text>
<circle className={`${styles.port} ${styles.portRun}`} cx="278" cy="132" r="4.5" />
</g>
</svg>
<div className={styles.bar}>
<button type="button" className={styles.toggle} aria-pressed={paused} onClick={() => setPaused((v) => !v)}>
{paused ? "흐름 재생" : "흐름 멈춤"}
</button>
</div>
</div>
</main>
);
}05 · Dots flowing along the wire — Measured area 1.118% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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
What moves
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<p class="ng__livewrap">
<span class="ng__live" id="ngLive" aria-live="polite">Waiting · 3 steps</span>
<span class="ng__demolive" aria-hidden="true">
<span class="ng__lstrip">
<span class="ng__lt">Waiting · 3 steps</span>
<span class="ng__lt">Running · 3 steps</span>
<span class="ng__lt">2 done · 1 failed</span>
</span>
</span>
</p>
<ul class="ng__list">
<li class="ng__card ng__card--1" data-state="wait">
<span class="ng__band" aria-hidden="true"></span>
<span class="ng__title">Form intake</span>
<span class="ng__chips" aria-hidden="true"><span class="ng__strip">
<span class="ng__chip ng__chip--wait"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg><span class="ng__txt">Waiting</span></span>
<span class="ng__chip ng__chip--run"><svg class="ng__ic ng__ic--spin" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></svg><span class="ng__txt">Running</span></span>
<span class="ng__chip ng__chip--done"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg><span class="ng__txt">Done</span></span>
<span class="ng__chip ng__chip--fail"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg><span class="ng__txt">Failed</span></span>
</span></span>
</li>
<li class="ng__card ng__card--2" data-state="wait">
<span class="ng__band" aria-hidden="true"></span>
<span class="ng__title">AI recap</span>
<span class="ng__chips" aria-hidden="true"><span class="ng__strip">
<span class="ng__chip ng__chip--wait"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg><span class="ng__txt">Waiting</span></span>
<span class="ng__chip ng__chip--run"><svg class="ng__ic ng__ic--spin" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></svg><span class="ng__txt">Running</span></span>
<span class="ng__chip ng__chip--done"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg><span class="ng__txt">Done</span></span>
<span class="ng__chip ng__chip--fail"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg><span class="ng__txt">Failed</span></span>
</span></span>
</li>
<li class="ng__card ng__card--3" data-state="wait">
<span class="ng__band" aria-hidden="true"></span>
<span class="ng__title">Send email</span>
<span class="ng__chips" aria-hidden="true"><span class="ng__strip">
<span class="ng__chip ng__chip--wait"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z"/></svg><span class="ng__txt">Waiting</span></span>
<span class="ng__chip ng__chip--run"><svg class="ng__ic ng__ic--spin" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></svg><span class="ng__txt">Running</span></span>
<span class="ng__chip ng__chip--done"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg><span class="ng__txt">Done</span></span>
<span class="ng__chip ng__chip--fail"><svg class="ng__ic" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z"/></svg><span class="ng__txt">Failed</span></span>
</span></span>
</li>
</ul>
<button type="button" class="ng__run" id="ngRun">Run again</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변형 3개는 이 셋만 바꾼다
$duration: $dur-loop;
$easing: steps(1, end);
$color: $subject-blue;
// 흰 무대 위 글자는 4.5:1 이 필요하다 — 토큰 $subject-blue 는 4.53 으로 아슬아슬해 한 단 어둡게 쓰고,
// 성공 초록은 팔레트에 없어 민트를 어둡게 잡았다 (실측 5.63 · 6.08, 279·280 이 CTA 파랑에 쓴 방식)
$run-ink: #7c90be;
$done-ink: #479077;
$wait-ink: rgba(23, 20, 26, .62);
$line-ink: rgba(23, 20, 26, .7);
$wait-edge: rgba(23, 20, 26, .18);
$wait-band: rgba(23, 20, 26, .28);
// 상태 한 칸의 높이. 칩 네 장을 세로로 쌓고 이 값의 배수만큼 밀어 한 장만 보여 준다
$cell: 20px;
@include stage($stage-paper);
.ng { width: min(100%, 360px); display: grid; gap: $sp-2; }
// ── 알림 줄: 진짜 aria-live 와 미리보기용 줄을 같은 칸에 겹쳐 둔다.
// 감출 때 display:none 을 쓰면 칸 폭까지 사라진다 → opacity 로 자리만 지킨다
.ng__livewrap { display: grid; justify-items: center; margin: 0; }
.ng__live, .ng__demolive { grid-area: 1 / 1; }
.ng__live {
font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: $cell; color: $line-ink;
letter-spacing: .01em;
}
.ng__demolive { height: $cell; overflow: hidden; opacity: 0; }
.ng__lstrip { display: grid; }
.ng__lt {
height: $cell; font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: $cell; color: $line-ink;
letter-spacing: .01em; white-space: nowrap; text-align: center;
}
// ── 카드
.ng__list { list-style: none; margin: 0; padding: 0; display: grid; gap: $sp-2; }
.ng__card {
position: relative; overflow: hidden;
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: $sp-2;
padding: $sp-2 $sp-3; padding-left: $sp-4;
border: 2px solid $wait-edge; border-radius: $r-control; background: $stage-paper;
}
.ng__title { font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: $cell; color: $subject-ink; }
// 왼쪽 띠는 위 변에 **붙어** 아래로 자란다 — 붙는 지점이 있는 요소라 origin 이 중심이 아니다 (blog-demo 규칙 8)
.ng__band {
position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: $wait-band; transform: scaleY(0); transform-origin: 50% 0;
}
// ── 상태 칩: 네 장을 쌓고 $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;
}
.ng__ic { width: 14px; height: 14px; flex: 0 0 auto; }
.ng__chip--wait { color: $wait-ink; }
.ng__chip--run { color: $run-ink; }
.ng__chip--done { color: $done-ink; }
.ng__chip--fail { color: $error; }
// ── 정적 경로: 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; }
.ng__card[data-state="run"] .ng__band {
background: $run-ink; transform-origin: 50% 0;
animation-name: ngFill; animation-duration: 800ms; animation-timing-function: $ease-out; animation-fill-mode: forwards;
}
.ng__card[data-state="done"] .ng__band { background: $done-ink; transform: scaleY(1); }
.ng__card[data-state="fail"] .ng__band { background: $error; transform: scaleY(1); }
// 실행 중 아이콘은 제 중심에서 계속 돈다 — 컷 전환만으로는 모자란 프레임을 이게 채운다
.ng__ic--spin {
transform-origin: 50% 50%;
animation-name: ngSpin; animation-duration: 1s; animation-timing-function: linear; animation-iteration-count: infinite;
}
// ── 단추
.ng__run {
justify-self: center; font-family: inherit; font-weight: 700; font-size: clamp(10px, 3vw, 11px); line-height: 1.8;
color: $subject-cream; background: $run-ink; border: none; border-radius: $r-pill; padding: $sp-1 $sp-5; cursor: pointer;
}
.ng__run:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 미리보기 루프 (2s): 세 카드가 차례로 실행되고 88% 안에 대기로 돌아온다.
// 교체는 steps(1, end) 컷 — 크로스페이드 중간 장(반투명 유령)을 만들지 않는다
.ng.is-demo .ng__live { opacity: 0; }
.ng.is-demo .ng__demolive { opacity: 1; }
.ng.is-demo .ng__lstrip {
animation-name: ngLine; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 { animation-name: ngEdge1; }
.ng.is-demo .ng__card--2 { animation-name: ngEdge2; }
.ng.is-demo .ng__card--3 { animation-name: ngEdge3; }
.ng.is-demo .ng__card--1 .ng__strip { animation-name: ngStrip1; }
.ng.is-demo .ng__card--2 .ng__strip { animation-name: ngStrip2; }
.ng.is-demo .ng__card--3 .ng__strip { animation-name: ngStrip3; }
.ng.is-demo .ng__card,
.ng.is-demo .ng__strip {
animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 .ng__band { animation-name: ngBand1; }
.ng.is-demo .ng__card--2 .ng__band { animation-name: ngBand2; }
.ng.is-demo .ng__card--3 .ng__band { animation-name: ngBand3; }
.ng.is-demo .ng__band {
animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite;
}
@keyframes ngSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes ngFill {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@keyframes ngLine {
0%, 11.9% { transform: translateY(0); }
12%, 71.9% { transform: translateY(-$cell); }
72%, 87.9% { transform: translateY(-$cell * 2); }
88%, 100% { transform: translateY(0); }
}
// 1번: 대기 → 실행 중(12%) → 성공(36%) → 대기(80%)
@keyframes ngStrip1 {
0%, 11.9% { transform: translateY(0); }
12%, 35.9% { transform: translateY(-$cell); }
36%, 79.9% { transform: translateY(-$cell * 2); }
80%, 100% { transform: translateY(0); }
}
@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; }
}
// 띠는 실행 구간만 linear 로 자라고 나머지는 컷이다 (키프레임마다 타이밍을 따로 준다)
@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; }
}
// 2번: 대기 → 실행 중(24%) → 성공(56%) → 대기(84%)
@keyframes ngStrip2 {
0%, 23.9% { transform: translateY(0); }
24%, 55.9% { transform: translateY(-$cell); }
56%, 83.9% { transform: translateY(-$cell * 2); }
84%, 100% { transform: translateY(0); }
}
@keyframes ngEdge2 {
0%, 23.9% { border-color: $wait-edge; }
24%, 55.9% { border-color: $run-ink; }
56%, 83.9% { border-color: $done-ink; }
84%, 100% { border-color: $wait-edge; }
}
@keyframes ngBand2 {
0% { transform: scaleY(0); background-color: $wait-band; animation-timing-function: steps(1, end); }
24% { transform: scaleY(0); background-color: $run-ink; animation-timing-function: linear; }
55.9% { transform: scaleY(1); background-color: $run-ink; animation-timing-function: steps(1, end); }
56%, 83.9% { transform: scaleY(1); background-color: $done-ink; animation-timing-function: steps(1, end); }
84%, 100% { transform: scaleY(0); background-color: $wait-band; }
}
// 3번: 대기 → 실행 중(36%) → 실패(72%) → 대기(88%)
@keyframes ngStrip3 {
0%, 35.9% { transform: translateY(0); }
36%, 71.9% { transform: translateY(-$cell); }
72%, 87.9% { transform: translateY(-$cell * 3); }
88%, 100% { transform: translateY(0); }
}
@keyframes ngEdge3 {
0%, 35.9% { border-color: $wait-edge; }
36%, 71.9% { border-color: $run-ink; }
72%, 87.9% { border-color: $error; }
88%, 100% { border-color: $wait-edge; }
}
@keyframes ngBand3 {
0% { transform: scaleY(0); background-color: $wait-band; animation-timing-function: steps(1, end); }
36% { transform: scaleY(0); background-color: $run-ink; animation-timing-function: linear; }
71.9% { transform: scaleY(1); background-color: $run-ink; animation-timing-function: steps(1, end); }
72%, 87.9% { transform: scaleY(1); background-color: $error; animation-timing-function: steps(1, end); }
88%, 100% { transform: scaleY(0); background-color: $wait-band; }
}
// ── 320×200 폰 (BD9): 여백만 줄인다. 여기서 값을 올리면 세로 174px 안에 안 들어간다
@media (max-width: 340px), (max-height: 220px) {
.ng { gap: $sp-1; }
.ng__list { gap: $sp-1; }
.ng__card { padding: $sp-1 $sp-2; padding-left: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.ng__ic--spin,
.ng__band,
.ng__card,
.ng__strip,
.ng__lstrip { animation: none !important; }
.ng__card[data-state="run"] .ng__band { transform: scaleY(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
width: min(100%, 360px);
display: grid;
gap: 8px;
}
.ng__livewrap {
display: grid;
justify-items: center;
margin: 0;
}
.ng__live, .ng__demolive {
grid-area: 1/1;
}
.ng__live {
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 20px;
color: rgba(23, 20, 26, 0.7);
letter-spacing: 0.01em;
}
.ng__demolive {
height: 20px;
overflow: hidden;
opacity: 0;
}
.ng__lstrip {
display: grid;
}
.ng__lt {
height: 20px;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 20px;
color: rgba(23, 20, 26, 0.7);
letter-spacing: 0.01em;
white-space: nowrap;
text-align: center;
}
.ng__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.ng__card {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
padding: 8px 12px;
padding-left: 16px;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
background: #ffffff;
}
.ng__title {
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 20px;
color: #17141a;
}
.ng__band {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: rgba(23, 20, 26, 0.28);
transform: scaleY(0);
transform-origin: 50% 0;
}
.ng__chips {
display: block;
height: 20px;
overflow: hidden;
}
.ng__strip {
display: grid;
}
.ng__chip {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 20px;
white-space: nowrap;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1;
}
.ng__ic {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.ng__chip--wait {
color: rgba(23, 20, 26, 0.62);
}
.ng__chip--run {
color: #7c90be;
}
.ng__chip--done {
color: #479077;
}
.ng__chip--fail {
color: #c2361a;
}
.ng__card[data-state=wait] .ng__strip {
transform: translateY(0);
}
.ng__card[data-state=run] .ng__strip {
transform: translateY(-20px);
}
.ng__card[data-state=done] .ng__strip {
transform: translateY(-40px);
}
.ng__card[data-state=fail] .ng__strip {
transform: translateY(-60px);
}
.ng__card[data-state=run] {
border-color: #7c90be;
}
.ng__card[data-state=done] {
border-color: #479077;
}
.ng__card[data-state=fail] {
border-color: #c2361a;
}
.ng__card[data-state=run] .ng__band {
background: #7c90be;
transform-origin: 50% 0;
animation-name: ngFill;
animation-duration: 800ms;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
.ng__card[data-state=done] .ng__band {
background: #479077;
transform: scaleY(1);
}
.ng__card[data-state=fail] .ng__band {
background: #c2361a;
transform: scaleY(1);
}
.ng__ic--spin {
transform-origin: 50% 50%;
animation-name: ngSpin;
animation-duration: 1s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.ng__run {
justify-self: center;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 3vw, 11px);
line-height: 1.8;
color: #fff7e6;
background: #7c90be;
border: none;
border-radius: 999px;
padding: 4px 20px;
cursor: pointer;
}
.ng__run:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ng.is-demo .ng__live {
opacity: 0;
}
.ng.is-demo .ng__demolive {
opacity: 1;
}
.ng.is-demo .ng__lstrip {
animation-name: ngLine;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 {
animation-name: ngEdge1;
}
.ng.is-demo .ng__card--2 {
animation-name: ngEdge2;
}
.ng.is-demo .ng__card--3 {
animation-name: ngEdge3;
}
.ng.is-demo .ng__card--1 .ng__strip {
animation-name: ngStrip1;
}
.ng.is-demo .ng__card--2 .ng__strip {
animation-name: ngStrip2;
}
.ng.is-demo .ng__card--3 .ng__strip {
animation-name: ngStrip3;
}
.ng.is-demo .ng__card,
.ng.is-demo .ng__strip {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ng.is-demo .ng__card--1 .ng__band {
animation-name: ngBand1;
}
.ng.is-demo .ng__card--2 .ng__band {
animation-name: ngBand2;
}
.ng.is-demo .ng__card--3 .ng__band {
animation-name: ngBand3;
}
.ng.is-demo .ng__band {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ngSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ngFill {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
@keyframes ngLine {
0%, 11.9% {
transform: translateY(0);
}
12%, 71.9% {
transform: translateY(-20px);
}
72%, 87.9% {
transform: translateY(-40px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes ngStrip1 {
0%, 11.9% {
transform: translateY(0);
}
12%, 35.9% {
transform: translateY(-20px);
}
36%, 79.9% {
transform: translateY(-40px);
}
80%, 100% {
transform: translateY(0);
}
}
@keyframes ngEdge1 {
0%, 11.9% {
border-color: rgba(23, 20, 26, 0.18);
}
12%, 35.9% {
border-color: #7c90be;
}
36%, 79.9% {
border-color: #479077;
}
80%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@keyframes ngBand1 {
0% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
animation-timing-function: steps(1, end);
}
12% {
transform: scaleY(0);
background-color: #7c90be;
animation-timing-function: linear;
}
35.9% {
transform: scaleY(1);
background-color: #7c90be;
animation-timing-function: steps(1, end);
}
36%, 79.9% {
transform: scaleY(1);
background-color: #479077;
animation-timing-function: steps(1, end);
}
80%, 100% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
}
}
@keyframes ngStrip2 {
0%, 23.9% {
transform: translateY(0);
}
24%, 55.9% {
transform: translateY(-20px);
}
56%, 83.9% {
transform: translateY(-40px);
}
84%, 100% {
transform: translateY(0);
}
}
@keyframes ngEdge2 {
0%, 23.9% {
border-color: rgba(23, 20, 26, 0.18);
}
24%, 55.9% {
border-color: #7c90be;
}
56%, 83.9% {
border-color: #479077;
}
84%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@keyframes ngBand2 {
0% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
animation-timing-function: steps(1, end);
}
24% {
transform: scaleY(0);
background-color: #7c90be;
animation-timing-function: linear;
}
55.9% {
transform: scaleY(1);
background-color: #7c90be;
animation-timing-function: steps(1, end);
}
56%, 83.9% {
transform: scaleY(1);
background-color: #479077;
animation-timing-function: steps(1, end);
}
84%, 100% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
}
}
@keyframes ngStrip3 {
0%, 35.9% {
transform: translateY(0);
}
36%, 71.9% {
transform: translateY(-20px);
}
72%, 87.9% {
transform: translateY(-60px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes ngEdge3 {
0%, 35.9% {
border-color: rgba(23, 20, 26, 0.18);
}
36%, 71.9% {
border-color: #7c90be;
}
72%, 87.9% {
border-color: #c2361a;
}
88%, 100% {
border-color: rgba(23, 20, 26, 0.18);
}
}
@keyframes ngBand3 {
0% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
animation-timing-function: steps(1, end);
}
36% {
transform: scaleY(0);
background-color: #7c90be;
animation-timing-function: linear;
}
71.9% {
transform: scaleY(1);
background-color: #7c90be;
animation-timing-function: steps(1, end);
}
72%, 87.9% {
transform: scaleY(1);
background-color: #c2361a;
animation-timing-function: steps(1, end);
}
88%, 100% {
transform: scaleY(0);
background-color: rgba(23, 20, 26, 0.28);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
gap: 4px;
}
.ng__list {
gap: 4px;
}
.ng__card {
padding: 4px 8px;
padding-left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng__ic--spin,
.ng__band,
.ng__card,
.ng__strip,
.ng__lstrip {
animation: none !important;
}
.ng__card[data-state=run] .ng__band {
transform: scaleY(1) !important;
}
}const root = document.getElementById('ng');
const runButton = document.getElementById('ngRun');
const live = document.getElementById('ngLive');
const cards = Array.prototype.slice.call(document.querySelectorAll('.ng__card'));
const RESULT = ['done', 'done', 'fail'];
const LINE = { wait: 'Waiting · 3 steps', run: 'Running · 3 steps', end: '2 done · 1 failed' };
let timers = [];
function runPipeline() {
root.classList.remove('is-demo');
timers.forEach(function (id) { clearTimeout(id); });
timers = [];
cards.forEach(function (card) { card.setAttribute('data-state', 'wait'); });
live.textContent = LINE.wait;
cards.forEach(function (card, i) {
timers.push(setTimeout(function () {
card.setAttribute('data-state', 'run');
if (i === 0) { live.textContent = LINE.run; }
}, 300 + i * 600));
timers.push(setTimeout(function () {
card.setAttribute('data-state', RESULT[i]);
if (i === cards.length - 1) { live.textContent = LINE.end; }
}, 1100 + i * 600));
});
}
runButton.addEventListener('click', runPipeline);import { useRef, useState } from "react";
import styles from "./RunStatus.module.scss";
export type RunStatusProps = { duration?: number; easing?: string; color?: string };
/** ambient 의 useRef 는 T|null 을 돌려준다 — 배열 상자로 단언해 null 가드를 줄인다 */
type Box<T> = { current: T };
const CLOCK = (
<svg className={styles.ic} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm64-88a8,8,0,0,1-8,8H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48A8,8,0,0,1,192,128Z" /></svg>
);
const SPINNER = (
<svg className={`${styles.ic} ${styles.icSpin}`} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm88,88H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z" /></svg>
);
const CHECK = (
<svg className={styles.ic} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z" /></svg>
);
const WARN = (
<svg className={styles.ic} viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" /><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm-8-80V80a8,8,0,0,1,16,0v56a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,172Z" /></svg>
);
const CHIPS = [
{ key: "wait", cls: styles.chipWait, icon: CLOCK, text: "대기" },
{ key: "run", cls: styles.chipRun, icon: SPINNER, text: "실행 중" },
{ key: "done", cls: styles.chipDone, icon: CHECK, text: "성공" },
{ key: "fail", cls: styles.chipFail, icon: WARN, text: "실패" },
];
const TITLES = ["폼 접수", "AI 요약", "메일 보내기"];
const RESULT = ["done", "done", "fail"];
const LINE = { wait: "대기 중 · 3단계", run: "실행 중 · 3단계", end: "성공 2 · 실패 1" };
const CARD_CLASS = [styles.card1, styles.card2, styles.card3];
export default function RunStatus({ duration = 800, easing = "ease-out", color = "#2a5fd8" }: RunStatusProps) {
const [demo, setDemo] = useState(true);
const [states, setStates] = useState<string[]>(["wait", "wait", "wait"]);
const [line, setLine] = useState<string>(LINE.wait);
const timers = useRef<number[]>([]) as Box<number[]>;
const runPipeline = () => {
setDemo(false);
timers.current.forEach((id) => clearTimeout(id));
timers.current = [];
setStates(["wait", "wait", "wait"]);
setLine(LINE.wait);
TITLES.forEach((_, i) => {
timers.current.push(setTimeout(() => {
setStates((prev) => prev.map((v, j) => (j === i ? "run" : v)));
if (i === 0) { setLine(LINE.run); }
}, 300 + i * 600));
timers.current.push(setTimeout(() => {
setStates((prev) => prev.map((v, j) => (j === i ? RESULT[i] : v)));
if (i === TITLES.length - 1) { setLine(LINE.end); }
}, 1100 + i * 600));
});
};
return (
<main className={styles.stage}>
<div
className={`${styles.ng} ${demo ? styles.isDemo : ""}`}
style={{ "--ng-duration": `${duration}ms`, "--ng-easing": easing, "--ng-color": color }}
>
<p className={styles.livewrap}>
<span className={styles.live} aria-live="polite">{line}</span>
<span className={styles.demolive} aria-hidden="true">
<span className={styles.lstrip}>
<span className={styles.lt}>{LINE.wait}</span>
<span className={styles.lt}>{LINE.run}</span>
<span className={styles.lt}>{LINE.end}</span>
</span>
</span>
</p>
<ul className={styles.list}>
{TITLES.map((title, i) => (
<li key={title} className={`${styles.card} ${CARD_CLASS[i]}`} data-state={states[i]}>
<span className={styles.band} aria-hidden="true" />
<span className={styles.title}>{title}</span>
<span className={styles.chips} aria-hidden="true">
<span className={styles.strip}>
{CHIPS.map((chip) => (
<span key={chip.key} className={`${styles.chip} ${chip.cls}`}>
{chip.icon}
<span className={styles.txt}>{chip.text}</span>
</span>
))}
</span>
</span>
</li>
))}
</ul>
<button type="button" className={styles.run} onClick={runPipeline}>다시 실행</button>
</div>
</main>
);
}06 · Node run status — Measured area 4.892% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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
What you see
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<div class="ng__bar">
<span class="ng__title">Pick nodes</span>
<span class="ng__badge">
<span class="ng__num" aria-hidden="true"><span class="ng__digits" id="ngDigits"><i>0 selected</i><i>1 selected</i><i>2 selected</i><i>3 selected</i><i>4 selected</i></span></span>
</span>
</div>
<div class="ng__canvas" id="ngCanvas">
<svg class="ng__layer" viewBox="0 0 400 196" preserveAspectRatio="none" aria-hidden="true">
<rect class="ng__marquee" id="ngMarquee" x="56" y="20" width="208" height="161"/>
</svg>
<div class="ng__node ng__node--a">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Receive</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
<span class="ng__ring" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--b">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Filter</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
<span class="ng__ring" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--c">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Send</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
<span class="ng__ring" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--d">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Store</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
<span class="ng__ring" aria-hidden="true"></span>
</div>
</div>
<p class="ng__sr" id="ngLive" aria-live="polite" data-tpl="N nodes selected">0 nodes selected</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 `초`(2s) 고정
$easing: $ease-out; // 사각형이 사라질 때의 감속
$color: $subject-blue; // 고른 상태의 색 — 테두리·사각형·배지
@include stage($stage-yellow);
.stage { position: relative; }
.ng { width: min(100%, 400px); display: flex; flex-direction: column; gap: $sp-2; }
.ng__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.ng__title { font-weight: 800; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.2; color: $subject-ink; letter-spacing: .01em; }
.ng__badge {
display: inline-flex; align-items: center; gap: $sp-1; background: $subject-ink; color: $subject-cream;
border-radius: $r-pill; padding: $sp-1 $sp-3; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1;
}
// 개수 문구를 세로로 쌓아 두고 한 칸씩 밀어 올린다 — 컷이라 반투명한 중간 숫자가 안 생긴다
.ng__num { display: inline-block; height: 12px; overflow: hidden; }
.ng__digits { display: block; transform: translateY(0); }
.ng__digits i { display: block; height: 12px; font-style: normal; font-weight: 800; font-size: 10px; line-height: 12px; white-space: nowrap; }
.ng__canvas {
position: relative; width: 100%; height: clamp(132px, 42vw, 196px);
background-color: $stage-paper;
background-image: linear-gradient(to right, rgba($subject-ink, .10) 1px, transparent 1px),
linear-gradient(to bottom, rgba($subject-ink, .10) 1px, transparent 1px);
background-size: 24px 24px;
border-radius: $r-card; overflow: hidden; box-shadow: $shadow-press;
touch-action: none; cursor: crosshair;
}
.ng__layer { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ng__marquee {
fill: rgba($color, .16); stroke: $color; stroke-width: 2; stroke-dasharray: 6 4;
// 굵기를 화면 px 으로 고정 — scale 로 키워도 점선이 두꺼워지지 않는다
vector-effect: non-scaling-stroke;
opacity: 0;
// 비중심 origin: 끌기 시작점(사각형의 좌상단)에 붙어 자라는 요소라서 0 0 이다
transform-box: fill-box; transform-origin: 0 0;
}
.ng__marquee.is-on { opacity: 1; }
.ng__node {
position: absolute; width: 24%; height: 22%;
display: flex; align-items: center; justify-content: center;
background: $subject-cream; border: 1px solid rgba($subject-ink, .18);
border-radius: $r-control; box-shadow: $shadow-press;
}
.ng__node--a { left: 20%; top: 16%; }
.ng__node--b { left: 56%; top: 20%; }
.ng__node--c { left: 24%; top: 60%; }
.ng__node--d { left: 70%; top: 62%; }
.ng__name { font-weight: 700; font-size: clamp(8px, 2.4vw, 11px); line-height: 1.1; color: $subject-ink; }
.ng__port {
position: absolute; top: 50%; width: 7px; height: 7px; border-radius: 50%;
background: $stage-paper; border: 2px solid rgba($subject-ink, .45); transform: translateY(-50%);
}
.ng__port--in { left: -5px; }
.ng__port--out { right: -5px; }
.ng__ring {
position: absolute; inset: -4px; border: 2px solid $color; border-radius: $r-card;
opacity: 0; pointer-events: none;
}
.ng__node.is-picked .ng__ring { opacity: 1; }
.ng__sr { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
// ── 미리보기 루프 (2s) : 사각형이 좌상단에서 우하단으로 연속으로 자라고
// 걸친 노드가 14.5% · 42.5% · 53.5% 에 차례로 컷으로 켜진다.
// 사각형만 90% 안에 사라지고 켜진 테두리는 남는다 — 화면이 비지 않게
.ng.is-demo .ng__marquee {
animation-name: ngGrow, ngFade;
animation-duration: $duration, $duration;
animation-timing-function: linear, $easing; // 자라는 건 등속(손가락 속도), 사라지는 건 감속
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
@keyframes ngGrow {
0%, 8% { transform: scale(.02, .02); }
64%, 100% { transform: scale(1, 1); }
}
@keyframes ngFade {
0%, 7% { opacity: 0; }
9%, 82% { opacity: 1; }
90%, 100% { opacity: 0; }
}
.ng.is-demo .ng__digits {
animation-name: ngCount; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes ngCount {
0% { transform: translateY(0); }
14.5% { transform: translateY(-12px); }
42.5% { transform: translateY(-24px); }
53.5% { transform: translateY(-36px); }
100% { transform: translateY(-36px); }
}
.ng.is-demo .ng__ring {
animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ng.is-demo .ng__node--a .ng__ring { animation-name: ngRingA; }
.ng.is-demo .ng__node--c .ng__ring { animation-name: ngRingC; }
.ng.is-demo .ng__node--b .ng__ring { animation-name: ngRingB; }
@keyframes ngRingA { 0% { opacity: 0; } 14.5% { opacity: 1; } 100% { opacity: 1; } }
@keyframes ngRingC { 0% { opacity: 0; } 42.5% { opacity: 1; } 100% { opacity: 1; } }
@keyframes ngRingB { 0% { opacity: 0; } 53.5% { opacity: 1; } 100% { opacity: 1; } }
// ── 320×200 폰 (BD9). 좁은 쪽에서는 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.ng { gap: $sp-1; }
.ng__badge { padding: 0 $sp-2; }
.ng__title { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__marquee,
.ng.is-demo .ng__digits,
.ng.is-demo .ng__ring { animation: none !important; }
.ng.is-demo .ng__marquee { opacity: 1 !important; transform: scale(1, 1) !important; }
.ng.is-demo .ng__ring { opacity: 1 !important; }
.ng.is-demo .ng__node--d .ng__ring { opacity: 0 !important; }
.ng.is-demo .ng__digits { transform: translateY(-36px) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.ng {
width: min(100%, 400px);
display: flex;
flex-direction: column;
gap: 8px;
}
.ng__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ng__title {
font-weight: 800;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.2;
color: #17141a;
letter-spacing: 0.01em;
}
.ng__badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1;
}
.ng__num {
display: inline-block;
height: 12px;
overflow: hidden;
}
.ng__digits {
display: block;
transform: translateY(0);
}
.ng__digits i {
display: block;
height: 12px;
font-style: normal;
font-weight: 800;
font-size: 10px;
line-height: 12px;
white-space: nowrap;
}
.ng__canvas {
position: relative;
width: 100%;
height: clamp(132px, 42vw, 196px);
background-color: #ffffff;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.1) 1px, transparent 1px);
background-size: 24px 24px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
touch-action: none;
cursor: crosshair;
}
.ng__layer {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ng__marquee {
fill: rgba(47, 109, 246, 0.16);
stroke: #2f6df6;
stroke-width: 2;
stroke-dasharray: 6 4;
vector-effect: non-scaling-stroke;
opacity: 0;
transform-box: fill-box;
transform-origin: 0 0;
}
.ng__marquee.is-on {
opacity: 1;
}
.ng__node {
position: absolute;
width: 24%;
height: 22%;
display: flex;
align-items: center;
justify-content: center;
background: #fff7e6;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ng__node--a {
left: 20%;
top: 16%;
}
.ng__node--b {
left: 56%;
top: 20%;
}
.ng__node--c {
left: 24%;
top: 60%;
}
.ng__node--d {
left: 70%;
top: 62%;
}
.ng__name {
font-weight: 700;
font-size: clamp(8px, 2.4vw, 11px);
line-height: 1.1;
color: #17141a;
}
.ng__port {
position: absolute;
top: 50%;
width: 7px;
height: 7px;
border-radius: 50%;
background: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.45);
transform: translateY(-50%);
}
.ng__port--in {
left: -5px;
}
.ng__port--out {
right: -5px;
}
.ng__ring {
position: absolute;
inset: -4px;
border: 2px solid #2f6df6;
border-radius: 16px;
opacity: 0;
pointer-events: none;
}
.ng__node.is-picked .ng__ring {
opacity: 1;
}
.ng__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ng.is-demo .ng__marquee {
animation-name: ngGrow, ngFade;
animation-duration: 2s, 2s;
animation-timing-function: linear, ease-out;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
@keyframes ngGrow {
0%, 8% {
transform: scale(0.02, 0.02);
}
64%, 100% {
transform: scale(1, 1);
}
}
@keyframes ngFade {
0%, 7% {
opacity: 0;
}
9%, 82% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
.ng.is-demo .ng__digits {
animation-name: ngCount;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ngCount {
0% {
transform: translateY(0);
}
14.5% {
transform: translateY(-12px);
}
42.5% {
transform: translateY(-24px);
}
53.5% {
transform: translateY(-36px);
}
100% {
transform: translateY(-36px);
}
}
.ng.is-demo .ng__ring {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__node--a .ng__ring {
animation-name: ngRingA;
}
.ng.is-demo .ng__node--c .ng__ring {
animation-name: ngRingC;
}
.ng.is-demo .ng__node--b .ng__ring {
animation-name: ngRingB;
}
@keyframes ngRingA {
0% {
opacity: 0;
}
14.5% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ngRingC {
0% {
opacity: 0;
}
42.5% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ngRingB {
0% {
opacity: 0;
}
53.5% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
gap: 4px;
}
.ng__badge {
padding: 0 8px;
}
.ng__title {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__marquee,
.ng.is-demo .ng__digits,
.ng.is-demo .ng__ring {
animation: none !important;
}
.ng.is-demo .ng__marquee {
opacity: 1 !important;
transform: scale(1, 1) !important;
}
.ng.is-demo .ng__ring {
opacity: 1 !important;
}
.ng.is-demo .ng__node--d .ng__ring {
opacity: 0 !important;
}
.ng.is-demo .ng__digits {
transform: translateY(-36px) !important;
}
}(function () {
var ng = document.getElementById('ng');
var canvas = document.getElementById('ngCanvas');
var marquee = document.getElementById('ngMarquee');
var digits = document.getElementById('ngDigits');
var live = document.getElementById('ngLive');
var nodes = canvas.querySelectorAll('.ng__node');
var DIGIT_H = 12; // 숫자 한 칸 높이 — style.scss 의 .ng__digits i 높이와 같은 값
var VIEW_W = 400, VIEW_H = 196; // svg viewBox 단위. 화면 px 을 이 좌표로 되돌려 넣는다
var start = null;
function rectOf(el) {
var r = el.getBoundingClientRect(), c = canvas.getBoundingClientRect();
return { x: r.left - c.left, y: r.top - c.top, w: r.width, h: r.height };
}
function place(x, y, w, h) {
var c = canvas.getBoundingClientRect();
marquee.setAttribute('x', String(x / c.width * VIEW_W));
marquee.setAttribute('y', String(y / c.height * VIEW_H));
marquee.setAttribute('width', String(w / c.width * VIEW_W));
marquee.setAttribute('height', String(h / c.height * VIEW_H));
}
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('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);
});
function stop() { start = null; marquee.classList.remove('is-on'); }
canvas.addEventListener('pointerup', stop);
canvas.addEventListener('pointercancel', stop);
})();import { useRef, useState } from "react";
import styles from "./MarqueeSelect.module.scss";
export type MarqueeSelectProps = { duration?: number; easing?: string; color?: string };
// ambient 에 React 이벤트 제네릭이 없다 — 필요한 것만 로컬 타입으로 선언한다
type PointerEvt = { clientX: number; clientY: number; pointerId: number; target: any; preventDefault(): void };
type Box<T> = { current: T };
type Rect = { x: number; y: number; w: number; h: number };
const VIEW_W = 400;
const VIEW_H = 196;
const DIGIT_H = 12;
const NODES = [
{ key: "a", name: "받기", left: "20%", top: "16%" },
{ key: "b", name: "거르기", left: "56%", top: "20%" },
{ key: "c", name: "보내기", left: "24%", top: "60%" },
{ key: "d", name: "저장", left: "70%", top: "62%" },
];
const COUNTS = ["0개 선택", "1개 선택", "2개 선택", "3개 선택", "4개 선택"];
const LIVE = ["노드 0개 선택", "노드 1개 선택", "노드 2개 선택", "노드 3개 선택", "노드 4개 선택"];
export default function MarqueeSelect({ duration = 2000, easing = "ease-out", color = "#2f6df6" }: MarqueeSelectProps) {
const [demo, setDemo] = useState(true);
const [picked, setPicked] = useState<boolean[]>([false, false, false, false]);
const [rect, setRect] = useState<Rect | null>(null);
const canvasRef = useRef<any>(null) as Box<any>;
const nodeRefs = useRef<any[]>([]) as Box<any[]>;
const startRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const count = picked.filter(Boolean).length;
const rootStyle = { "--ngDur": duration + "ms", "--ngEase": easing, "--ngColor": color } as { [k: string]: string };
function onDown(e: PointerEvt) {
if (e.target && e.target.closest && e.target.closest("[data-node]")) return; // 빈 바닥에서만 시작
setDemo(false);
const c = canvasRef.current.getBoundingClientRect();
startRef.current = { x: e.clientX - c.left, y: e.clientY - c.top };
canvasRef.current.setPointerCapture(e.pointerId);
setRect({ x: (startRef.current.x / c.width) * VIEW_W, y: (startRef.current.y / c.height) * VIEW_H, w: 0, h: 0 });
e.preventDefault();
}
function onMove(e: PointerEvt) {
const s = startRef.current;
if (!s) return;
const c = canvasRef.current.getBoundingClientRect();
const cx = e.clientX - c.left;
const cy = e.clientY - c.top;
// 어느 방향으로 끌어도 되게 정규화한다 — 두 점 중 작은 쪽이 좌상단, 차의 절댓값이 크기다
const x = Math.min(s.x, cx);
const y = Math.min(s.y, cy);
const w = Math.abs(cx - s.x);
const h = Math.abs(cy - s.y);
setRect({ x: (x / c.width) * VIEW_W, y: (y / c.height) * VIEW_H, w: (w / c.width) * VIEW_W, h: (h / c.height) * VIEW_H });
setPicked(
nodeRefs.current.map(function (el: any) {
if (!el) return false;
const r = el.getBoundingClientRect();
return r.left - c.left < x + w && r.right - c.left > x && r.top - c.top < y + h && r.bottom - c.top > y;
})
);
}
function onUp() {
startRef.current = null;
setRect(null);
}
return (
<main className={styles.stage}>
<div className={demo ? styles.ng + " " + styles.isDemo : styles.ng} style={rootStyle}>
<div className={styles.bar}>
<span className={styles.title}>노드 고르기</span>
<span className={styles.badge}>
<span className={styles.num} aria-hidden="true">
<span
className={styles.digits}
style={demo ? undefined : { transform: "translateY(" + -DIGIT_H * count + "px)" }}
>
{COUNTS.map((c) => (
<i key={c}>{c}</i>
))}
</span>
</span>
</span>
</div>
<div
className={styles.canvas}
ref={(el: any) => { canvasRef.current = el; }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<svg className={styles.layer} viewBox={"0 0 " + VIEW_W + " " + VIEW_H} preserveAspectRatio="none" aria-hidden="true">
<rect
className={rect ? styles.marquee + " " + styles.isOn : styles.marquee}
x={rect ? rect.x : 56}
y={rect ? rect.y : 20}
width={rect ? rect.w : 208}
height={rect ? rect.h : 161}
/>
</svg>
{NODES.map((n, i) => (
<div
key={n.key}
data-node={n.key}
ref={(el: any) => { nodeRefs.current[i] = el; }}
className={[styles.node, styles["node_" + n.key], picked[i] ? styles.isPicked : ""].join(" ")}
style={{ left: n.left, top: n.top }}
>
<span className={styles.port + " " + styles.portIn} aria-hidden="true" />
<span className={styles.name}>{n.name}</span>
<span className={styles.port + " " + styles.portOut} aria-hidden="true" />
<span className={styles.ring} aria-hidden="true" />
</div>
))}
</div>
<p className={styles.sr} aria-live="polite">{LIVE[count]}</p>
</div>
</main>
);
}07 · Marquee select — Measured area 17.009% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
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
What happens on screen
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<div class="ng__canvas" id="ngCanvas">
<div class="ng__world" id="ngWorld">
<svg class="ng__wires" viewBox="0 0 400 224" preserveAspectRatio="none" aria-hidden="true">
<path class="ng__wire" d="M168,81 C196,81 156,134 184,134"/>
<path class="ng__wire" d="M264,134 C292,134 252,67 280,67"/>
</svg>
<div class="ng__node ng__node--a">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Collect</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--b">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Tidy</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--c">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Dispatch</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
</div>
<span class="ng__chip">Grid floor</span>
<div class="ng__tools">
<span class="ng__zoom" title="Zoom level">
<b class="ng__zoomNow" id="ngZoom">100%</b>
<b class="ng__zoomAlt" aria-hidden="true">120%</b>
</span>
<button type="button" class="ng__btn" id="ngOut" aria-label="Zoom out"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity="0.2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Zm112,0a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h64A8,8,0,0,1,152,112Z"/></svg></button>
<button type="button" class="ng__btn" id="ngIn" aria-label="Zoom in"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity="0.2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Zm112,0a8,8,0,0,1-8,8H120v24a8,8,0,0,1-16,0V120H80a8,8,0,0,1,0-16h24V80a8,8,0,0,1,16,0v24h24A8,8,0,0,1,152,112Z"/></svg></button>
<button type="button" class="ng__btn" id="ngHome" aria-label="Recenter"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M232,128,128,232,24,128,128,24Z" opacity="0.2"/><path d="M90.34,61.66a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,0l32,32a8,8,0,0,1-11.32,11.32L136,43.31V96a8,8,0,0,1-16,0V43.31L101.66,61.66A8,8,0,0,1,90.34,61.66Zm64,132.68L136,212.69V160a8,8,0,0,0-16,0v52.69l-18.34-18.35a8,8,0,0,0-11.32,11.32l32,32a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm83.32-72-32-32a8,8,0,0,0-11.32,11.32L212.69,120H160a8,8,0,0,0,0,16h52.69l-18.35,18.34a8,8,0,0,0,11.32,11.32l32-32A8,8,0,0,0,237.66,122.34ZM43.31,136H96a8,8,0,0,0,0-16H43.31l18.35-18.34A8,8,0,0,0,50.34,90.34l-32,32a8,8,0,0,0,0,11.32l32,32a8,8,0,0,0,11.32-11.32Z"/></svg></button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 `초`(2s) 고정
$easing: $ease-out; // 단추 눌림 피드백 (팬은 아래 주석대로 일부러 linear)
$color: $subject-blue; // 연결선·포트 색
@include stage($stage-orange);
.ng { width: min(100%, 400px); }
.ng__canvas {
position: relative; width: 100%; height: clamp(140px, 48vw, 224px); overflow: hidden;
border-radius: $r-card; box-shadow: $shadow-raised;
background-color: $subject-cream;
// 격자 무늬 = 세로줄 한 겹 + 가로줄 한 겹. 타일 크기는 background-size 가 정한다
background-image: linear-gradient(to right, rgba($subject-ink, .16) 1px, transparent 1px),
linear-gradient(to bottom, rgba($subject-ink, .16) 1px, transparent 1px);
background-size: 24px 24px;
background-position: 0 0;
touch-action: none; cursor: grab;
}
.ng__world {
position: absolute; inset: 0;
// 확대는 무대 한가운데를 축으로 — 모서리에 두면 커지는 게 아니라 옆으로 밀리며 늘어나 보인다
transform-origin: 50% 50%;
transform: translate(0, 0) scale(1);
}
.ng__wires { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ng__wire { fill: none; stroke: $color; stroke-width: 2; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.ng__node {
position: absolute; width: 20%; height: 20%;
display: flex; align-items: center; justify-content: center;
background: $stage-paper; border: 1px solid rgba($subject-ink, .20);
border-radius: $r-control; box-shadow: $shadow-press;
}
.ng__node--a { left: 22%; top: 26%; }
.ng__node--b { left: 46%; top: 50%; }
.ng__node--c { left: 70%; top: 20%; }
.ng__name { font-weight: 700; font-size: clamp(8px, 2.4vw, 11px); line-height: 1.1; color: $subject-ink; }
.ng__port {
position: absolute; top: 50%; width: 7px; height: 7px; border-radius: 50%;
background: $stage-paper; border: 2px solid $color; transform: translateY(-50%);
}
.ng__port--in { left: -5px; }
.ng__port--out { right: -5px; }
.ng__chip {
position: absolute; left: 8px; top: 8px;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
padding: $sp-1 $sp-2; font-weight: 700; font-size: 9px; line-height: 1.2;
}
.ng__tools { position: absolute; right: 8px; bottom: 8px; display: flex; align-items: center; gap: $sp-1; }
.ng__zoom {
position: relative; display: inline-block; width: 34px; height: 16px;
background: $subject-ink; border-radius: $r-xs;
}
.ng__zoomNow, .ng__zoomAlt {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 9px; line-height: 1; color: $subject-cream;
}
.ng__zoomAlt { opacity: 0; }
.ng__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 20px; height: 20px; padding: 0; border: none; border-radius: $r-xs;
background: $subject-ink; color: $subject-cream; cursor: pointer;
transition: transform $dur-instant $easing;
}
.ng__btn:active { transform: scale(.92); } // 자기 중심에서 눌린다
.ng__ico { width: 14px; height: 14px; }
// ── 미리보기 루프 (2s) : 천천히 팬 → 한 번 확대 → 88% 에 복귀
// ⚠ 격자(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); }
}
// 배율 글자는 컷으로 갈아끼운다 — 두 겹을 페이드로 섞으면 반투명 유령 프레임이 생긴다
.ng.is-demo .ng__zoomNow, .ng.is-demo .ng__zoomAlt {
animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ng.is-demo .ng__zoomNow { animation-name: ngLabelBase; }
.ng.is-demo .ng__zoomAlt { animation-name: ngLabelZoom; }
@keyframes ngLabelBase { 0% { opacity: 1; } 45.5% { opacity: 0; } 83.5% { opacity: 1; } 100% { opacity: 1; } }
@keyframes ngLabelZoom { 0% { opacity: 0; } 45.5% { opacity: 1; } 83.5% { opacity: 0; } 100% { opacity: 0; } }
// ── 320×200 폰 (BD9). 좁은 쪽에서는 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.ng__chip { font-size: 8px; padding: 0 $sp-1; }
.ng__btn { width: 18px; height: 18px; }
.ng__ico { width: 12px; height: 12px; }
.ng__zoom { width: 30px; height: 14px; }
.ng__zoomNow, .ng__zoomAlt { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__canvas,
.ng.is-demo .ng__world,
.ng.is-demo .ng__zoomNow,
.ng.is-demo .ng__zoomAlt { animation: none !important; }
.ng.is-demo .ng__world { transform: translate(0, 0) scale(1) !important; }
.ng.is-demo .ng__canvas { background-position: 0 0 !important; }
.ng.is-demo .ng__zoomNow { opacity: 1 !important; }
.ng.is-demo .ng__zoomAlt { opacity: 0 !important; }
.ng__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
width: min(100%, 400px);
}
.ng__canvas {
position: relative;
width: 100%;
height: clamp(140px, 48vw, 224px);
overflow: hidden;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background-color: #fff7e6;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.16) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.16) 1px, transparent 1px);
background-size: 24px 24px;
background-position: 0 0;
touch-action: none;
cursor: grab;
}
.ng__world {
position: absolute;
inset: 0;
transform-origin: 50% 50%;
transform: translate(0, 0) scale(1);
}
.ng__wires {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ng__wire {
fill: none;
stroke: #2f6df6;
stroke-width: 2;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.ng__node {
position: absolute;
width: 20%;
height: 20%;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ng__node--a {
left: 22%;
top: 26%;
}
.ng__node--b {
left: 46%;
top: 50%;
}
.ng__node--c {
left: 70%;
top: 20%;
}
.ng__name {
font-weight: 700;
font-size: clamp(8px, 2.4vw, 11px);
line-height: 1.1;
color: #17141a;
}
.ng__port {
position: absolute;
top: 50%;
width: 7px;
height: 7px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #2f6df6;
transform: translateY(-50%);
}
.ng__port--in {
left: -5px;
}
.ng__port--out {
right: -5px;
}
.ng__chip {
position: absolute;
left: 8px;
top: 8px;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
padding: 4px 8px;
font-weight: 700;
font-size: 9px;
line-height: 1.2;
}
.ng__tools {
position: absolute;
right: 8px;
bottom: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.ng__zoom {
position: relative;
display: inline-block;
width: 34px;
height: 16px;
background: #17141a;
border-radius: 4px;
}
.ng__zoomNow, .ng__zoomAlt {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 9px;
line-height: 1;
color: #fff7e6;
}
.ng__zoomAlt {
opacity: 0;
}
.ng__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: #17141a;
color: #fff7e6;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ng__btn:active {
transform: scale(0.92);
}
.ng__ico {
width: 14px;
height: 14px;
}
.ng.is-demo .ng__canvas {
animation-name: ngGridPan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__world {
animation-name: ngWorldPan;
animation-duration: 2s;
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);
}
}
.ng.is-demo .ng__zoomNow, .ng.is-demo .ng__zoomAlt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__zoomNow {
animation-name: ngLabelBase;
}
.ng.is-demo .ng__zoomAlt {
animation-name: ngLabelZoom;
}
@keyframes ngLabelBase {
0% {
opacity: 1;
}
45.5% {
opacity: 0;
}
83.5% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes ngLabelZoom {
0% {
opacity: 0;
}
45.5% {
opacity: 1;
}
83.5% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng__chip {
font-size: 8px;
padding: 0 4px;
}
.ng__btn {
width: 18px;
height: 18px;
}
.ng__ico {
width: 12px;
height: 12px;
}
.ng__zoom {
width: 30px;
height: 14px;
}
.ng__zoomNow, .ng__zoomAlt {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__canvas,
.ng.is-demo .ng__world,
.ng.is-demo .ng__zoomNow,
.ng.is-demo .ng__zoomAlt {
animation: none !important;
}
.ng.is-demo .ng__world {
transform: translate(0, 0) scale(1) !important;
}
.ng.is-demo .ng__canvas {
background-position: 0 0 !important;
}
.ng.is-demo .ng__zoomNow {
opacity: 1 !important;
}
.ng.is-demo .ng__zoomAlt {
opacity: 0 !important;
}
.ng__btn {
transition: none !important;
}
}(function () {
var ng = document.getElementById('ng');
var canvas = document.getElementById('ngCanvas');
var world = document.getElementById('ngWorld');
var zoomText = document.getElementById('ngZoom');
var panX = 0, panY = 0, zoom = 1, from = null;
function apply() {
// 격자와 노드 레이어에 같은 거리를 준다. 한쪽만 움직이면 바닥이 노드 밑에서 미끄러져 보인다
canvas.style.backgroundPosition = panX + 'px ' + panY + 'px';
world.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + zoom + ')';
zoomText.textContent = Math.round(zoom * 100) + '%';
}
function stopDemo() { ng.classList.remove('is-demo'); }
function setZoom(next) {
stopDemo();
zoom = Math.min(2, Math.max(0.6, Math.round(next * 10) / 10));
apply();
}
canvas.addEventListener('pointerdown', function (e) {
if (e.target.closest('.ng__btn')) return;
stopDemo();
from = { x: e.clientX - panX, y: e.clientY - panY };
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
});
canvas.addEventListener('pointermove', function (e) {
if (!from) return;
panX = e.clientX - from.x;
panY = e.clientY - from.y;
apply();
});
function stop() { from = null; }
canvas.addEventListener('pointerup', stop);
canvas.addEventListener('pointercancel', stop);
document.getElementById('ngIn').addEventListener('click', function () { setZoom(zoom + 0.2); });
document.getElementById('ngOut').addEventListener('click', function () { setZoom(zoom - 0.2); });
document.getElementById('ngHome').addEventListener('click', function () {
stopDemo(); panX = 0; panY = 0; zoom = 1; apply();
});
})();import { useRef, useState } from "react";
import styles from "./PanZoomCanvas.module.scss";
export type PanZoomCanvasProps = { duration?: number; easing?: string; color?: string };
// ambient 에 React 이벤트 제네릭이 없다 — 필요한 것만 로컬 타입으로 선언한다
type PointerEvt = { clientX: number; clientY: number; pointerId: number; target: any; preventDefault(): void };
type Box<T> = { current: T };
const NODES = [
{ key: "a", name: "수집", left: "22%", top: "26%", inPort: false, outPort: true },
{ key: "b", name: "정리", left: "46%", top: "50%", inPort: true, outPort: true },
{ key: "c", name: "전송", left: "70%", top: "20%", inPort: true, outPort: false },
];
const WIRES = ["M168,81 C196,81 156,134 184,134", "M264,134 C292,134 252,67 280,67"];
export default function PanZoomCanvas({ duration = 2000, easing = "ease-out", color = "#2f6df6" }: PanZoomCanvasProps) {
const [demo, setDemo] = useState(true);
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const canvasRef = useRef<any>(null) as Box<any>;
const fromRef = useRef<{ x: number; y: number } | null>(null) as Box<{ x: number; y: number } | null>;
const rootStyle = { "--ngDur": duration + "ms", "--ngEase": easing, "--ngColor": color } as { [k: string]: string };
// 격자와 노드 레이어에 **같은 거리**를 준다. 한쪽만 움직이면 바닥이 노드 밑에서 미끄러져 보인다
const gridStyle = demo ? undefined : { backgroundPosition: pan.x + "px " + pan.y + "px" };
const worldStyle = demo ? undefined : { transform: "translate(" + pan.x + "px, " + pan.y + "px) scale(" + zoom + ")" };
function step(next: number) {
setDemo(false);
setZoom(Math.min(2, Math.max(0.6, Math.round(next * 10) / 10)));
}
function onDown(e: PointerEvt) {
if (e.target && e.target.closest && e.target.closest("button")) return;
setDemo(false);
fromRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
canvasRef.current.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onMove(e: PointerEvt) {
const f = fromRef.current;
if (!f) return;
setPan({ x: e.clientX - f.x, y: e.clientY - f.y });
}
function onUp() {
fromRef.current = null;
}
return (
<main className={styles.stage}>
<div className={demo ? styles.ng + " " + styles.isDemo : styles.ng} style={rootStyle}>
<div
className={styles.canvas}
ref={(el: any) => { canvasRef.current = el; }}
style={gridStyle}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<div className={styles.world} style={worldStyle}>
<svg className={styles.wires} viewBox="0 0 400 224" preserveAspectRatio="none" aria-hidden="true">
{WIRES.map((d) => (
<path key={d} className={styles.wire} d={d} />
))}
</svg>
{NODES.map((n) => (
<div key={n.key} className={styles.node} style={{ left: n.left, top: n.top }}>
{n.inPort ? <span className={styles.port + " " + styles.portIn} aria-hidden="true" /> : null}
<span className={styles.name}>{n.name}</span>
{n.outPort ? <span className={styles.port + " " + styles.portOut} aria-hidden="true" /> : null}
</div>
))}
</div>
<span className={styles.chip}>격자 바닥</span>
<div className={styles.tools}>
<span className={styles.zoom} title="배율">
<b className={styles.zoomNow}>{Math.round(zoom * 100) + "%"}</b>
<b className={styles.zoomAlt} aria-hidden="true">120%</b>
</span>
<button type="button" className={styles.btn} aria-label="축소" onClick={() => step(zoom - 0.2)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity="0.2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Zm112,0a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h64A8,8,0,0,1,152,112Z"/></svg>
</button>
<button type="button" className={styles.btn} aria-label="확대" onClick={() => step(zoom + 0.2)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M192,112a80,80,0,1,1-80-80A80,80,0,0,1,192,112Z" opacity="0.2"/><path d="M229.66,218.34,179.6,168.28a88.21,88.21,0,1,0-11.32,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Zm112,0a8,8,0,0,1-8,8H120v24a8,8,0,0,1-16,0V120H80a8,8,0,0,1,0-16h24V80a8,8,0,0,1,16,0v24h24A8,8,0,0,1,152,112Z"/></svg>
</button>
<button
type="button"
className={styles.btn}
aria-label="가운데로"
onClick={() => { setDemo(false); setPan({ x: 0, y: 0 }); setZoom(1); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M232,128,128,232,24,128,128,24Z" opacity="0.2"/><path d="M90.34,61.66a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,0l32,32a8,8,0,0,1-11.32,11.32L136,43.31V96a8,8,0,0,1-16,0V43.31L101.66,61.66A8,8,0,0,1,90.34,61.66Zm64,132.68L136,212.69V160a8,8,0,0,0-16,0v52.69l-18.34-18.35a8,8,0,0,0-11.32,11.32l32,32a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm83.32-72-32-32a8,8,0,0,0-11.32,11.32L212.69,120H160a8,8,0,0,0,0,16h52.69l-18.35,18.34a8,8,0,0,0,11.32,11.32l32-32A8,8,0,0,0,237.66,122.34ZM43.31,136H96a8,8,0,0,0,0-16H43.31l18.35-18.34A8,8,0,0,0,50.34,90.34l-32,32a8,8,0,0,0,0,11.32l32,32a8,8,0,0,0,11.32-11.32Z"/></svg>
</button>
</div>
</div>
</div>
</main>
);
}08 · Panning and zooming the grid canvas — Measured area 54.162% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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
What moves
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.

<main class="stage">
<div class="ng is-demo" id="ng">
<div class="ng__board" id="ngCanvas">
<span class="ng__chip">Graph</span>
<svg class="ng__wires" viewBox="0 0 152 196" preserveAspectRatio="none" aria-hidden="true">
<path class="ng__wire" d="M52,51 C52,67 94,67 94,82"/>
<path class="ng__wire" d="M94,118 C94,132 52,132 52,145"/>
</svg>
<div class="ng__node ng__node--a" data-id="in">
<span class="ng__name">Input</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--b" data-id="map">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Map</span>
<span class="ng__port ng__port--out" aria-hidden="true"></span>
</div>
<div class="ng__node ng__node--c" data-id="out">
<span class="ng__port ng__port--in" aria-hidden="true"></span>
<span class="ng__name">Output</span>
</div>
</div>
<div class="ng__panel">
<div class="ng__head">
<span class="ng__brace" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico"><path d="M240,128c-64,0,0,88-64,88H80c-64,0,0-88-64-88,64,0,0-88,64-88h96C240,40,176,128,240,128Z" opacity="0.2"/><path d="M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z"/></svg></span>
<span class="ng__file">graph.json</span>
<button type="button" class="ng__copy" id="ngCopy" aria-label="Copy JSON"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico" aria-hidden="true"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg></button>
</div>
<pre class="ng__code" id="ngCode"></pre>
</div>
<p class="ng__toast" id="ngToast" aria-live="polite" data-tpl="Copied N lines" data-fail="Copy failed — select the text yourself">
<span class="ng__tick" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ng__ico"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg></span><span class="ng__toastText" id="ngToastText">Copied 29 lines</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 미리보기 루프 주기 — 설정 `초`(2s) 고정
$easing: $ease-spring; // JSON 한 줄이 떠오르는 곡선
$color: $subject-blue; // 연결선·복사 단추·체크 표시
@include stage($stage-ink);
.ng {
position: relative; width: min(100%, 416px);
display: grid; grid-template-columns: 38% 1fr; gap: $sp-2;
background: $subject-cream; border-radius: $r-card; padding: $sp-3; box-shadow: $shadow-raised;
}
.ng__board {
position: relative; height: clamp(140px, 44vw, 196px); overflow: hidden;
border-radius: $r-control; background-color: $stage-paper;
background-image: linear-gradient(to right, rgba($subject-ink, .10) 1px, transparent 1px),
linear-gradient(to bottom, rgba($subject-ink, .10) 1px, transparent 1px);
background-size: 24px 24px;
}
.ng__chip {
position: absolute; left: 4px; top: 4px; z-index: 2;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
padding: 0 $sp-2; font-weight: 700; font-size: 8px; line-height: 1.6;
}
.ng__wires { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ng__wire { fill: none; stroke: $color; stroke-width: 2; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.ng__node {
position: absolute; width: 56%; height: 18%;
display: flex; align-items: center; justify-content: center;
background: $subject-cream; border: 1px solid rgba($subject-ink, .20);
border-radius: $r-sm; box-shadow: $shadow-press;
}
.ng__node--a { left: 6%; top: 8%; }
.ng__node--b { left: 34%; top: 42%; }
.ng__node--c { left: 6%; top: 74%; }
.ng__name { font-weight: 700; font-size: clamp(7px, 2vw, 9px); line-height: 1.1; color: $subject-ink; }
.ng__port {
position: absolute; left: 50%; width: 6px; height: 6px; border-radius: 50%;
background: $stage-paper; border: 2px solid $color; transform: translateX(-50%);
}
.ng__port--in { top: -4px; }
.ng__port--out { bottom: -4px; }
.ng__panel {
height: clamp(140px, 44vw, 196px); overflow: hidden;
border-radius: $r-control; background: $subject-ink; display: flex; flex-direction: column;
}
.ng__head {
display: flex; align-items: center; gap: $sp-1; padding: $sp-1 $sp-2;
border-bottom: 1px solid rgba($stage-paper, .14); flex: 0 0 auto;
}
.ng__brace { display: inline-flex; color: rgba($stage-paper, .72); flex: 0 0 auto; }
.ng__file { font-weight: 700; font-size: 8px; line-height: 1.4; color: $subject-cream; flex: 1 1 auto; }
.ng__ico { width: 12px; height: 12px; display: block; }
.ng__copy {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; padding: 0; border: none; border-radius: $r-xs;
background: $color; color: $subject-cream; cursor: pointer; flex: 0 0 auto;
transition: transform $dur-instant $ease-pop;
}
.ng__copy:active { transform: scale(.92); } // 자기 중심에서 눌린다
.ng__code {
margin: 0; padding: $sp-1 $sp-2; overflow: hidden; flex: 1 1 auto;
font-family: ui-monospace, "Consolas", "Courier New", monospace;
font-size: clamp(5px, 1.8vw, 8px); line-height: 1.3; color: $subject-cream;
white-space: pre;
}
.ng__line { display: block; }
.ng__toast {
position: absolute; right: 20px; bottom: 20px; margin: 0;
display: inline-flex; align-items: center; gap: $sp-1;
background: $subject-cream; color: $subject-ink; border-radius: $r-pill;
padding: $sp-1 $sp-3; box-shadow: $shadow-raised;
font-weight: 700; font-size: 9px; line-height: 1.2; white-space: nowrap;
opacity: 0; transform: translateY(6px);
}
.ng__tick { display: inline-flex; color: $color; flex: 0 0 auto; }
.ng__toast.is-on { opacity: 1; transform: translateY(0); }
// ── 미리보기 루프 (2s) : JSON 이 위에서 아래로 한 줄씩 떠오르고,
// 다 차면 복사 배지가 컷으로 떴다가 88% 안에 되돌아온다
.ng.is-demo .ng__line:nth-child(-n + 16) {
animation-name: ngLine; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards; // 지연 구간에 작성 상태가 한 번 번쩍이는 것을 막는다
}
@for $i from 1 through 16 {
.ng.is-demo .ng__line:nth-child(#{$i}) { animation-delay: #{($i - 1) * 22}ms; }
}
@keyframes ngLine {
0% { opacity: 0; transform: translateY(6px); }
13% { opacity: 1; transform: translateY(0); }
100% { opacity: 1; transform: translateY(0); }
}
.ng.is-demo .ng__toast {
animation-name: ngToastCut; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes ngToastCut {
0% { opacity: 0; transform: translateY(6px); }
70.5% { opacity: 1; transform: translateY(0); }
84.5% { opacity: 0; transform: translateY(6px); }
100% { opacity: 0; transform: translateY(6px); }
}
// ── 320×200 폰 (BD9). 좁은 쪽에서는 값을 올리지 않는다
@media (max-width: 340px), (max-height: 220px) {
.ng { padding: $sp-2; gap: $sp-1; }
.ng__head { padding: 0 $sp-1; }
.ng__code { padding: 0 $sp-1; }
.ng__file { font-size: 7px; }
.ng__copy { width: 16px; height: 16px; }
.ng__ico { width: 10px; height: 10px; }
.ng__toast { right: 12px; bottom: 12px; font-size: 8px; padding: 0 $sp-2; }
.ng__chip { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__line,
.ng.is-demo .ng__toast { animation: none !important; }
.ng.is-demo .ng__line { opacity: 1 !important; transform: none !important; }
.ng.is-demo .ng__toast { opacity: 1 !important; transform: none !important; }
.ng__copy { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ng {
position: relative;
width: min(100%, 416px);
display: grid;
grid-template-columns: 38% 1fr;
gap: 8px;
background: #fff7e6;
border-radius: 16px;
padding: 12px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ng__board {
position: relative;
height: clamp(140px, 44vw, 196px);
overflow: hidden;
border-radius: 12px;
background-color: #ffffff;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.1) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.1) 1px, transparent 1px);
background-size: 24px 24px;
}
.ng__chip {
position: absolute;
left: 4px;
top: 4px;
z-index: 2;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
padding: 0 8px;
font-weight: 700;
font-size: 8px;
line-height: 1.6;
}
.ng__wires {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.ng__wire {
fill: none;
stroke: #2f6df6;
stroke-width: 2;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.ng__node {
position: absolute;
width: 56%;
height: 18%;
display: flex;
align-items: center;
justify-content: center;
background: #fff7e6;
border: 1px solid rgba(23, 20, 26, 0.2);
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ng__node--a {
left: 6%;
top: 8%;
}
.ng__node--b {
left: 34%;
top: 42%;
}
.ng__node--c {
left: 6%;
top: 74%;
}
.ng__name {
font-weight: 700;
font-size: clamp(7px, 2vw, 9px);
line-height: 1.1;
color: #17141a;
}
.ng__port {
position: absolute;
left: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #2f6df6;
transform: translateX(-50%);
}
.ng__port--in {
top: -4px;
}
.ng__port--out {
bottom: -4px;
}
.ng__panel {
height: clamp(140px, 44vw, 196px);
overflow: hidden;
border-radius: 12px;
background: #17141a;
display: flex;
flex-direction: column;
}
.ng__head {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
flex: 0 0 auto;
}
.ng__brace {
display: inline-flex;
color: rgba(255, 255, 255, 0.72);
flex: 0 0 auto;
}
.ng__file {
font-weight: 700;
font-size: 8px;
line-height: 1.4;
color: #fff7e6;
flex: 1 1 auto;
}
.ng__ico {
width: 12px;
height: 12px;
display: block;
}
.ng__copy {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: #2f6df6;
color: #fff7e6;
cursor: pointer;
flex: 0 0 auto;
transition: transform 140ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ng__copy:active {
transform: scale(0.92);
}
.ng__code {
margin: 0;
padding: 4px 8px;
overflow: hidden;
flex: 1 1 auto;
font-family: ui-monospace, "Consolas", "Courier New", monospace;
font-size: clamp(5px, 1.8vw, 8px);
line-height: 1.3;
color: #fff7e6;
white-space: pre;
}
.ng__line {
display: block;
}
.ng__toast {
position: absolute;
right: 20px;
bottom: 20px;
margin: 0;
display: inline-flex;
align-items: center;
gap: 4px;
background: #fff7e6;
color: #17141a;
border-radius: 999px;
padding: 4px 12px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-weight: 700;
font-size: 9px;
line-height: 1.2;
white-space: nowrap;
opacity: 0;
transform: translateY(6px);
}
.ng__tick {
display: inline-flex;
color: #2f6df6;
flex: 0 0 auto;
}
.ng__toast.is-on {
opacity: 1;
transform: translateY(0);
}
.ng.is-demo .ng__line:nth-child(-n+16) {
animation-name: ngLine;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ng.is-demo .ng__line:nth-child(1) {
animation-delay: 0ms;
}
.ng.is-demo .ng__line:nth-child(2) {
animation-delay: 22ms;
}
.ng.is-demo .ng__line:nth-child(3) {
animation-delay: 44ms;
}
.ng.is-demo .ng__line:nth-child(4) {
animation-delay: 66ms;
}
.ng.is-demo .ng__line:nth-child(5) {
animation-delay: 88ms;
}
.ng.is-demo .ng__line:nth-child(6) {
animation-delay: 110ms;
}
.ng.is-demo .ng__line:nth-child(7) {
animation-delay: 132ms;
}
.ng.is-demo .ng__line:nth-child(8) {
animation-delay: 154ms;
}
.ng.is-demo .ng__line:nth-child(9) {
animation-delay: 176ms;
}
.ng.is-demo .ng__line:nth-child(10) {
animation-delay: 198ms;
}
.ng.is-demo .ng__line:nth-child(11) {
animation-delay: 220ms;
}
.ng.is-demo .ng__line:nth-child(12) {
animation-delay: 242ms;
}
.ng.is-demo .ng__line:nth-child(13) {
animation-delay: 264ms;
}
.ng.is-demo .ng__line:nth-child(14) {
animation-delay: 286ms;
}
.ng.is-demo .ng__line:nth-child(15) {
animation-delay: 308ms;
}
.ng.is-demo .ng__line:nth-child(16) {
animation-delay: 330ms;
}
@keyframes ngLine {
0% {
opacity: 0;
transform: translateY(6px);
}
13% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.ng.is-demo .ng__toast {
animation-name: ngToastCut;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ngToastCut {
0% {
opacity: 0;
transform: translateY(6px);
}
70.5% {
opacity: 1;
transform: translateY(0);
}
84.5% {
opacity: 0;
transform: translateY(6px);
}
100% {
opacity: 0;
transform: translateY(6px);
}
}
@media (max-width: 340px), (max-height: 220px) {
.ng {
padding: 8px;
gap: 4px;
}
.ng__head {
padding: 0 4px;
}
.ng__code {
padding: 0 4px;
}
.ng__file {
font-size: 7px;
}
.ng__copy {
width: 16px;
height: 16px;
}
.ng__ico {
width: 10px;
height: 10px;
}
.ng__toast {
right: 12px;
bottom: 12px;
font-size: 8px;
padding: 0 8px;
}
.ng__chip {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ng.is-demo .ng__line,
.ng.is-demo .ng__toast {
animation: none !important;
}
.ng.is-demo .ng__line {
opacity: 1 !important;
transform: none !important;
}
.ng.is-demo .ng__toast {
opacity: 1 !important;
transform: none !important;
}
.ng__copy {
transition: none !important;
}
}(function () {
var ng = document.getElementById('ng');
var board = document.getElementById('ngCanvas');
var code = document.getElementById('ngCode');
var copyBtn = document.getElementById('ngCopy');
var toast = document.getElementById('ngToast');
var toastText = document.getElementById('ngToastText');
var nodes = board.querySelectorAll('.ng__node');
var hideTimer = 0;
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);
}
function paint(text) {
code.textContent = '';
var rows = text.split('\n');
for (var i = 0; i < rows.length; i++) {
var row = document.createElement('span');
row.className = 'ng__line';
row.textContent = rows[i];
code.appendChild(row);
}
}
function say(text) {
toastText.textContent = text;
toast.classList.add('is-on');
clearTimeout(hideTimer);
hideTimer = setTimeout(function () { toast.classList.remove('is-on'); }, 1600);
}
var json = build();
paint(json);
copyBtn.addEventListener('click', function () {
ng.classList.remove('is-demo');
var count = json.split('\n').length; // 몇 줄인지는 글자를 직접 세어 말한다
var job = navigator.clipboard ? navigator.clipboard.writeText(json) : Promise.reject();
job.then(function () {
say(toast.getAttribute('data-tpl').replace('N', String(count)));
}, function () {
say(toast.getAttribute('data-fail'));
var range = document.createRange();
range.selectNodeContents(code);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./GraphToJson.module.scss";
export type GraphToJsonProps = { duration?: number; easing?: string; color?: string };
type Box<T> = { current: T };
type GraphNode = { id: string; x: number; y: number };
type GraphLink = { from: string; to: string };
const NODES = [
{ id: "in", name: "입력", left: "6%", top: "8%", inPort: false, outPort: true },
{ id: "map", name: "변환", left: "34%", top: "42%", inPort: true, outPort: true },
{ id: "out", name: "출력", left: "6%", top: "74%", inPort: true, outPort: false },
];
const WIRES = ["M52,51 C52,67 94,67 94,82", "M94,118 C94,132 52,132 52,145"];
const FAIL = "복사 실패 — 글자를 직접 선택하세요";
const DONE = "N줄 복사했어요";
export default function GraphToJson({ duration = 2000, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: GraphToJsonProps) {
const [demo, setDemo] = useState(true);
const [json, setJson] = useState("");
const [toast, setToast] = useState("29줄 복사했어요");
const [shown, setShown] = useState(false);
const boardRef = useRef<any>(null) as Box<any>;
const codeRef = useRef<any>(null) as Box<any>;
const nodeRefs = useRef<any[]>([]) as Box<any[]>;
const rootStyle = { "--ngDur": duration + "ms", "--ngEase": easing, "--ngColor": color } as { [k: string]: string };
useEffect(() => {
// 화면에 놓인 노드를 그대로 훑어 객체로 만든다 — 아래 글자는 전부 이 객체에서 나온 것이다
const board = boardRef.current;
if (!board) return;
const b = board.getBoundingClientRect();
const nodes: GraphNode[] = [];
for (let i = 0; i < NODES.length; i++) {
const el = nodeRefs.current[i];
if (!el) continue;
const r = el.getBoundingClientRect();
nodes.push({ id: NODES[i].id, x: Math.round(r.left - b.left), y: Math.round(r.top - b.top) });
}
const links: GraphLink[] = [];
for (let j = 1; j < nodes.length; j++) links.push({ from: nodes[j - 1].id, to: nodes[j].id });
setJson(JSON.stringify({ nodes: nodes, links: links }, null, 2));
}, []);
function say(text: string) {
setToast(text);
setShown(true);
setTimeout(() => setShown(false), 1600);
}
function onCopy() {
setDemo(false);
const count = json.split("\n").length; // 몇 줄인지는 글자를 직접 세어 말한다
const job = navigator.clipboard ? navigator.clipboard.writeText(json) : Promise.reject();
job.then(
() => say(DONE.replace("N", String(count))),
() => {
say(FAIL);
const node = codeRef.current;
if (!node) return;
const range = document.createRange();
range.selectNodeContents(node);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
}
);
}
const lines = json ? json.split("\n") : [];
return (
<main className={styles.stage}>
<div className={demo ? styles.ng + " " + styles.isDemo : styles.ng} style={rootStyle}>
<div className={styles.board} ref={(el: any) => { boardRef.current = el; }}>
<span className={styles.chip}>그래프</span>
<svg className={styles.wires} viewBox="0 0 152 196" preserveAspectRatio="none" aria-hidden="true">
{WIRES.map((d) => (
<path key={d} className={styles.wire} d={d} />
))}
</svg>
{NODES.map((n, i) => (
<div
key={n.id}
ref={(el: any) => { nodeRefs.current[i] = el; }}
className={styles.node}
style={{ left: n.left, top: n.top }}
>
{n.inPort ? <span className={styles.port + " " + styles.portIn} aria-hidden="true" /> : null}
<span className={styles.name}>{n.name}</span>
{n.outPort ? <span className={styles.port + " " + styles.portOut} aria-hidden="true" /> : null}
</div>
))}
</div>
<div className={styles.panel}>
<div className={styles.head}>
<span className={styles.brace} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M240,128c-64,0,0,88-64,88H80c-64,0,0-88-64-88,64,0,0-88,64-88h96C240,40,176,128,240,128Z" opacity="0.2"/><path d="M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z"/></svg>
</span>
<span className={styles.file}>graph.json</span>
<button type="button" className={styles.copy} aria-label="JSON 복사" onClick={onCopy}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M216,40V168H168V88H88V40Z" opacity="0.2"/><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"/></svg>
</button>
</div>
<pre className={styles.code} ref={(el: any) => { codeRef.current = el; }}>
{lines.map((row, i) => (
<span key={String(i) + row} className={styles.line}>{row}</span>
))}
</pre>
</div>
<p className={shown ? styles.toast + " " + styles.isOn : styles.toast} aria-live="polite">
<span className={styles.tick} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico}><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></svg>
</span>
<span className={styles.toastText}>{toast}</span>
</p>
</div>
</main>
);
}09 · Exporting the graph as JSON — Measured area 3.749% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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. Letting the marquee select in 07 be driven from the keyboard as well pulls in keyboard shortcut UI, keycaps and help sheet included. 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 a flow builder ui design 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. To make a dragged box coast after you let go, the spring values in physics simulation transfer directly.
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.









