Skip to content
Uxelith
Sign in
Freev1.0.1Cards

Spotlight Card

A card lit by a soft light that glides after the pointer, with a lit edge and an optional texture that shows only under the light. Group several and the light carries across their borders. Keyboard focus lights it too.

pnpm dlx shadcn add …/r/spotlight-card.json
Licence
MIT
Packages
2
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/spotlight-card.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as spotlight-card.tsx. It's yours: change anything.

Usage

example.tsx
import { SpotlightCard, SpotlightGroup } from "@/components/spotlight-card"

export function Features() {
  return (
    <SpotlightGroup className="grid gap-3 sm:grid-cols-2">
      <SpotlightCard pattern="dots" className="p-6">
        <h3 className="font-semibold">Fast by default</h3>
        <p className="text-sm text-muted-foreground">The light never re-renders React.</p>
      </SpotlightCard>
      <SpotlightCard pattern="dots" className="p-6">
        <h3 className="font-semibold">Edge-lit</h3>
        <p className="text-sm text-muted-foreground">Neighbouring borders catch the light.</p>
      </SpotlightCard>
    </SpotlightGroup>
  )
}

Source

One file, 216 lines, nothing hidden. Read it before you install it.

spotlight-card.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface SpotlightCardProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Colour of the light. Any CSS colour. Defaults to the theme's `--primary` at low strength. */
  spotlightColor?: string;
  /** Radius of the light in pixels. */
  spotlightSize?: number;
  /** Also light the card's border under the pointer. */
  borderGlow?: boolean;
  /** A texture that only shows where the light falls. */
  pattern?: "none" | "dots" | "grid";
}

// How quickly the light catches up with the pointer (ms to close ~63% of the gap).
// A frame-rate-independent ease written out here, so the card needs no animation library.
const FOLLOW_MS = 70;

const light = (color: string) =>
  `radial-gradient(var(--spotlight-size) circle at var(--spotlight-x, 50%) var(--spotlight-y, 50%), ${color}, transparent 70%)`;

const patterns = {
  dots: "radial-gradient(circle at center, var(--spotlight-pattern) 1px, transparent 1.5px) 0 0 / 14px 14px",
  grid: "linear-gradient(var(--spotlight-pattern) 1px, transparent 1px) 0 0 / 24px 24px, linear-gradient(90deg, var(--spotlight-pattern) 1px, transparent 1px) 0 0 / 24px 24px",
};

const RING_MASK: React.CSSProperties = {
  mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
  WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
  WebkitMaskComposite: "xor",
};

type Aim = (clientX: number, clientY: number, inside: boolean) => void;
type Card = { node: HTMLDivElement; aim: Aim; leave: () => void };

const GroupContext = React.createContext<Set<Card> | null>(null);

/**
 * Wrap several cards (a feature grid) so the light carries across them: the
 * borders of neighbouring cards catch it as the pointer moves between them.
 */
