Multi Select Lasso

Selection rectangle overlay for canvas multi-select gestures.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/multi-select-lasso.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, } from "react"; import { cn } from "../../lib/utils"; /** * Lasso geometry — pixel coordinates on the underlying canvas. * * @public */ export type LassoRect = { /** Height in pixels (always positive after normalization). */ height: number; /** Width in pixels (always positive after normalization). */ width: number; /** Left edge in pixels. */ x: number; /** Top edge in pixels. */ y: number; }; /** * Localizable strings. * * @public */ export type MultiSelectLassoLabels = { /** Plural noun for the count badge. Defaults to `"items"`. */ plural?: string; /** Aria-label for the lasso layer. Defaults to `"Multi-select lasso"`. */ region?: string; /** Singular noun for the count badge. Defaults to `"item"`. */ singular?: string; }; const DEFAULT_LABELS = { plural: "items", region: "Multi-select lasso", singular: "item", } as const satisfies Required<MultiSelectLassoLabels>; /** * Props for {@link MultiSelectLasso}. * * @public */ export type MultiSelectLassoProps = { /** Optional count badge — rendered near the bottom-right corner. */ count?: number; /** Optional override slot rendered inside the lasso (e.g. a label). */ hint?: ReactNode; /** Localizable strings. */ labels?: MultiSelectLassoLabels; /** Drag rectangle in pixels. When `null`, nothing renders. */ rect: LassoRect | null; } & ComponentPropsWithoutRef<"div">; const normalizeRect = (rect: LassoRect): LassoRect => { const x = rect.width < 0 ? rect.x + rect.width : rect.x; const y = rect.height < 0 ? rect.y + rect.height : rect.y; const width = Math.abs(rect.width); const height = Math.abs(rect.height); return { height, width, x, y }; }; /** * Selection rectangle for canvas multi-select. Render as a sibling of * the canvas with `position: absolute` parent so the `rect` coordinates * land in the same pixel space. Pure presentation; the host owns the * pointer-down/move/up lifecycle and produces the rect (or `null` to * hide the lasso). * * @example * ```tsx * <div className="relative h-screen w-screen" onPointerDown={}> * <Canvas /> * <MultiSelectLasso rect={lassoRect} count={hoveredIds.length} /> * </div> * ``` * * @public */ export const MultiSelectLasso = forwardRef< HTMLDivElement, MultiSelectLassoProps >((props, ref) => { const { className, count, hint, labels, rect, ...rest } = props; if (!rect) { return null; } const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const normalized = normalizeRect(rect); if (normalized.width === 0 || normalized.height === 0) { return null; } const noun = count === 1 ? resolvedLabels.singular : resolvedLabels.plural; return ( <div aria-hidden="true" aria-label={resolvedLabels.region} className={cn( "pointer-events-none absolute z-30 rounded-md border-2 border-blue-500/70 bg-blue-500/10", className, )} data-multi-select-lasso ref={ref} style={{ height: normalized.height, left: normalized.x, top: normalized.y, width: normalized.width, }} {...rest} > {hint ? ( <span className="absolute -top-7 left-0 rounded-md bg-foreground/90 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-background" data-multi-select-hint > {hint} </span> ) : null} {typeof count === "number" ? ( <span className="absolute -bottom-7 right-0 rounded-full bg-foreground px-2 py-0.5 text-[10px] font-semibold text-background" data-multi-select-count > {count} {noun} </span> ) : null} </div> ); }); MultiSelectLasso.displayName = "MultiSelectLasso";

Dependencies

  • @vllnt/ui@^0.2.1