Magnetic Lasso
Drag to select. Gather the work.
Drag a selection box around collection items. To select with a keyboard, focus an item and press Enter or Space.
Drag across any work. It converges only after you release.
Source
components/interior/magnetic-lasso.tsx"use client";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export type MagneticLassoItem = {
id: string;
ink?: string;
kind?: "landscape" | "portrait" | "square";
label: string;
pattern?: "arc" | "dash" | "dots" | "grid";
tone: string;
x: number;
y: number;
};
export type MagneticLassoProps = {
className?: string;
defaultSelectedIds?: string[];
items: MagneticLassoItem[];
onSelectionChange?: (ids: string[]) => void;
selectedIds?: string[];
};
type Point = { x: number; y: number };
const pointFromEvent = (event: PointerEvent, rect: DOMRect): Point => {
return {
x: ((event.clientX - rect.left) / rect.width) * 100,
y: ((event.clientY - rect.top) / rect.height) * 100,
};
};
const inBounds = (point: Point, start: Point, end: Point) => {
const left = Math.min(start.x, end.x);
const right = Math.max(start.x, end.x);
const top = Math.min(start.y, end.y);
const bottom = Math.max(start.y, end.y);
return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
};
const dimensionsFor = (kind: MagneticLassoItem["kind"]) => {
if (kind === "portrait") return "h-[7.8rem] w-[5.75rem]";
if (kind === "landscape") return "h-[4.9rem] w-[7.8rem]";
return "size-[5.9rem]";
};
const dotColumns = [14, 32, 50, 68, 86] as const;
const gridColumns = [16, 39, 62, 85] as const;
const rowsFor = (kind: MagneticLassoItem["kind"], grid = false) =>
kind === "landscape"
? grid ? [20, 50, 80] : [18, 50, 82]
: grid ? [14, 33, 52, 71, 88] : [14, 31, 48, 65, 82];
const aspectFor = (kind: MagneticLassoItem["kind"]) =>
kind === "landscape" ? 7.8 / 4.9 : kind === "portrait" ? 5.75 / 7.8 : 1;
const trianglePath = (cx: number, cy: number, aspect: number, size = 2.5) =>
`M ${cx} ${cy - size} L ${cx + size / aspect} ${cy + size} L ${cx - size / aspect} ${cy + size} Z`;
function CardPattern({ kind, pattern = "dots", stroke = "#171717" }: Pick<MagneticLassoItem, "kind" | "pattern"> & { stroke?: string }) {
const aspect = aspectFor(kind);
const dotPositions = rowsFor(kind).flatMap((cy) => dotColumns.map((cx) => [cx, cy] as const));
const gridPositions = rowsFor(kind, true).flatMap((cy) => gridColumns.map((cx) => [cx, cy] as const));
if (pattern === "arc") {
return (
<svg aria-hidden className="absolute inset-0 size-full" preserveAspectRatio="none" viewBox="0 0 100 100">
<path d="M-12 28C26 39 64 20 79-12M-4 69H104" fill="none" stroke={stroke} strokeDasharray="4 5" strokeOpacity="0.48" strokeWidth="1.15" />
</svg>
);
}
if (pattern === "dash") {
return (
<svg aria-hidden className="absolute inset-0 size-full" preserveAspectRatio="none" viewBox="0 0 100 100">
<path d="M-4 30H104M-4 69H104" fill="none" stroke={stroke} strokeDasharray="4 5" strokeOpacity="0.54" strokeWidth="1.15" />
</svg>
);
}
if (pattern === "grid") {
return (
<svg aria-hidden className="absolute inset-0 size-full" preserveAspectRatio="none" viewBox="0 0 100 100">
{gridPositions.map(([cx, cy], index) => (
index === 5 || index === gridPositions.length - 6 ? (
<path key={`${cx}-${cy}`} d={trianglePath(cx, cy, aspect, 2.25)} fill="none" opacity="0.4" stroke={stroke} strokeWidth="1" />
) : (
<ellipse key={`${cx}-${cy}`} cx={cx} cy={cy} fill={index % 5 === 0 ? stroke : "none"} opacity={0.16 + ((index * 11) % 6) * 0.065} rx={1.7 / aspect} ry="1.7" stroke={stroke} strokeWidth="1" />
)
))}
</svg>
);
}
return (
<svg aria-hidden className="absolute inset-0 size-full" preserveAspectRatio="none" viewBox="0 0 100 100">
{dotPositions.map(([cx, cy], index) => (
index === 12 || index === 17 ? (
<path key={`${cx}-${cy}`} d={trianglePath(cx, cy, aspect)} fill={index === 12 ? stroke : "none"} opacity={index === 12 ? 0.32 : 0.46} stroke={stroke} strokeWidth="1.1" />
) : (
<ellipse key={`${cx}-${cy}`} cx={cx} cy={cy} fill={index < 8 ? stroke : "none"} opacity={0.18 + index * 0.022} rx={1.9 / aspect} ry="1.9" stroke={stroke} strokeWidth="1.1" />
)
))}
</svg>
);
}
/**
* A lightweight marquee selector for collection-like interfaces. Drag over a
* group of items, release, and the selected work gathers with a soft spring.
*/
export function MagneticLasso({
className,
defaultSelectedIds = [],
items,
onSelectionChange,
selectedIds,
}: MagneticLassoProps) {
const [uncontrolledSelectedIds, setUncontrolledSelectedIds] = useState(defaultSelectedIds);
const reducedMotion = useReducedMotion() ?? false;
const selection = selectedIds ?? uncontrolledSelectedIds;
const startPointRef = useRef<Point | null>(null);
const latestPointRef = useRef<Point | null>(null);
const pointerIdRef = useRef<number | null>(null);
const boundsRef = useRef<DOMRect | null>(null);
const frameRef = useRef<number | null>(null);
const fadeRef = useRef<number | null>(null);
const fillRef = useRef<SVGRectElement>(null);
const outlineRef = useRef<SVGRectElement>(null);
const descriptionId = useId();
const setSelection = (nextSelection: string[]) => {
if (selectedIds === undefined) setUncontrolledSelectedIds(nextSelection);
onSelectionChange?.(nextSelection);
};
const clearMarquee = () => {
fillRef.current?.setAttribute("opacity", "0");
outlineRef.current?.setAttribute("opacity", "0");
};
const cancelFade = () => {
if (fadeRef.current !== null) {
window.clearTimeout(fadeRef.current);
fadeRef.current = null;
}
};
const abortGesture = () => {
if (frameRef.current !== null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
pointerIdRef.current = null;
startPointRef.current = null;
latestPointRef.current = null;
boundsRef.current = null;
clearMarquee();
};
const paintMarquee = () => {
frameRef.current = null;
const start = startPointRef.current;
const end = latestPointRef.current;
if (!start || !end) return;
const x = Math.min(start.x, end.x);
const y = Math.min(start.y, end.y);
const width = Math.abs(start.x - end.x);
const height = Math.abs(start.y - end.y);
for (const rect of [fillRef.current, outlineRef.current]) {
rect?.setAttribute("x", x.toFixed(2));
rect?.setAttribute("y", y.toFixed(2));
rect?.setAttribute("width", width.toFixed(2));
rect?.setAttribute("height", height.toFixed(2));
rect?.setAttribute("opacity", width > 1 || height > 1 ? "1" : "0");
}
};
const schedulePaint = () => {
if (frameRef.current === null) frameRef.current = window.requestAnimationFrame(paintMarquee);
};
useEffect(() => () => {
if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
if (fadeRef.current !== null) window.clearTimeout(fadeRef.current);
}, []);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.isPrimary || (event.pointerType === "mouse" && event.button !== 0)) return;
abortGesture();
cancelFade();
const bounds = event.currentTarget.getBoundingClientRect();
const point = pointFromEvent(event.nativeEvent, bounds);
pointerIdRef.current = event.pointerId;
boundsRef.current = bounds;
startPointRef.current = point;
latestPointRef.current = point;
event.currentTarget.setPointerCapture(event.pointerId);
paintMarquee();
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (pointerIdRef.current !== event.pointerId) return;
const bounds = boundsRef.current;
if (!bounds) return;
latestPointRef.current = pointFromEvent(event.nativeEvent, bounds);
schedulePaint();
};
const finishSelection = (event: React.PointerEvent<HTMLDivElement>, cancelled = false) => {
if (pointerIdRef.current !== event.pointerId) return;
pointerIdRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
if (frameRef.current !== null) {
window.cancelAnimationFrame(frameRef.current);
paintMarquee();
}
const start = startPointRef.current;
const end = latestPointRef.current;
const width = start && end ? Math.abs(start.x - end.x) : 0;
const height = start && end ? Math.abs(start.y - end.y) : 0;
if (!cancelled && start && end && width > 5 && height > 5) {
setSelection(items.filter((item) => inBounds(item, start, end)).map((item) => item.id));
}
fadeRef.current = window.setTimeout(() => {
clearMarquee();
startPointRef.current = null;
latestPointRef.current = null;
boundsRef.current = null;
fadeRef.current = null;
}, reducedMotion ? 0 : 180);
};
const toggleItem = (id: string) => {
setSelection(selection.includes(id) ? selection.filter((itemId) => itemId !== id) : [...selection, id]);
};
const selectedItems = items.filter((item) => selection.includes(item.id));
return (
<div
aria-describedby={descriptionId}
aria-label="Drag a selection box around collection items"
className={cn("relative isolate min-h-80 touch-none select-none overflow-hidden", className)}
onPointerCancel={(event) => finishSelection(event, true)}
onPointerDown={onPointerDown}
onLostPointerCapture={(event) => {
if (pointerIdRef.current === event.pointerId) abortGesture();
}}
onPointerMove={onPointerMove}
onPointerUp={finishSelection}
role="application"
>
<div className="pointer-events-none absolute right-4 top-4 z-30 text-[10px] font-medium tracking-[0.12em] text-black/45 dark:text-white/40">
<span>{selection.length > 0 ? `${selection.length} SELECTED` : "ALL WORK"}</span>
</div>
<div className="pointer-events-none absolute inset-0 opacity-[0.025] [background-image:radial-gradient(circle_at_center,currentColor_1px,transparent_1px)] [background-size:13px_13px]" />
{items.map((item) => {
const selectedIndex = selectedItems.findIndex((selectedItem) => selectedItem.id === item.id);
const selected = selectedIndex !== -1;
const gatheredX = 50 + (selectedIndex - (selectedItems.length - 1) / 2) * 11.5;
const gatheredY = 52 + (selectedIndex % 2 === 0 ? -2 : 2);
return (
<motion.button
key={item.id}
type="button"
aria-pressed={selected}
aria-label={`${selected ? "Remove" : "Select"} ${item.label}`}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleItem(item.id);
}
}}
initial={false}
animate={{
left: `${selected ? gatheredX : item.x}%`,
top: `${selected ? gatheredY : item.y}%`,
zIndex: selected ? 15 : 10,
opacity: selection.length > 0 && !selected ? 0.28 : 1,
scale: selected ? 1.04 : 1,
y: 0,
}}
transition={reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 360, damping: 29, mass: 0.72 }}
className={cn(
"absolute z-10 -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl text-left shadow-[0_10px_28px_rgba(35,31,28,0.11)] outline-none transition-[box-shadow,filter] focus-visible:ring-2 focus-visible:ring-[#f05b43] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f7f5f1] dark:shadow-none dark:focus-visible:ring-offset-[#191918]",
dimensionsFor(item.kind),
)}
style={{ backgroundColor: item.tone }}
>
<CardPattern kind={item.kind} pattern={item.pattern} stroke={item.ink} />
</motion.button>
);
})}
<svg
aria-hidden
className="pointer-events-none absolute inset-0 z-20 size-full"
preserveAspectRatio="none"
viewBox="0 0 100 100"
>
<rect ref={fillRef} fill="rgba(240,91,67,0.11)" opacity="0" rx="0.6" />
<rect ref={outlineRef} fill="none" opacity="0" rx="0.6" stroke="#ef5a43" strokeDasharray="1.3 1.1" strokeLinecap="round" strokeWidth="0.45" />
</svg>
<p id={descriptionId} className="sr-only">Drag a selection box around collection items. To select with a keyboard, focus an item and press Enter or Space.</p>
</div>
);
}
Usage
fragment-board.tsx"use client";
import { useState } from "react";
import { MagneticLasso, type MagneticLassoItem } from "@/components/interior/magnetic-lasso";
const fragments: MagneticLassoItem[] = [
{ id: "north", kind: "portrait", label: "NORTH", pattern: "arc", tone: "#f13b1b", x: 20, y: 38 },
{ id: "signal", kind: "landscape", label: "SIGNAL", pattern: "grid", tone: "#171717", ink: "#f3f1e9", x: 58, y: 30 },
{ id: "field", kind: "landscape", label: "FIELD", pattern: "dots", tone: "#58b985", x: 34, y: 72 },
];
export function FragmentBoard() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
return (
<MagneticLasso
items={fragments}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
className="min-h-96"
/>
);
}Props
MagneticLassoItem[]Collection items to place on the board. x and y are percentage positions; kind chooses landscape, portrait, or square framing.
string[]Controlled selection state. Supply this with onSelectionChange when another part of the interface needs the selected fragments.
string[]Initial selection for an uncontrolled lasso.
(ids: string[]) => voidCalled after the user releases a marquee or toggles a focused card with the keyboard.
stringClasses applied to the board. Use min-h-* to choose the interaction area.
Interaction inspiration: visual selection in tools such as Rivet. This lasso, presentation, and motion are independently implemented for this experiment.