How to build a newsletter section with a radial mouse spotlight in React
A React newsletter section with a mouse spotlight tracks pointer coordinates via useMotionValue, then assembles a live radial-gradient CSS string with useMotionTemplate so the highlight follows the cursor in real time. The form cycles through idle, loading (spinning SVG), and success (animated checkmark) states managed with useState and AnimatePresence.
- Stack: React 18 + Framer Motion 11, ~376 lines, zero icon library dependency (inline SVGs only).
- Core Framer Motion APIs: useMotionValue, useMotionTemplate, AnimatePresence, motion.section, whileInView.
- Accessible: the input has type=email + required, the spinner and checkmark SVGs carry aria-hidden, and focus is visible via box-shadow.
- Responsive: the form row wraps at narrow widths via flexWrap:wrap with a flex-basis on the input.
- The spotlight has no effect on touch devices, the dot-grid background still renders and the section reads cleanly without cursor interaction.
This newsletter section turns a standard email capture into a polished interactive moment. A radial spotlight chases the mouse across a dot-grid background, the email input glows on focus, and the submit button transitions through a spinning loader to a confirmed checkmark, all without a single third-party icon or CSS-in-JS library. The stacked avatar row and subscriber count close the social proof loop.
Anatomy
The section is a motion.section with a relative container and overflow:hidden. Two absolutely-positioned layers sit beneath the content: a dot-grid div (aria-hidden, radial-gradient background-image at 24px intervals) and a motion.div for the spotlight overlay. The actual content, eyebrow badge, heading in two parts (normal + accent-colored span), subtitle, form, and social proof row, lives in a centered div at z-index 1. The social proof row stacks three colored circles with negative margin to fake overlapping avatars.
How it works
On every mousemove over the section, the handler reads the pointer's position relative to the section via getBoundingClientRect and writes the values to two Framer Motion useMotionValue instances (mouseX, mouseY). useMotionTemplate then assembles those live values into a CSS string: `radial-gradient(500px circle at ${mouseX}px ${mouseY}px, color-mix(in srgb, var(--color-accent) 8%, transparent), transparent 70%)`. That string is bound to the background style of the overlay div, so the browser repaints the gradient every frame without React re-renders. The form's three states (idle, loading, success) are toggled with a plain useState; AnimatePresence with mode='wait' cross-fades between the form and the success pill.
How to build it in React
Wire up the mouse spotlight with useMotionTemplate
Create two motion values for the pointer coordinates and a ref for the section element. In the mousemove handler, subtract the section's bounding rect to get container-relative coordinates. Pass mouseX and mouseY into useMotionTemplate to produce a live CSS gradient string, then bind it to the overlay div's background style.
const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); const sectionRef = useRef<HTMLElement>(null); const spotlightBg = useMotionTemplate`radial-gradient( 500px circle at ${mouseX}px ${mouseY}px, color-mix(in srgb, var(--color-accent) 8%, transparent), transparent 70% )`; function handleMouseMove(e: React.MouseEvent<HTMLElement>) { const rect = sectionRef.current?.getBoundingClientRect(); if (!rect) return; mouseX.set(e.clientX - rect.left); mouseY.set(e.clientY - rect.top); }Build the dot-grid background layer
Add an absolutely-positioned div with aria-hidden and a radial-gradient background-image at a 24px grid size. Keep opacity around 0.35 so it reads as a texture rather than noise. Place the spotlight motion.div directly on top of it, also absolutely positioned with inset:0.
<div aria-hidden style={{ position: "absolute", inset: 0, backgroundImage: "radial-gradient(circle, color-mix(in srgb, var(--color-foreground) 10%, transparent) 1px, transparent 1px)", backgroundSize: "24px 24px", opacity: 0.35, pointerEvents: "none", }} /> <motion.div aria-hidden style={{ position: "absolute", inset: 0, background: spotlightBg, pointerEvents: "none" }} />Manage form states with useState and AnimatePresence
Declare a FormState type covering 'idle', 'loading', and 'success'. On submit, flip to loading and simulate an async call with setTimeout. Wrap both the form and the success pill in AnimatePresence with mode='wait' so one fades out before the other fades in.
type FormState = "idle" | "loading" | "success"; const [formState, setFormState] = useState<FormState>("idle"); function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (formState !== "idle") return; setFormState("loading"); setTimeout(() => setFormState("success"), 1500); } <AnimatePresence mode="wait"> {formState === "success" ? ( <motion.div key="success" initial={{ opacity: 0, scale: 0.92 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}> <CheckIcon /> Vous êtes inscrit ! </motion.div> ) : ( <motion.form key="form" onSubmit={handleSubmit} exit={{ opacity: 0 }}> {/* input + button */} </motion.form> )} </AnimatePresence>Add stacked avatar social proof
Render three small circles in a flex row, each shifted left by -8px using marginLeft on indices > 0. Use distinct accent-range colors for variety and a 2px border matching the background color to create the separation illusion. Follow the row with a subscriber count string.
{["#818cf8", "#a78bfa", "#60a5fa"].map((bg, i) => ( <div key={i} style={{ width: 24, height: 24, borderRadius: "50%", border: "2px solid var(--color-background)", background: bg, marginLeft: i === 0 ? 0 : -8, }} /> ))}
When to use it
Use this section on SaaS, agency, or content-brand sites where email capture is a primary conversion goal. It suits mid-page placement after a features or testimonials block, and works well just above the footer. Skip it when the page already has a heavy interactive hero, two cursor-reactive surfaces on the same page compete for attention. On purely transactional pages (checkout, onboarding), a simpler inline form is less distracting.
Used by
- Linear, Uses radial spotlight overlays on dark sections to draw focus without adding visual noise.
- Vercel, Employs cursor-driven ambient gradients on marketing sections, a technique close to this pattern.
- Superhuman, Pairs a clean centered email capture with subtle background animation and social proof subscriber counts.
- Beehiiv, Newsletter landing pages routinely combine subscriber counts, stacked avatars, and a single email input for high-converting signup sections.
FAQ
Why use useMotionTemplate instead of a plain CSS variable?
useMotionTemplate ties a Framer Motion MotionValue directly into a CSS string so the browser updates the style on every animation frame without going through React's render cycle. A plain state update on mousemove would trigger constant re-renders and feel janky at high pointer speeds.
Does the spotlight still work in dark mode?
Yes. The gradient uses color-mix(in srgb, var(--color-accent) 8%, transparent), so it picks up whatever accent color the active theme defines. The component's themeMode is set to 'both', meaning it adapts to all seven presets without code changes.
How do I connect the form to a real email service?
Replace the setTimeout mock in handleSubmit with a fetch call to your API route (Mailchimp, ConvertKit, Resend, etc.). Keep the formState pattern as-is: set 'loading' before the await and 'success' (or an error state) after. The AnimatePresence transition works the same regardless of the async source.
The spotlight feels too subtle. How do I intensify it?
Increase the opacity percentage in color-mix, from 8% up to 15-20%, and widen the circle radius from 500px to 700px. Go beyond 25% and the effect starts competing with the text contrast, so test on all theme presets before committing.