Return

Magnetic Lasso

Drag to select. Gather the work.

magnetic-lasso
ALL 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.

Why it feels direct: selection happens exactly where you drag—without a mode switch or toolbar. The collection stays still while you mark an area, then moves only when the gesture has a clear outcome.

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

items
MagneticLassoItem[]

Collection items to place on the board. x and y are percentage positions; kind chooses landscape, portrait, or square framing.

selectedIdsundefined
string[]

Controlled selection state. Supply this with onSelectionChange when another part of the interface needs the selected fragments.

defaultSelectedIds[]
string[]

Initial selection for an uncontrolled lasso.

onSelectionChangeundefined
(ids: string[]) => void

Called after the user releases a marquee or toggles a focused card with the keyboard.

classNameundefined
string

Classes 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.