Button That Attracts Floating Particles

A call to action button surrounded by sixteen small dots that bob gently in a loose ring. Hover, focus or press the button and every dot is pulled in on a soft spring and sticks to the rim of the button, one after another, while the button darkens, grows slightly and changes its label. Let go and the dots spring back to where they were. The demo puts the button in a small meeting card with avatars, a heading and a line of copy, and it pulls and releases by itself until someone touches it. The stage is fixed in height and the dots stay inside a 360 pixel screen.

Code

// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/attract-button.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { Magnet } from "lucide-react";

const PARTICLE_COUNT = 16;
// Particles rest between these two distances from the centre of the button, in pixels.
const INNER_RADIUS = 96;
const OUTER_RADIUS = 190;
const DOT_COLOURS = ["bg-sky-400", "bg-blue-500", "bg-indigo-400", "bg-cyan-400"];

const teammates = ["AK", "JR", "MO", "SL", "TN"];

/** A small repeatable random number generator, so the scatter is the same on every render. */
function seeded(seed: number) {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s - 1) / 2147483646;
  };
}

export default function ButtonThatAttractsFloatingParticles() {
  const rootRef = useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef);
  const reduce = useReducedMotion();
  const [attracting, setAttracting] = useState(false);
  const [demo, setDemo] = useState(true);

  const particles = useMemo(() => {
    const random = seeded(41);
    return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
      const angle = (i / PARTICLE_COUNT) * Math.PI * 2 + random() * 0.5;
      const distance = INNER_RADIUS + random() * (OUTER_RADIUS - INNER_RADIUS);
      // `x`/`y` is the resting spot. `dockX`/`dockY` is where the dot sticks on the rim of the button when pulled in.
      return { id: i, x: Math.cos(angle) * distance, y: Math.sin(angle) * distance * 0.62, dockX: Math.cos(angle) * 126, dockY: Math.sin(angle) * 42, size: 6 + Math.round(random() * 6), colour: DOT_COLOURS[i % DOT_COLOURS.length], bob: 2.4 + random() * 2 };
    });
  }, []);

  // Until someone touches the button it pulls and releases by itself so the effect is visible.
  useEffect(() => {
    if (!demo || !inView || reduce) return;
    const timer = setTimeout(() => setAttracting((on) => !on), attracting ? 1500 : 1000);
    return () => clearTimeout(timer);
  }, [demo, inView, reduce, attracting]);

  const pull = () => {
    setDemo(false);
    setAttracting(true);
  };
  const release = () => {
    setDemo(false);
    setAttracting(false);
  };

  return (
    <div ref={rootRef} className="relative flex min-h-[460px] w-full items-center justify-center overflow-hidden bg-gradient-to-b from-sky-50 to-white px-4 py-14">
      <div className="relative flex w-full max-w-md flex-col items-center text-center">
        <div className="flex -space-x-2" aria-hidden="true">
          {teammates.map((initials, i) => (
            <span key={initials} className={`flex h-9 w-9 items-center justify-center rounded-full border-2 border-white text-xs font-semibold text-white ${["bg-sky-500", "bg-indigo-500", "bg-blue-600", "bg-cyan-600", "bg-slate-700"][i]}`}>
              {initials}
            </span>
          ))}
          <span className="flex h-9 w-9 items-center justify-center rounded-full border-2 border-white bg-slate-200 text-xs font-semibold text-slate-700">+11</span>
        </div>
        <h2 className="mt-5 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Standup starts in two minutes</h2>
        <p className="mt-2 max-w-sm text-base text-slate-600">Sixteen people are scattered across four time zones. One press pulls everyone into the room.</p>

        {/* The stage: particles are placed from its centre, which is also the centre of the button. */}
        <div className="relative mt-4 flex h-[250px] w-full items-center justify-center">
          {particles.map((p, i) => (
            <motion.span
              key={p.id}
              className="pointer-events-none absolute left-1/2 top-1/2"
              style={{ marginLeft: -p.size / 2, marginTop: -p.size / 2 }}
              initial={false}
              animate={attracting ? { x: p.dockX, y: p.dockY, scale: 0.85, opacity: 1 } : { x: p.x, y: p.y, scale: 1, opacity: 0.55 }}
              transition={
                reduce
                  ? { duration: 0 }
                  : attracting
                    ? { type: "spring", stiffness: 60, damping: 11, delay: i * 0.018 }
                    : { type: "spring", stiffness: 110, damping: 14, delay: i * 0.012 }
              }
              aria-hidden="true"
            >
              <motion.span
                className={`block rounded-full ${p.colour}`}
                style={{ width: p.size, height: p.size }}
                animate={reduce || attracting ? { y: 0 } : { y: [0, -6, 0] }}
                transition={reduce || attracting ? { duration: 0.2 } : { duration: p.bob, repeat: Infinity, ease: "easeInOut", delay: i * 0.15 }}
              />
            </motion.span>
          ))}

          <motion.button
            type="button"
            onPointerEnter={pull}
            onPointerLeave={release}
            onPointerDown={pull}
            onPointerUp={(event) => event.pointerType !== "mouse" && release()}
            onPointerCancel={release}
            onFocus={pull}
            onBlur={release}
            animate={{ scale: attracting && !reduce ? 1.04 : 1 }}
            transition={{ type: "spring", stiffness: 300, damping: 18 }}
            className={`relative z-10 inline-flex h-12 min-w-[13rem] touch-none select-none items-center justify-center gap-2 rounded-full border px-6 text-sm font-semibold shadow-lg transition-colors duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 ${attracting ? "border-blue-700 bg-blue-700 text-white shadow-blue-500/40" : "border-blue-200 bg-white text-blue-800 shadow-sky-200/60"}`}
          >
            <Magnet className={`h-4 w-4 transition-transform duration-300 ${attracting ? "-rotate-45 scale-110" : ""}`} aria-hidden="true" />
            {attracting ? "Gathering 16 people" : "Gather the team"}
          </motion.button>
        </div>

        <p className="text-sm text-slate-600" aria-live="polite">
          {attracting ? "Everyone is on their way." : "Hover, focus or press and hold the button."}
        </p>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/button-that-attracts-floating-particles.json

When to use it

How to customize it

Accessibility

Questions

How do the particles know where the button is?

They are positioned from the centre of the same box that centres the button, so an offset of zero is the middle of the button. Each dot stores a resting offset and a docking offset on an oval just outside the button, and animates between the two.

Why is the scatter the same every time?

The positions come from a tiny seeded number generator instead of `Math.random`, so the server and the browser render the same layout and nothing jumps on load.

Does it work on touch screens?

Yes. Pressing and holding pulls the dots in and lifting the finger releases them. `touch-none` on the button stops the page scrolling during the hold.

How do I run my own action?

Add an `onClick` to the button. The pull effect is driven by pointer and focus events, so a click handler does not interfere with it.

Why does it animate by itself in the preview?

So the effect can be seen without a pointer. The loop only runs while the component is on screen and stops for good on the first hover, press or focus.

Adapted from Attract Button in Kokonut UI, used under its open-source licence. Licences and credits

Related: Magnetic Button That Follows the Pointer, Hold to Confirm Button with Progress Ring, Shimmer Button with Light Sweep in Three Sizes.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

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