Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/spotlight-card.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
spotlight-card.tsx. It's yours: change anything.
Usage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
| spotlightColor | string | --primary at 18% | Colour of the light. Any CSS colour; translucent colours look best. |
| spotlightSize | number | 360 | Radius of the light, in pixels. |
| borderGlow | boolean | true | Also light the card's edge under the pointer. |
| pattern | "none" | "dots" | "grid" | "none" | A texture that shows only where the light falls. |
| SpotlightGroup | component | — | Wrap a grid of cards so the light carries across their borders. |
| ...props | HTMLAttributes<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
- v1.0.1Latest
Fix: the light now tracks the pointer exactly when the card sits inside a scaled or transformed container.