freeNew150 lines · framer-motion
CTA Spotlight
A glow that follows you to the button. A centred call-to-action section with a cursor-follow radial spotlight behind the headline, scoped entirely to the section so it never escapes the block or moves the real cursor.
- Use it for
- A standalone call-to-action break on a landing page, or the closing section before the footer, where you want one focused push without a heavy gradient band.
- Themes
- Light + dark
- License
- Commercial use
Live
Open beta
Build the thing you keep putting off
One workspace for the whole flow: draft, review, ship. No setup ritual, no migration project.
tsx
"use client";
import * as React from "react";
import {
motion,
useInView,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
} 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;
}
/**
* Centred CTA with a cursor-follow radial spotlight scoped entirely to this
* section: pointer position is tracked only while hovering the section itself
* and converted to a percentage, so the glow (an absolute div inside a
* relative, overflow-hidden wrapper) can never escape the block or move the
* real page cursor. With no pointer interaction it rests slightly above
* centre and breathes gently.
*/
export interface CtaSpotlightProps {
eyebrow?: string;
headline?: string;
subcopy?: string;
primaryCta?: string;
secondaryCta?: string;
showSecondaryCta?: boolean;
onPrimaryClick?: () => void;
onSecondaryClick?: () => void;
className?: string;
}
const REST_X = 50;
const REST_Y = 36;
export function CtaSpotlight({
eyebrow = "Open beta",
headline = "Build the thing you keep putting off",
subcopy = "One workspace for the whole flow: draft, review, ship. No setup ritual, no migration project.",
primaryCta = "Start building",
secondaryCta = "View the docs",
showSecondaryCta = true,
onPrimaryClick,
onSecondaryClick,
className,
}: CtaSpotlightProps) {
const reduce = useReducedMotionSafe();
const sectionRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(sectionRef, { amount: 0.3 });
const [tabVisible, setTabVisible] = React.useState(true);
const [hovering, setHovering] = React.useState(false);
React.useEffect(() => {
const onVis = () => setTabVisible(!document.hidden);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
const rawX = useMotionValue(REST_X);
const rawY = useMotionValue(REST_Y);
const springX = useSpring(rawX, { stiffness: 120, damping: 22, mass: 0.6 });
const springY = useSpring(rawY, { stiffness: 120, damping: 22, mass: 0.6 });
const posX = reduce ? rawX : springX;
const posY = reduce ? rawY : springY;
const background = useMotionTemplate`radial-gradient(640px circle at ${posX}% ${posY}%, color-mix(in oklab, var(--signal) 85%, transparent) 0%, color-mix(in oklab, var(--signal) 30%, transparent) 32%, transparent 68%)`;
const canBreathe = !reduce && inView && tabVisible && !hovering;
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (reduce || e.pointerType !== "mouse") return;
const rect = e.currentTarget.getBoundingClientRect();
rawX.set(((e.clientX - rect.left) / rect.width) * 100);
rawY.set(((e.clientY - rect.top) / rect.height) * 100);
};
const handlePointerLeave = () => {
setHovering(false);
rawX.set(REST_X);
rawY.set(REST_Y);
};
return (
<section
ref={sectionRef}
onPointerMove={handlePointerMove}
onPointerEnter={() => setHovering(true)}
onPointerLeave={handlePointerLeave}
className={cn("relative isolate w-full overflow-hidden bg-background", className)}
>
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ background }}
animate={canBreathe ? { opacity: [0.65, 0.9, 0.65] } : { opacity: hovering ? 1 : 0.75 }}
transition={canBreathe ? { duration: 5, repeat: Infinity, ease: "easeInOut" } : { duration: 0.4 }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_60%_at_50%_30%,transparent,var(--background)_78%)]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 [background-image:radial-gradient(circle_at_1px_1px,var(--border)_1px,transparent_1px)] [background-size:26px_26px] opacity-[0.25] [mask-image:radial-gradient(ellipse_70%_55%_at_50%_30%,black,transparent_75%)]"
/>
<div className="relative mx-auto flex max-w-3xl flex-col items-center px-4 py-24 text-center sm:px-6 sm:py-28">
<span className="inline-flex items-center gap-2 rounded-full border border-border bg-card/80 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground backdrop-blur">
<span className="size-1.5 rounded-full bg-signal" aria-hidden />
{eyebrow}
</span>
<h2 className="mt-6 font-display text-[clamp(2.2rem,5.5vw,4rem)] font-extrabold leading-[1.02] tracking-[-0.03em]">
{headline}
</h2>
<p className="mt-4 max-w-xl text-base leading-relaxed text-muted-foreground sm:text-lg">{subcopy}</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<button
type="button"
onClick={onPrimaryClick}
className="inline-flex h-12 items-center rounded-full bg-primary px-6 text-[15px] font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal"
>
{primaryCta} →
</button>
{showSecondaryCta && secondaryCta ? (
<button
type="button"
onClick={onSecondaryClick}
className="inline-flex h-12 items-center rounded-full border-2 border-foreground px-6 text-[15px] font-semibold transition hover:bg-foreground hover:text-background focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal"
>
{secondaryCta}
</button>
) : null}
</div>
</div>
</section>
);
}
export default CtaSpotlight;
How it behaves
- →Pointer position is tracked only via onPointerMove on the section itself (mouse pointers only), converted to a 0-100% coordinate pair and fed into a CSS radial-gradient built with framer-motion's useMotionTemplate, so the glow is pure CSS and already theme-correct via var(--signal)/var(--background).
- →The glow position is smoothed with useSpring so it eases toward the cursor instead of snapping; on pointer leave it springs back to a fixed resting point slightly above centre.
- →When idle (no hover, in view, tab visible, motion not reduced) the glow opacity breathes slowly on an infinite loop; it pauses via useInView (IntersectionObserver) when scrolled offscreen and via a visibilitychange listener when the tab is hidden.
- →prefers-reduced-motion disables both the spring easing and the idle breathing, leaving one calm static glow.
- →The secondary button is hidden entirely when showSecondaryCta is false or secondaryCta is empty, instead of rendering a dead button.
Accessibility
- ✓Both CTAs are real `<button>` elements (not dummy links), so they're keyboard-focusable and never risk nesting inside another `<a>` when the block sits in a library card.
- ✓The spotlight, vignette and dot-grid layers are all `aria-hidden` and `pointer-events-none`, so they never intercept clicks or appear in the accessibility tree.
- ✓Visible focus rings are tuned to the signal colour for both buttons.
- ✓Headline is a single `<h2>`; body copy keeps high contrast against the background regardless of glow position.
- ✓Honours prefers-reduced-motion.