Final Call to Action with Magnetic Giant Button and Orbiting Keywords

A closing section built around a single oversized round button. The button is magnetic: it leans toward the pointer on a spring, its label slides a little further than its body, and it flips from white to black on hover. Nine feature keywords circle it on two tilted ovals, growing and brightening as they pass in front and shrinking as they go behind. Behind everything the backdrop fades slowly through rose, violet, blue and green. With no pointer the button drifts in a small figure of eight. On phones the ovals tighten and the button stays a comfortable thumb target.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";

/** Two orbits of keywords. `wide` and `tall` are fractions of the stage, `tilt` is in degrees, `seconds` is one lap (negative runs backwards). */
const orbits = [
  { words: ["Proposals", "Contracts", "Invoices", "Payments", "Client portal"], wide: 0.44, tall: 0.34, tilt: -14, seconds: 30 },
  { words: ["Time tracking", "Scheduling", "Reports", "E-signatures"], wide: 0.3, tall: 0.46, tilt: 24, seconds: -22 },
];
const allWords = orbits.flatMap((o) => o.words);

/** The four moods the backdrop fades through. Every base colour keeps white text readable. */
const moods = [
  "radial-gradient(55% 70% at 12% 10%, #f97316, rgba(249,115,22,0) 70%), radial-gradient(60% 75% at 92% 95%, #7e22ce, rgba(126,34,206,0) 70%), radial-gradient(40% 50% at 70% 20%, #e11d48, rgba(225,29,72,0) 75%), #be123c",
  "radial-gradient(55% 70% at 90% 8%, #db2777, rgba(219,39,119,0) 70%), radial-gradient(60% 75% at 8% 95%, #1d4ed8, rgba(29,78,216,0) 70%), radial-gradient(40% 50% at 30% 25%, #9333ea, rgba(147,51,234,0) 75%), #6d28d9",
  "radial-gradient(55% 70% at 10% 90%, #0891b2, rgba(8,145,178,0) 70%), radial-gradient(60% 75% at 92% 10%, #4338ca, rgba(67,56,202,0) 70%), radial-gradient(40% 50% at 65% 75%, #2563eb, rgba(37,99,235,0) 75%), #1d4ed8",
  "radial-gradient(55% 70% at 88% 92%, #0e7490, rgba(14,116,144,0) 70%), radial-gradient(60% 75% at 10% 8%, #4d7c0f, rgba(77,124,15,0) 70%), radial-gradient(40% 50% at 35% 70%, #059669, rgba(5,150,105,0) 75%), #047857",
];

const MAGNET_RADIUS = 260;
const MAGNET_PULL = 0.4;

