08Lasso-Select Drag That Picks Only What's Inside the Dragged Rectangle
A rectangle is recalculated every frame from the drag's start point and the current pointer position, and every card overlapping it switches to selected instantly.
Best forSelecting several gallery items at once
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="An area where you drag to select several cards at once. You can also focus a card and press Enter to pick it one by one">
<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">Drag from an empty spot and cards overlapping the rectangle get selected</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 = 'Overlapping cards get selected in real time';
}
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 + ' selected' : 'Drag from an empty spot and cards overlapping the rectangle get selected';
}
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 + ' selected' : 'Drag from an empty spot and cards overlapping the rectangle get selected';
});
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 + ' selected' : 'Drag from an empty spot and cards overlapping the rectangle get selected';
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 = 'Drag from an empty spot and cards overlapping the rectangle get selected';
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 Lasso-Select Drag That Picks Only What's Inside the Dragged Rectangle — Measured area 14.549% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · Drag and Drop Animation CSS: 9 Physics Patterns
The post this demo lives in
Drag and Drop Animation CSS: 9 Physics PatternsNine drag and drop animation css builds: A magnetic grid catches the icon inside a 24px radius, a rubber-band edge lets See all demos in this post
If you like this effect - similar demos
drag04 Handle-only — capturing the pointer on a small grip so the rest of the row stays interactiveDrag to Reorder UI: 9 Lists, One Drop Index
drag02 A Snap That Pulls Only Inside Distance and Angle ThresholdsJigsaw Puzzle JavaScript: 9 Snapping Tab Boards
drag03 Multi-file — a group that counts entries and computes filled slots versus the remainderDrag and Drop File Upload UI: 9 Computed Dropzones