Selection Halo

Local-user selection halo with corner handles + label slot for spatial canvases.

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/selection-halo.json

Storybook

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

View in Storybook

3 stories available:

Code

"use client"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { cn } from "../../lib/utils"; /** * Geometric bounds of the selection in container px. * * @public */ export type SelectionBounds = { height: number; width: number; x: number; y: number; }; /** * Localizable strings. * * @public */ export type SelectionHaloLabels = { /** Aria-label for the halo. Defaults to `"Selection"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Selection", } as const satisfies Required<SelectionHaloLabels>; /** * Props for {@link SelectionHalo}. * * @public */ export type SelectionHaloProps = { /** Selection bounds in container pixels. */ bounds: SelectionBounds; /** Optional label rendered above the top-left corner. */ label?: ReactNode; /** Localizable strings. */ labels?: SelectionHaloLabels; /** When `true`, the ring pulses (use during multi-step transitions). */ pulsing?: boolean; } & Omit<ComponentPropsWithoutRef<"div">, "style">; /** * Local-user selection halo for a canvas. Outlines a rectangular * region with a primary ring + handles at every corner. Pure * presentation — the host computes bounds (single object, group * bounding box, lasso result) and toggles the wrapper. * * Distinct from {@link SelectionPresence}: this halo represents the * **local** user's selection (with corner handles + label slot), while * `SelectionPresence` represents a remote participant's selection. * * @example * ```tsx * <SelectionHalo bounds={{ x: 80, y: 60, width: 200, height: 120 }} label="3 selected" /> * ``` * * @public */ export const SelectionHalo = ({ ref, ...props }: SelectionHaloProps & { ref?: React.Ref<HTMLDivElement> }) => { const { bounds, className, label, labels, pulsing = false, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; return ( <div aria-label={resolvedLabels.region} className={cn( "pointer-events-none absolute z-20 rounded-md ring-2 ring-primary", pulsing ? "animate-pulse" : "", className, )} data-pulsing={pulsing ? "true" : undefined} data-selection-halo ref={ref} style={{ height: `${bounds.height.toString()}px`, left: `${bounds.x.toString()}px`, top: `${bounds.y.toString()}px`, width: `${bounds.width.toString()}px`, }} {...rest} > {(["nw", "ne", "se", "sw"] as const).map((corner) => ( <span aria-hidden="true" className={cn( "absolute size-2 rounded-sm border-2 border-primary bg-background", corner === "nw" && "-left-1 -top-1", corner === "ne" && "-right-1 -top-1", corner === "se" && "-bottom-1 -right-1", corner === "sw" && "-bottom-1 -left-1", )} data-handle-corner={corner} key={corner} /> ))} {label ? ( <span className="absolute -top-6 left-0 inline-flex items-center rounded-md bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground shadow-sm" data-selection-label > {label} </span> ) : null} </div> ); }; SelectionHalo.displayName = "SelectionHalo";

Dependencies

  • @vllnt/ui@^0.3.0