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.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook4 stories available:
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";
typescript