export function SpotlightGroup({ className, onPointerMove, onPointerLeave, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const [cards] = React.useState(() => new Set<Card>());
  return (
    <GroupContext.Provider value={cards}>
      <div
        className={className}
        onPointerMove={(event) => {
          onPointerMove?.(event);
          for (const card of cards) card.aim(event.clientX, event.clientY, card.node.contains(event.target as Node));
        }}
        onPointerLeave={(event) => {
          onPointerLeave?.(event);
          for (const card of cards) card.leave();
        }}
        {...props}
      />
    </GroupContext.Provider>
  );
}

/**
 * A card lit by a soft light that glides after the pointer.
 *
 * - The light eases after the pointer and is written to CSS variables, so
 *   moving the mouse never re-renders React. No animation library needed.
 * - The border catches the light; inside a `SpotlightGroup` it carries over
 *   to neighbouring cards. An optional texture shows only under the light.
 * - Keyboard users get the same light, centred, while anything inside has focus.
 * - With `prefers-reduced-motion` the light sits under the pointer without
 *   gliding and appears without a fade. It is decorative and hidden from
 *   assistive technology.
 */
export function SpotlightCard({
  spotlightColor,
  spotlightSize = 360,
  borderGlow = true,
  pattern = "none",
  className,
  style,
  children,
  onPointerMove,
  onPointerLeave,
  ...props
}: SpotlightCardProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const group = React.useContext(GroupContext);
  const lit = React.useRef(false);
  // Where the light is, where it is heading, and the frame moving it there.
  const follow = React.useRef({ x: 0, y: 0, tx: 0, ty: 0, frame: 0, last: 0 });

  const write = React.useCallback((x: number, y: number) => {
    ref.current?.style.setProperty("--spotlight-x", `${x}px`);
    ref.current?.style.setProperty("--spotlight-y", `${y}px`);
  }, []);

  React.useEffect(() => () => cancelAnimationFrame(follow.current.frame), []);

  const aim = React.useCallback<Aim>(
    (clientX, clientY, inside) => {
      const node = ref.current;
      if (!node) return;
      const rect = node.getBoundingClientRect();
      // rect is in screen pixels; divide by any ancestor scale to get the card's own pixels.
      const px = (clientX - rect.left) / (rect.width / node.offsetWidth || 1);
      const py = (clientY - rect.top) / (rect.height / node.offsetHeight || 1);
      const l = follow.current;
      l.tx = px;
      l.ty = py;
      // Arriving from outside, the light starts where the pointer is rather than flying in.
      if (!lit.current || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        cancelAnimationFrame(l.frame);
        l.frame = 0;
        l.x = px;
        l.y = py;
        write(px, py);
      } else if (!l.frame) {
        l.last = performance.now();
        const step = (now: number) => {
          const k = 1 - Math.exp(-(now - l.last) / FOLLOW_MS);
          l.last = now;
          l.x += (l.tx - l.x) * k;
          l.y += (l.ty - l.y) * k;
          const settled = Math.abs(l.tx - l.x) < 0.5 && Math.abs(l.ty - l.y) < 0.5;
          if (settled) {
            l.x = l.tx;
            l.y = l.ty;
          }
          write(l.x, l.y);
          l.frame = settled ? 0 : requestAnimationFrame(step);
        };
        l.frame = requestAnimationFrame(step);
      }
      lit.current = true;
      node.dataset.spotlight = inside ? "on" : "near";
    },
    [write],
  );

  const leave = React.useCallback(() => {
    const node = ref.current;
    if (!node) return;
    lit.current = false;
    delete node.dataset.spotlight;
  }, []);

  React.useEffect(() => {
    const node = ref.current;
    if (!group || !node) return;
    const card = { node, aim, leave };
    group.add(card);
    return () => {
      group.delete(card);
    };
  }, [group, aim, leave]);

  const fade = "opacity-0 transition-opacity duration-300 motion-reduce:transition-none";
  const on = "group-focus-within/spotlight:opacity-100 group-data-[spotlight=on]/spotlight:opacity-100";

  return (
    <div
      ref={ref}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        if (!group) aim(event.clientX, event.clientY, true);
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        if (!group) leave();
        else if (ref.current) ref.current.dataset.spotlight = "near";
      }}
      className={cn(
        "group/spotlight relative isolate overflow-hidden rounded-xl border border-border bg-card text-card-foreground",
        "shadow-[0_1px_2px_rgb(0_0_0/0.06),0_8px_24px_-12px_rgb(0_0_0/0.18)]",
        className,
      )}
      style={
        {
          "--spotlight-color": spotlightColor ?? "color-mix(in oklab, var(--primary) 16%, transparent)",
          "--spotlight-pattern": "color-mix(in oklab, var(--foreground) 22%, transparent)",
          "--spotlight-size": `${spotlightSize}px`,
          ...style,
        } as React.CSSProperties
      }
      {...props}
    >
      <div aria-hidden="true" className={cn("pointer-events-none absolute inset-0 -z-10", fade, on)} style={{ background: light("var(--spotlight-color)") }} />
      {pattern !== "none" && (
        <div
          aria-hidden="true"
          className={cn("pointer-events-none absolute inset-0 -z-10", fade, on)}
          style={{
            background: patterns[pattern],
            maskImage: light("#000"),
            WebkitMaskImage: light("#000"),
          }}
        />
      )}
      {borderGlow && (
        <div
          aria-hidden="true"
          className={cn("pointer-events-none absolute inset-0 rounded-[inherit] p-px", fade, on, "group-data-[spotlight=near]/spotlight:opacity-100")}
          style={{
            // Same hue as the fill, strong enough to read as a lit edge.
            background: light("oklch(from var(--spotlight-color) calc(l + 0.1) c h / 0.9)"),
            ...RING_MASK,
          }}
        />
      )}
      {children}
    </div>
  );
}

Props

PropTypeDefaultDescription
spotlightColorstring--primary at 18%Colour of the light. Any CSS colour; translucent colours look best.
spotlightSizenumber360Radius of the light, in pixels.
borderGlowbooleantrueAlso light the card's edge under the pointer.
pattern"none" | "dots" | "grid""none"A texture that shows only where the light falls.
SpotlightGroupcomponent—Wrap a grid of cards so the light carries across their borders.
...propsHTMLAttributes<HTMLDivElement>—Any div attribute. Your className is merged last, so it wins.

Accessibility

  • The light is decorative and marked aria-hidden.
  • When anything inside the card has keyboard focus, the light shows centred.
  • With prefers-reduced-motion the light sits under the pointer without gliding and appears without fading.
  • Contrast of text inside the card is unaffected: the light stays under the content at low strength.

Changelog

  1. v1.0.1Latest

    Fix: the light now tracks the pointer exactly when the card sits inside a scaled or transformed container.

More in Cards

All 6 in Cards
  • CardsPro

    Card Stack

    A deck you throw cards from: the top card leans from where you grabbed it and flies off with the speed you gave it, the rest rise to take its place, and Undo brings it back. Keys and buttons too.

  • CardsPro

    Compare Slider

    Two layers split by a glass handle you drag, or that follows the pointer. A press glides the divider there on a spring, a double-click sends it home; optional feathered seam, vertical split and first-view sweep.

  • CardsPro

    Morph Card

    A card that grows into its own story: the photo, eyebrow and title fly to their places, and you pull the story down to fold it back into the grid. Built on the native modal dialog.