07Marquee select
Dragging on empty canvas grows a dashed rectangle, lighting the outline of every node it touches and counting them in a badge.
Best forMoving several blocks at once, bulk delete
pointermoveMath.minaria-live

<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 · Node Based UI Design: 9 Patterns, No Library
The post this demo lives in
Node Based UI Design: 9 Patterns, No LibraryNode based ui design is the wiring screen — boxes dropped on a canvas and joined with lines, the way n8n, Figma, and Ble See all demos in this post


