Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 02:39:20Get Lifetime →
loopkit
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.