export default function FinalCallToActionWithMagneticGiantButtonAndOrbitingKeywords() {
  const reduce = !!useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const stage = useRef<HTMLDivElement>(null);
  const pills = useRef<(HTMLSpanElement | null)[]>([]);
  const lastMove = useRef(-10000);
  const clock = useRef(0);
  const [onScreen, setOnScreen] = useState(true);

  // The button is pulled toward the pointer; the label inside is pulled a little further for depth.
  const bx = useMotionValue(0);
  const by = useMotionValue(0);
  const sx = useSpring(bx, { stiffness: 150, damping: 12, mass: 0.6 });
  const sy = useSpring(by, { stiffness: 150, damping: 12, mass: 0.6 });
  const lx = useTransform(sx, (v) => v * 0.35);
  const ly = useTransform(sy, (v) => v * 0.35);
  const glowX = useTransform(sx, (v) => v * -0.5);
  const glowY = useTransform(sy, (v) => v * -0.5);

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // One loop places every keyword on its tilted oval and, when nobody is pointing, lets the button drift.
  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    let w = el.clientWidth;
    let h = el.clientHeight;
    const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { w = el.clientWidth; h = el.clientHeight; if (reduce) place(0, 1); }) : null;
    ro?.observe(el);

    function place(t: number, grow: number) {
      let n = 0;
      for (const o of orbits) {
        const tilt = (o.tilt * Math.PI) / 180;
        const rx = w * o.wide * grow;
        const ry = h * o.tall * grow;
        o.words.forEach((_, i) => {
          const a = (i / o.words.length) * Math.PI * 2 + (t / o.seconds) * Math.PI * 2 + (o.seconds < 0 ? 0.6 : 0);
          const ex = Math.cos(a) * rx;
          const ey = Math.sin(a) * ry;
          const x = ex * Math.cos(tilt) - ey * Math.sin(tilt);
          const y = ex * Math.sin(tilt) + ey * Math.cos(tilt);
          const depth = (Math.sin(a) + 1) / 2; // 0 at the back of the oval, 1 at the front
          const pill = pills.current[n++];
          if (!pill) return;
          pill.style.transform = `translate(-50%, -50%) translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) scale(${(0.72 + depth * 0.4).toFixed(3)})`;
          pill.style.opacity = ((0.4 + depth * 0.6) * Math.min(1, grow * 1.2)).toFixed(3);
          pill.style.zIndex = depth > 0.5 ? "30" : "5";
        });
      }
    }

    if (reduce) place(0, 1);
    if (reduce || !onScreen) return () => ro?.disconnect();
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
      raf = requestAnimationFrame(tick);
      const step = Math.min(0.05, (now - last) / 1000);
      last = now;
      if (document.hidden) return;
      clock.current += step;
      const t = clock.current;
      const k = Math.min(1, t / 1.3);
      place(t, 1 - Math.pow(1 - k, 3) * 0.75);
      if (now - lastMove.current > 2400) {
        bx.set(Math.sin(t * 0.9) * 16);
        by.set(Math.sin(t * 1.8) * 9);
      }
    };
    raf = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(raf);
      ro?.disconnect();
    };
  }, [reduce, onScreen, bx, by]);

  const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (reduce) return;
    const r = e.currentTarget.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    lastMove.current = performance.now();
    const near = Math.hypot(dx, dy) < MAGNET_RADIUS;
    bx.set(near ? dx * MAGNET_PULL : 0);
    by.set(near ? dy * MAGNET_PULL : 0);
  };
  const onLeave = () => {
    bx.set(0);
    by.set(0);
  };

  return (
    <section ref={ref} data-paused={!onScreen} className="fcm-root relative overflow-hidden bg-rose-700 px-6 py-16 text-white sm:py-24">
      <style>{`
        @keyframes final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords-fade { 0%, 100% { opacity: 0; } 8%, 25% { opacity: 1; } 33% { opacity: 0; } }
        @keyframes final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords-drift { 0%, 100% { transform: translate(0, -10%) scale(1); } 50% { transform: translate(60%, 20%) scale(1.25); } }
        @keyframes final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords-ring { 0% { transform: scale(1); opacity: 0.55; } 100% { transform: scale(1.9); opacity: 0; } }
        .fcm-mood { opacity: 0; animation: final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords-fade 24s linear infinite; }
        .fcm-drift { animation: final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords-drift 18s ease-in-out infinite; }
        .fcm-ring { animation: final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords-ring 3.2s cubic-bezier(0.2, 0.6, 0.3, 1) infinite; }
        .fcm-root[data-paused="true"] [class*="fcm-"] { animation-play-state: paused; }
        @media (prefers-reduced-motion: reduce) { .fcm-mood, .fcm-drift, .fcm-ring { animation: none; } .fcm-mood:first-child { opacity: 1; } .fcm-ring { opacity: 0.3; } }
      `}</style>

      {/* The backdrop: four colour moods stacked and faded in turn, each slowly rotating. */}
      <div aria-hidden="true" className="absolute inset-0">
        {moods.map((mood, i) => (
          <div key={i} className="fcm-mood absolute inset-0" style={{ background: mood, animationDelay: `${-1.92 - (moods.length - i) * 6}s` }} />
        ))}
        <div className="fcm-drift absolute -left-1/4 top-0 h-full w-3/4 rounded-full bg-white/10 blur-3xl" />
        <div className="fcm-drift absolute -right-1/4 bottom-0 h-2/3 w-1/2 rounded-full bg-black/25 blur-3xl [animation-delay:-9s]" />
        <div className="absolute inset-0" style={{ backgroundImage: "radial-gradient(ellipse 70% 60% at 50% 60%, rgba(0,0,0,0), rgba(0,0,0,0.35))" }} />
      </div>

      <div className="relative mx-auto max-w-5xl text-center">
        <motion.div initial={reduce ? false : { opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 80, damping: 16 }}>
          <h2 className="mx-auto max-w-4xl text-4xl font-semibold leading-[1.05] tracking-tight sm:text-6xl">Run the studio from one place.</h2>
          <p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-white/90">Canopy runs the whole client side of your studio, from the first proposal to the final payment.</p>
          <p className="sr-only">Included: {allWords.join(", ")}.</p>
        </motion.div>

        <div ref={stage} onPointerMove={onMove} onPointerLeave={onLeave} className="relative mx-auto mt-6 h-[380px] w-full max-w-4xl touch-pan-y sm:h-[460px]">
          {orbits.map((o) => (
            <motion.span
              key={o.tilt}
              aria-hidden="true"
              className="pointer-events-none absolute left-1/2 top-1/2 rounded-[50%] border border-dashed border-white/30"
              style={{ width: `${o.wide * 200}%`, height: `${o.tall * 200}%`, x: "-50%", y: "-50%", rotate: o.tilt }}
              initial={reduce ? false : { opacity: 0, scale: 0.3 }}
              animate={{ opacity: 1, scale: 1 }}
              transition={{ duration: 1.3, ease: [0.2, 0.8, 0.2, 1] }}
            />
          ))}
          {allWords.map((word, i) => (
            <span
              key={word}
              ref={(el) => {
                pills.current[i] = el;
              }}
              aria-hidden="true"
              className="pointer-events-none absolute left-1/2 top-1/2 whitespace-nowrap rounded-full border border-white/50 bg-white/20 px-3.5 py-1.5 text-sm font-medium text-white opacity-0 shadow-lg shadow-black/10 backdrop-blur-sm will-change-transform sm:px-5 sm:py-2 sm:text-base"
              style={{ transform: "translate(-50%, -50%)" }}
            >
              {word}
            </span>
          ))}

          <div className="absolute left-1/2 top-1/2 z-20 -ml-20 -mt-20 h-40 w-40 sm:-ml-28 sm:-mt-28 sm:h-56 sm:w-56">
            <span aria-hidden="true" className="fcm-ring absolute inset-0 rounded-full border border-white" />
            <span aria-hidden="true" className="fcm-ring absolute inset-0 rounded-full border border-white [animation-delay:-1.6s]" />
            <motion.span aria-hidden="true" className="absolute -inset-6 rounded-full bg-white/40 blur-2xl" style={{ x: glowX, y: glowY }} />
            <motion.a
              href="#"
              style={{ x: sx, y: sy }}
              initial={reduce ? false : { scale: 0, rotate: -40 }}
              animate={{ scale: 1, rotate: 0 }}
              whileHover={reduce ? undefined : { scale: 1.07 }}
              whileTap={{ scale: 0.93 }}
              transition={{ type: "spring", stiffness: 170, damping: 12, delay: reduce ? 0 : 0.15 }}
              className="group relative flex h-full w-full items-center justify-center rounded-full bg-white text-slate-950 shadow-2xl shadow-black/40 transition-colors duration-300 hover:bg-slate-950 hover:text-white focus:outline-none focus-visible:ring-4 focus-visible:ring-white focus-visible:ring-offset-4 focus-visible:ring-offset-slate-950"
            >
              <motion.span style={{ x: lx, y: ly }} className="flex flex-col items-center gap-1 sm:gap-2">
                <span className="text-2xl font-semibold tracking-tight sm:text-4xl">Start free</span>
                <span className="flex items-center gap-1.5 text-xs font-medium sm:text-sm">
                  14 days, no card
                  <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1">
                    <path d="M4 10h12M11 5l5 5-5 5" />
                  </svg>
                </span>
              </motion.span>
            </motion.a>
          </div>
        </div>

        <p className="mt-4 text-sm text-white/90">
          Already with us?{" "}
          <a href="#" className="inline-flex min-h-[40px] items-center font-semibold underline underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-white">
            Sign in to your studio
          </a>
        </p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/final-call-to-action-with-magnetic-giant-button-and-orbiting-keywords.json

When to use it

How to customize it

Accessibility

Questions

How does a magnetic button work?

The stage listens for pointer movement and measures the distance from the pointer to its centre. Inside a set radius, the button's target offset becomes a fraction of that distance, and a spring moves it there. Outside the radius the target returns to zero, so the button springs back. The label uses the same value multiplied again, which makes it look like it floats above the surface.

How are the keywords placed on a tilted oval?

Each keyword has an angle that grows with time. Cosine and sine of that angle, multiplied by the oval's width and height, give a point on a flat ellipse, and a rotation by the tilt angle turns the whole ellipse. The sine of the angle also says whether the point is on the near or far side, which drives its scale, opacity and stacking order.

How does the colour-shifting backdrop avoid repainting?

It does not animate colours. Four gradient layers are stacked, and a keyframe changes only their opacity so one fades in as the previous fades out. Two soft blobs drift on top using only transforms. Opacity and transforms are handled by the compositor, so the gradients are painted once.

What does the button do on a touch screen?

There is no hover on touch, so the button keeps its idle drift and responds to a press with a quick squeeze. If a finger moves across the stage the magnet follows it, and vertical scrolling still works.

Can I use a normal pill button instead of a circle?

Yes. Change `rounded-full` sizing on the wrapper to a wider box, for example `h-20 w-72`, and adjust the negative margins to half of each side. The magnet and orbits do not depend on the shape.

Related: Magnetic Button That Follows the Pointer, Call-to-Action Banner on a Brand Colour, Hero with Orbiting App Icons Around a Logo.

More call-to-action sections or all free UI components. Start for free to have a full site built around it.

More in Cta Sections

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap