08引いた四角形の内側だけを選ぶ投げ縄セレクトドラッグ
ドラッグ開始点と現在のポインター座標から矩形範囲を毎フレーム再計算し、その範囲に重なるカードは即座に選択状態へ変わる。
使いどころギャラリーの複数枚を一度に選択
LASSO_MIN_DRAG_PXrectsIntersectrole="listbox"

<main class="stage">
<div class="ls is-demo" id="ls">
<div class="ls__field" id="lsField" role="listbox" aria-multiselectable="true" aria-label="ドラッグして複数のカードを一度に選ぶ領域。カードにフォーカスしてEnterで一つずつ選ぶこともできます">
<button type="button" class="ls__item" data-idx="0" role="option" aria-selected="false">1</button>
<button type="button" class="ls__item" data-idx="1" role="option" aria-selected="false">2</button>
<button type="button" class="ls__item" data-idx="2" role="option" aria-selected="false">3</button>
<button type="button" class="ls__item" data-idx="3" role="option" aria-selected="false">4</button>
<button type="button" class="ls__item" data-idx="4" role="option" aria-selected="false">5</button>
<button type="button" class="ls__item" data-idx="5" role="option" aria-selected="false">6</button>
<div class="ls__lasso" id="lsLasso"></div>
</div>
<p class="ls__caption" id="lsCaption">空いた場所を引くと四角形と重なるカードが選ばれます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.ls {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ls__field {
touch-action: none;
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-3;
width: 100%;
max-width: 220px;
padding: $sp-3;
border-radius: $r-card;
border: 1px dashed rgba($subject-cream, .22);
}
.ls__item {
appearance: none;
border: 0;
aspect-ratio: 1 / 1;
border-radius: $r-sm;
background: rgba($subject-cream, .1);
color: $subject-cream;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color $dur-quick $ease-out, box-shadow $dur-quick $ease-out;
}
.ls__item.is-selected {
background: $subject-blue;
box-shadow: 0 0 0 2px $subject-blue-light inset;
}
.ls__item:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.ls__lasso {
position: absolute;
pointer-events: none;
border: 1px solid $subject-blue-light;
background: rgba($subject-blue, .16);
border-radius: $r-xs;
opacity: 0;
}
.ls__caption {
margin: 0;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ls__field { max-width: 180px; }
.ls__item { font-size: 12px; }
.ls__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ls__item { transition: none; }
}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);
}
.ls {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ls__field {
touch-action: none;
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
width: 100%;
max-width: 220px;
padding: 12px;
border-radius: 16px;
border: 1px dashed rgba(255, 247, 230, 0.22);
}
.ls__item {
appearance: none;
border: 0;
aspect-ratio: 1/1;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 220ms ease-out, box-shadow 220ms ease-out;
}
.ls__item.is-selected {
background: #2f6df6;
box-shadow: 0 0 0 2px #5b8cf8 inset;
}
.ls__item:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ls__lasso {
position: absolute;
pointer-events: none;
border: 1px solid #5b8cf8;
background: rgba(47, 109, 246, 0.16);
border-radius: 4px;
opacity: 0;
}
.ls__caption {
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.ls__field {
max-width: 180px;
}
.ls__item {
font-size: 12px;
}
.ls__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls__item {
transition: none;
}
}(function () {
// 기법: 빈 곳에서 드래그를 시작하면 시작점과 현재 포인터로 사각형(라쏘)을 만들고,
// 매 프레임 그 사각형과 각 카드의 getBoundingClientRect 를 좌표 비교(rectsIntersect)해
// 겹치는 카드만 실시간으로 선택 표시한다.
var root = document.getElementById('ls');
var field = document.getElementById('lsField');
var lasso = document.getElementById('lsLasso');
var caption = document.getElementById('lsCaption');
var items = Array.prototype.slice.call(field.querySelectorAll('.ls__item'));
var LASSO_MIN_DRAG_PX = 4;
var selected = items.map(function () { return false; });
var lassoing = false;
var lassoStart = null;
var focusIdx = 0;
function setSelected(idx, on) {
selected[idx] = on;
items[idx].classList.toggle('is-selected', on);
items[idx].setAttribute('aria-selected', on ? 'true' : 'false');
}
function rectsIntersect(a, b) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function updateLassoSelection(rect) {
items.forEach(function (el, idx) {
var r = el.getBoundingClientRect();
setSelected(idx, rectsIntersect(rect, r));
});
}
function drawLasso(x0, y0, x1, y1) {
var left = Math.min(x0, x1), top = Math.min(y0, y1);
var w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
var fieldRect = field.getBoundingClientRect();
lasso.style.left = (left - fieldRect.left) + 'px';
lasso.style.top = (top - fieldRect.top) + 'px';
lasso.style.width = w + 'px';
lasso.style.height = h + 'px';
lasso.style.opacity = '1';
return { left: left, top: top, right: left + w, bottom: top + h };
}
function beginLasso(clientX, clientY) {
lassoing = true;
lassoStart = { x: clientX, y: clientY };
root.classList.add('is-dragging');
caption.textContent = '重なるカードがリアルタイムで選ばれます';
}
function moveLasso(clientX, clientY) {
if (!lassoing) return;
var rect = drawLasso(lassoStart.x, lassoStart.y, clientX, clientY);
if (Math.abs(clientX - lassoStart.x) > LASSO_MIN_DRAG_PX || Math.abs(clientY - lassoStart.y) > LASSO_MIN_DRAG_PX) {
updateLassoSelection(rect);
}
}
function endLasso() {
lassoing = false;
root.classList.remove('is-dragging');
lasso.style.opacity = '0';
var count = selected.filter(Boolean).length;
caption.textContent = count > 0 ? count + '個選択しました' : '空いた場所を引くと四角形と重なるカードが選ばれます';
}
field.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
if (e.target.classList.contains('ls__item')) return;
field.setPointerCapture(e.pointerId);
beginLasso(e.clientX, e.clientY);
e.preventDefault();
});
field.addEventListener('pointermove', function (e) {
if (!lassoing) return;
moveLasso(e.clientX, e.clientY);
e.preventDefault();
});
field.addEventListener('pointerup', function (e) {
if (!lassoing) return;
endLasso();
e.preventDefault();
});
items.forEach(function (el, idx) {
el.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
e.stopPropagation();
});
el.addEventListener('click', function () {
root.classList.remove('is-demo');
setSelected(idx, !selected[idx]);
var count = selected.filter(Boolean).length;
caption.textContent = count > 0 ? count + '個選択しました' : '空いた場所を引くと四角形と重なるカードが選ばれます';
});
el.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelected(idx, !selected[idx]);
var count = selected.filter(Boolean).length;
caption.textContent = count > 0 ? count + '個選択しました' : '空いた場所を引くと四角形と重なるカードが選ばれます';
return;
}
var dir = 0;
if (e.key === 'ArrowRight') dir = 1;
else if (e.key === 'ArrowLeft') dir = -1;
else if (e.key === 'ArrowDown') dir = 3;
else if (e.key === 'ArrowUp') dir = -3;
else if (e.key === 'Escape') {
e.preventDefault();
items.forEach(function (elx, i) { setSelected(i, false); });
caption.textContent = '空いた場所を引くと四角形と重なるカードが選ばれます';
return;
} else { return; }
e.preventDefault();
focusIdx = Math.max(0, Math.min(items.length - 1, idx + dir));
items[focusIdx].focus({ preventScroll: true });
});
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var fieldRect0 = field.getBoundingClientRect();
var startX = fieldRect0.left + fieldRect0.width * 0.08;
var startY = fieldRect0.top + fieldRect0.height * 0.14;
var endX = fieldRect0.left + fieldRect0.width * 0.62;
var endY = fieldRect0.top + fieldRect0.height * 0.68;
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) demoStart = ts;
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1100) {
var p = Math.min(1, (t - 200) / 900);
var ease = 1 - Math.pow(1 - p, 2);
var cx = startX + (endX - startX) * ease;
var cy = startY + (endY - startY) * ease;
if (t < 220) beginLasso(startX, startY);
moveLasso(cx, cy);
} else if (t < 1450) {
// 선택 결과 관찰
} else if (t < 1480) {
endLasso();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysLassoSelect.module.scss";
type Box<T> = { current: T };
type Rect = { left: number; top: number; right: number; bottom: number };
export default function DragPhysLassoSelect() {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const lassoRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<HTMLButtonElement[]>([]);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const field = fieldRef.current;
const lasso = lassoRef.current;
const caption = captionRef.current;
const items = (itemRefs.current || []).filter(Boolean);
if (!root || !field || !lasso || !caption || items.length !== 6) return;
const LASSO_MIN_DRAG_PX = 4;
const selected: boolean[] = items.map(() => false);
let lassoing = false;
let lassoStart: { x: number; y: number } | null = null;
let focusIdx = 0;
function setSelected(idx: number, on: boolean) {
selected[idx] = on;
items[idx].classList.toggle(styles.isSelected, on);
items[idx].setAttribute("aria-selected", on ? "true" : "false");
}
function rectsIntersect(a: Rect, b: Rect) {
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}
function updateLassoSelection(rect: Rect) {
items.forEach(function (el, idx) {
const r = el.getBoundingClientRect();
setSelected(idx, rectsIntersect(rect, r));
});
}
function drawLasso(x0: number, y0: number, x1: number, y1: number): Rect {
const left = Math.min(x0, x1), top = Math.min(y0, y1);
const w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
const fieldRect = field!.getBoundingClientRect();
lasso!.style.left = left - fieldRect.left + "px";
lasso!.style.top = top - fieldRect.top + "px";
lasso!.style.width = w + "px";
lasso!.style.height = h + "px";
lasso!.style.opacity = "1";
return { left: left, top: top, right: left + w, bottom: top + h };
}
function updateCountCaption() {
const count = selected.filter(Boolean).length;
caption!.textContent = count > 0 ? count + "개 골랐어요" : "빈 곳을 끌면 사각형과 겹친 카드가 골라져요";
}
function beginLasso(clientX: number, clientY: number) {
lassoing = true;
lassoStart = { x: clientX, y: clientY };
root!.classList.add(styles.isDragging);
caption!.textContent = "겹치는 카드가 실시간으로 골라져요";
}
function moveLasso(clientX: number, clientY: number) {
if (!lassoing || !lassoStart) return;
const rect = drawLasso(lassoStart.x, lassoStart.y, clientX, clientY);
if (Math.abs(clientX - lassoStart.x) > LASSO_MIN_DRAG_PX || Math.abs(clientY - lassoStart.y) > LASSO_MIN_DRAG_PX) {
updateLassoSelection(rect);
}
}
function endLasso() {
lassoing = false;
root!.classList.remove(styles.isDragging);
lasso!.style.opacity = "0";
updateCountCaption();
}
function onFieldPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
if ((e.target as HTMLElement).classList.contains(styles.item)) return;
field!.setPointerCapture(e.pointerId);
beginLasso(e.clientX, e.clientY);
e.preventDefault();
}
function onFieldPointerMove(e: PointerEvent) {
if (!lassoing) return;
moveLasso(e.clientX, e.clientY);
e.preventDefault();
}
function onFieldPointerUp(e: PointerEvent) {
if (!lassoing) return;
endLasso();
e.preventDefault();
}
field.addEventListener("pointerdown", onFieldPointerDown);
field.addEventListener("pointermove", onFieldPointerMove);
field.addEventListener("pointerup", onFieldPointerUp);
const itemHandlers: { el: HTMLButtonElement; down: (e: PointerEvent) => void; click: () => void; key: (e: KeyboardEvent) => void }[] = [];
items.forEach(function (el, idx) {
function down(e: PointerEvent) {
root!.classList.remove("is-demo");
e.stopPropagation();
}
function click() {
root!.classList.remove("is-demo");
setSelected(idx, !selected[idx]);
updateCountCaption();
}
function key(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(idx, !selected[idx]);
updateCountCaption();
return;
}
let dir = 0;
if (e.key === "ArrowRight") dir = 1;
else if (e.key === "ArrowLeft") dir = -1;
else if (e.key === "ArrowDown") dir = 3;
else if (e.key === "ArrowUp") dir = -3;
else if (e.key === "Escape") {
e.preventDefault();
items.forEach(function (elx, i) { setSelected(i, false); });
updateCountCaption();
return;
} else { return; }
e.preventDefault();
focusIdx = Math.max(0, Math.min(items.length - 1, idx + dir));
items[focusIdx].focus({ preventScroll: true });
}
el.addEventListener("pointerdown", down);
el.addEventListener("click", click);
el.addEventListener("keydown", key);
itemHandlers.push({ el, down, click, key });
});
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
const fieldRect0 = field.getBoundingClientRect();
const startX = fieldRect0.left + fieldRect0.width * 0.08;
const startY = fieldRect0.top + fieldRect0.height * 0.14;
const endX = fieldRect0.left + fieldRect0.width * 0.62;
const endY = fieldRect0.top + fieldRect0.height * 0.68;
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) demoStart = ts;
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1100) {
const p = Math.min(1, (t - 200) / 900);
const ease = 1 - Math.pow(1 - p, 2);
const cx = startX + (endX - startX) * ease;
const cy = startY + (endY - startY) * ease;
if (t < 220) beginLasso(startX, startY);
moveLasso(cx, cy);
} else if (t < 1450) {
// 선택 결과 관찰
} else if (t < 1480) {
endLasso();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
field.removeEventListener("pointerdown", onFieldPointerDown);
field.removeEventListener("pointermove", onFieldPointerMove);
field.removeEventListener("pointerup", onFieldPointerUp);
itemHandlers.forEach(function (h) {
h.el.removeEventListener("pointerdown", h.down);
h.el.removeEventListener("click", h.click);
h.el.removeEventListener("keydown", h.key);
});
cancelAnimationFrame(demoRaf);
};
}, []);
const labels = ["1", "2", "3", "4", "5", "6"];
return (
<main className={styles.stage}>
<div className={`${styles.ls} is-demo`} ref={rootRef}>
<div
className={styles.field}
ref={fieldRef}
role="listbox"
aria-multiselectable="true"
aria-label="드래그해서 여러 카드를 한 번에 고르는 영역. 카드에 포커스한 뒤 Enter 로 하나씩 고를 수도 있습니다"
>
{labels.map(function (label, idx) {
return (
<button
key={label}
type="button"
className={styles.item}
role="option"
aria-selected="false"
ref={function (el: HTMLButtonElement | null) { if (el) itemRefs.current![idx] = el; }}
>{label}</button>
);
})}
<div className={styles.lasso} ref={lassoRef}></div>
</div>
<p className={styles.caption} ref={captionRef}>빈 곳을 끌면 사각형과 겹친 카드가 골라져요</p>
</div>
</main>
);
}08 引いた四角形の内側だけを選ぶ投げ縄セレクトドラッグ — 計測 累積面積 14.549% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算
このデモが属する記事
ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算「ドラッグ アンド ドロップ 実装」の作り方は9通りあります。磁力グリッドが24px以内でアイコンを捕まえ、ゴムのように伸びた境界がその手をまた放し、指を離した瞬間の速度が残りの距離を押し進める。 この記事のデモをすべて見る


