Context Lens

Vignette overlay that dims the canvas outside a circular focus region.

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/context-lens.json

Storybook

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

View in Storybook

4 stories available:

Code

"use client"; import { type ComponentPropsWithoutRef, forwardRef, useId } from "react"; import { cn } from "../../lib/utils"; /** * Focus point — pixel coordinates relative to the canvas viewport. * * @public */ export type ContextLensFocus = { /** Center X of the lens. */ cx: number; /** Center Y of the lens. */ cy: number; /** Inner radius — fully transparent inside this circle. */ inner: number; /** Outer radius — full dim beyond this circle. */ outer: number; }; /** * Localizable strings. * * @public */ export type ContextLensLabels = { /** Aria-label for the lens layer. Defaults to `"Context lens"`. */ region?: string; }; const DEFAULT_LABELS = { region: "Context lens", } as const satisfies Required<ContextLensLabels>; /** * Props for {@link ContextLens}. * * @public */ export type ContextLensProps = { /** Lens geometry. When `null`, no lens renders (full surface unobscured). */ focus: ContextLensFocus | null; /** Localizable strings. */ labels?: ContextLensLabels; /** Dim opacity outside the outer ring (0..1). Defaults to `0.55`. */ opacity?: number; } & ComponentPropsWithoutRef<"svg">; const clamp01 = (value: number): number => { if (value < 0) { return 0; } if (value > 1) { return 1; } return value; }; const safeRadius = (value: number): number => (value < 0 ? 0 : value); /** * Vignette overlay that dims the canvas outside a circular focus * region. Use to draw the eye to a selection, an active run, or any * single object the user must attend to. Pure presentation; the host * computes the focus center + radii from the active selection's * bounding box. * * The lens is `pointer-events: none` — host gestures pass through. * * @example * ```tsx * <div className="relative h-screen w-screen"> * <Canvas /> * <ContextLens focus={{ cx: 480, cy: 320, inner: 90, outer: 180 }} /> * </div> * ``` * * @public */ export const ContextLens = forwardRef<SVGSVGElement, ContextLensProps>( (props, ref) => { const { className, focus, labels, opacity = 0.55, ...rest } = props; const maskId = useId(); if (!focus) { return null; } const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const inner = safeRadius(focus.inner); const outer = safeRadius(Math.max(focus.outer, inner)); const fillOpacity = clamp01(opacity); return ( <svg aria-hidden="true" aria-label={resolvedLabels.region} className={cn( "pointer-events-none absolute inset-0 z-20 h-full w-full", className, )} data-context-lens ref={ref} {...rest} > <defs> <radialGradient cx={focus.cx} cy={focus.cy} data-context-lens-gradient gradientUnits="userSpaceOnUse" id={maskId} r={outer === 0 ? 1 : outer} > <stop offset="0%" stopColor="black" stopOpacity={0} /> <stop offset={outer === 0 ? "100%" : `${(inner / outer) * 100}%`} stopColor="black" stopOpacity={0} /> <stop offset="100%" stopColor="black" stopOpacity={1} /> </radialGradient> </defs> <rect data-context-lens-dim fill="black" fillOpacity={fillOpacity} height="100%" mask={`url(#${maskId}-mask)`} width="100%" x={0} y={0} /> <mask id={`${maskId}-mask`}> <rect fill="white" height="100%" width="100%" x={0} y={0} /> <circle cx={focus.cx} cy={focus.cy} fill={`url(#${maskId})`} r={outer} /> </mask> </svg> ); }, ); ContextLens.displayName = "ContextLens";

Dependencies

  • @vllnt/ui@^0.2.1