Effects & Borders

Spotlight Search Row

List row whose background follows the cursor with a soft radial highlight, written per-element and subtle enough for dense result lists.

Install

npx shadcn@latest add @paragon/spotlight-search-row

spotlight-search-row.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface SpotlightSearchRowProps extends React.ComponentProps<"div"> {
  /** Highlight radius in px. */
  radius?: number;
  /** Highlight intensity, 0–1. */
  strength?: number;
  /** Disables the spotlight; the row renders unchanged. */
  static?: boolean;
}

/**
 * A list row whose background softly follows the cursor with a radial
 * highlight — subtle enough for data-dense result lists. The cursor position
 * is written as custom properties directly on the highlight element (never
 * the parent, so there's no style-recalc cascade), and the only transition
 * is opacity. Mouse pointers only; reduced motion disables the follow
 * entirely and leaves your normal hover styling in charge.
 */
export function SpotlightSearchRow({
  radius = 140,
  strength = 1,
  static: isStatic = false,
  className,
  children,
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  ...props
}: SpotlightSearchRowProps) {
  const reducedMotion = useReducedMotion();
  const layerRef = React.useRef<HTMLDivElement>(null);
  const enabled = !isStatic && !reducedMotion;
  const intensity = Math.min(Math.max(strength, 0), 1);

  const moveTo = (event: React.PointerEvent<HTMLDivElement>) => {
    const layer = layerRef.current;
    if (!layer) return;
    const rect = event.currentTarget.getBoundingClientRect();
    layer.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
    layer.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
  };

  return (
    <div
      data-slot="spotlight-search-row"
      className={cn("relative", className)}
      onPointerEnter={(event) => {
        onPointerEnter?.(event);
        if (!enabled || event.pointerType !== "mouse") return;
        moveTo(event);
        layerRef.current?.setAttribute("data-active", "");
      }}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        if (!enabled || event.pointerType !== "mouse") return;
        moveTo(event);
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        layerRef.current?.removeAttribute("data-active");
      }}
      {...props}
    >
      <div
        ref={layerRef}
        aria-hidden
        className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-200 ease-out data-active:opacity-100"
        style={{
          background: `radial-gradient(${radius}px circle at var(--spot-x, 50%) var(--spot-y, 50%), color-mix(in oklab, var(--color-foreground) ${(6 * intensity).toFixed(1)}%, transparent), transparent 70%)`,
        }}
      />
      {children}
    </div>
  );
}