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.
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.jsonSource
"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.