freeNew138 lines · framer-motion
Card Spotlight Grid
A spotlight that only ever lights the border. A grid of cards whose 1px border lights up in a radial spotlight that follows the cursor, built with a masked gradient so the glow never leaks into the card body.
- Use it for
- A features, integrations or tool grid where you want a premium cursor-reactive feel without a glow that washes out the copy inside each card.
- Themes
- Light + dark
- License
- Commercial use
Live
Building blocks
Cards that light up where you point
Instant context
Every tool call carries the thread it came from, so nothing needs re-explaining.
Scoped access
Each card the agent can draw gets its own narrow set of permissions.
Composable runs
Chain cards into a pipeline without writing glue code between them.
Always watched
A quiet trace runs underneath, ready the moment something looks off.
tsx
"use client";
import * as React from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
export interface SpotlightCardItem {
title: string;
description: string;
icon?: "bolt" | "shield" | "layers" | "radar";
}
export interface CardSpotlightGridProps {
eyebrow?: string;
title?: string;
cards?: SpotlightCardItem[];
/** Colour of the cursor spotlight on the card borders. */
spotlightColor?: "signal" | "info" | "lime" | "ok";
/** Radius in px of the spotlight. */
radius?: number;
columns?: 2 | 3;
className?: string;
}
const DEFAULT_CARDS: SpotlightCardItem[] = [
{ title: "Instant context", description: "Every tool call carries the thread it came from, so nothing needs re-explaining.", icon: "bolt" },
{ title: "Scoped access", description: "Each card the agent can draw gets its own narrow set of permissions.", icon: "shield" },
{ title: "Composable runs", description: "Chain cards into a pipeline without writing glue code between them.", icon: "layers" },
{ title: "Always watched", description: "A quiet trace runs underneath, ready the moment something looks off.", icon: "radar" },
];
const colorVar: Record<NonNullable<CardSpotlightGridProps["spotlightColor"]>, string> = {
signal: "var(--signal)",
info: "var(--info)",
lime: "var(--lime)",
ok: "var(--ok)",
};
function Icon({ name }: { name: NonNullable<SpotlightCardItem["icon"]> }) {
const common = { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, className: "size-5" };
switch (name) {
case "bolt":
return (
<svg {...common}>
<path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />
</svg>
);
case "shield":
return (
<svg {...common}>
<path d="M12 3 4 6v6c0 5 3.5 7.5 8 9 4.5-1.5 8-4 8-9V6l-8-3Z" />
</svg>
);
case "layers":
return (
<svg {...common}>
<path d="m12 3 9 5-9 5-9-5 9-5Z" />
<path d="m3 13 9 5 9-5" />
</svg>
);
case "radar":
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 12 18 7" />
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
</svg>
);
}
}
function SpotlightCard({ card, color, radius }: { card: SpotlightCardItem; color: string; radius: number }) {
const reduce = useReducedMotionSafe();
const mx = useMotionValue(50);
const my = useMotionValue(50);
const [active, setActive] = React.useState(false);
const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mx}% ${my}%, ${color}, transparent 72%)`;
return (
<div
className="group relative rounded-3xl bg-border p-px transition-colors"
onPointerMove={(e) => {
if (reduce) return;
const r = e.currentTarget.getBoundingClientRect();
mx.set(((e.clientX - r.left) / r.width) * 100);
my.set(((e.clientY - r.top) / r.height) * 100);
}}
onPointerEnter={() => setActive(true)}
onPointerLeave={() => setActive(false)}
>
<motion.div aria-hidden className="pointer-events-none absolute inset-0 rounded-3xl" style={{ background }} animate={{ opacity: active && !reduce ? 1 : 0 }} transition={{ duration: 0.25 }} />
<div className="relative z-10 h-full rounded-[calc(1.5rem-1px)] bg-card p-6">
<span className="inline-flex size-10 items-center justify-center rounded-xl bg-muted text-foreground transition-colors duration-300 group-hover:bg-signal/10 group-hover:text-signal">
{card.icon && <Icon name={card.icon} />}
</span>
<h3 className="mt-4 font-display text-lg font-bold tracking-tight text-foreground">{card.title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{card.description}</p>
</div>
</div>
);
}
const colsClass: Record<2 | 3, string> = { 2: "sm:grid-cols-2", 3: "sm:grid-cols-2 lg:grid-cols-4" };
export function CardSpotlightGrid({
eyebrow = "Building blocks",
title = "Cards that light up where you point",
cards = DEFAULT_CARDS,
spotlightColor = "signal",
radius = 180,
columns = 3,
className,
}: CardSpotlightGridProps) {
const color = colorVar[spotlightColor];
return (
<section className={cn("w-full bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
<div className="mx-auto max-w-3xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.2em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(2rem,5vw,3.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-foreground">{title}</h2>
</div>
<div className={cn("mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-4", colsClass[columns])}>
{cards.map((c) => (
<SpotlightCard key={c.title} card={c} color={color} radius={radius} />
))}
</div>
</section>
);
}
export default CardSpotlightGrid;
How it behaves
- →Each card tracks pointer position locally and renders a radial-gradient spotlight behind a 1px padding ring, masked so only the border shows the glow (the gradient sits beneath an inner card that covers everything but that ring).
- →The spotlight fades in and out with opacity on pointer enter/leave instead of popping.
- →spotlightColor and radius are passed straight into the gradient so the effect can match any accent token.
- →Pointer tracking is skipped entirely under prefers-reduced-motion, leaving a plain bordered card.
Accessibility
- ✓The spotlight layer is `aria-hidden` and `pointer-events-none`; it never sits between the pointer and real content.
- ✓Icons are decorative (`aria-hidden`); card titles are real headings read by screen readers in document order.
- ✓Honours prefers-reduced-motion.