Selection Halo

Local-user selection halo with corner handles + label slot for spatial canvases. Install Selection Halo from the VLLNT UI registry with the shadcn CLI.

Report a bug

Local-user selection halo with corner handles + label slot for spatial canvases. Part of the data-display family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

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

Installation

Add Selection Halo to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/selection-halo.json

Source

"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";

Stories

Explore every variant and state in the interactive Storybook:

Preview

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

Dependencies

  • @vllnt/ui@^0.3.0