Reaction Buttons with Dot and Ring Bursts

A social post card with four reaction buttons that each celebrate in their own way. Like pops a heart through a ring with eight dots flying outwards. Applaud tips a thumb and throws three bars upwards. Repost spins its arrows inside a double ripple, and Save drops a bookmark that scatters dots below it. The count beside each icon rolls up or down to its new value, and double-tapping the photo stamps a large heart on it with a bigger version of the like burst. The preview plays through all of them until a visitor presses something. On phones the copy sits above the card and the four buttons share one row.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { Bookmark, Heart, Repeat2, ThumbsUp } from "lucide-react";
import type { LucideIcon } from "lucide-react";

type Piece = { shape: "dot" | "ring" | "bar" | "square"; x: number; y: number; size: number; colour: string; delay?: number; turn?: number };
type Reaction = { id: string; label: string; done: string; count: number; colour: string; icon: LucideIcon; pieces: Piece[]; move: Record<string, number[]> };

const polar = (deg: number, r: number) => ({ x: Math.round(Math.cos((deg * Math.PI) / 180) * r), y: Math.round(Math.sin((deg * Math.PI) / 180) * r) });

// Each reaction has its own burst: a list of small shapes and where each one flies to (pixels from the icon centre).
const reactions: Reaction[] = [
  {
    id: "like",
    label: "Like",
    done: "Liked",
    count: 248,
    colour: "#ff4d73",
    icon: Heart,
    move: { scale: [1, 0.55, 1.4, 0.92, 1] },
    pieces: [
      { shape: "ring", x: 0, y: 0, size: 52, colour: "#ff4d73" },
      ...[0, 45, 90, 135, 180, 225, 270, 315].map((a, i): Piece => ({ shape: "dot", ...polar(a - 90, i % 2 ? 30 : 42), size: i % 2 ? 5 : 8, colour: i % 2 ? "#ffd166" : "#ff4d73", delay: i % 2 ? 0.05 : 0 })),
    ],
  },
  {
    id: "applaud",
    label: "Applaud",
    done: "Applauded",
    count: 61,
    colour: "#38bdf8",
    icon: ThumbsUp,
    move: { rotate: [0, -28, 10, -4, 0], y: [0, -5, 0, 0, 0] },
    pieces: [
      { shape: "bar", ...polar(-90, 40), size: 14, colour: "#38bdf8", turn: 0 },
      { shape: "bar", ...polar(-130, 38), size: 11, colour: "#a5f3fc", turn: -40, delay: 0.04 },
      { shape: "bar", ...polar(-50, 38), size: 11, colour: "#a5f3fc", turn: 40, delay: 0.04 },
      { shape: "ring", ...polar(-160, 40), size: 12, colour: "#38bdf8", delay: 0.1 },
      { shape: "ring", ...polar(-20, 40), size: 12, colour: "#38bdf8", delay: 0.1 },
      { shape: "dot", ...polar(-110, 54), size: 5, colour: "#ffffff", delay: 0.12 },
      { shape: "dot", ...polar(-70, 54), size: 5, colour: "#ffffff", delay: 0.12 },
    ],
  },
  {
    id: "repost",
    label: "Repost",
    done: "Reposted",
    count: 32,
    colour: "#34d399",
    icon: Repeat2,
    move: { rotate: [0, 200, 170, 180, 180] },
    pieces: [
      { shape: "ring", x: 0, y: 0, size: 54, colour: "#34d399" },
      { shape: "ring", x: 0, y: 0, size: 78, colour: "#a7f3d0", delay: 0.12 },
      ...[45, 135, 225, 315].map((a, i): Piece => ({ shape: "square", ...polar(a, 42), size: 7, colour: i % 2 ? "#a7f3d0" : "#34d399", turn: 180, delay: 0.05 })),
    ],
  },
  {
    id: "save",
    label: "Save",
    done: "Saved",
    count: 17,
    colour: "#fbbf24",
    icon: Bookmark,
    move: { y: [0, -9, 4, -1, 0], scaleY: [1, 1.1, 0.82, 1.04, 1] },
    pieces: [
      { shape: "ring", x: 0, y: 14, size: 46, colour: "#fbbf24", delay: 0.12 },
      ...[50, 70, 90, 110, 130].map((a, i): Piece => ({ shape: "dot", ...polar(a, i % 2 ? 38 : 50), size: i % 2 ? 5 : 7, colour: i % 2 ? "#ffffff" : "#fbbf24", delay: 0.14 + i * 0.02 })),
      { shape: "bar", ...polar(-140, 34), size: 9, colour: "#fde68a", turn: -50 },
      { shape: "bar", ...polar(-40, 34), size: 9, colour: "#fde68a", turn: 50 },
    ],
  },
];

const BURST_S = 0.7; // how long a burst lasts, in seconds
const SPREAD = 1; // multiply the distance every piece travels: 1.4 for a bigger burst
// Demo script: [time in ms, reaction id]. "photo" is the double tap, "reset" clears everything.
const SCRIPT: [number, string][] = [[200, "like"], [1150, "applaud"], [1380, "repost"], [2250, "save"], [2950, "photo"], [5200, "reset"]];
const LOOP_MS = 6200;

/** One burst. It is mounted with a new key each time, plays once and is left invisible at the end. */
function Burst({ pieces, scale = 1 }: { pieces: Piece[]; scale?: number }) {
  return (
    <span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 block h-0 w-0" style={{ transform: `scale(${scale})` }}>
      {pieces.map((p, i) => {
        const base = { position: "absolute" as const, left: -p.size / 2, top: -p.size / 2, width: p.size, height: p.size };
        const t = { duration: BURST_S, delay: p.delay ?? 0, ease: [0.16, 1, 0.3, 1] as const };
        if (p.shape === "ring")
          return <motion.span key={i} style={{ ...base, x: p.x * SPREAD, y: p.y * SPREAD, borderRadius: 999, border: `${p.size > 30 ? 3 : 2}px solid ${p.colour}` }} initial={{ scale: 0.2, opacity: 1 }} animate={{ scale: p.size > 30 ? 1.25 : [0.2, 1.2, 1], opacity: [1, 1, 0] }} transition={{ ...t, opacity: { duration: BURST_S, delay: p.delay ?? 0, times: [0, 0.45, 1] } }} />;
        const shapeStyle = p.shape === "bar" ? { width: 4, left: -2, borderRadius: 4 } : p.shape === "square" ? { borderRadius: 2 } : { borderRadius: 999 };
        return <motion.span key={i} style={{ ...base, ...shapeStyle, backgroundColor: p.colour, rotate: p.shape === "bar" ? (p.turn ?? 0) : 0 }} initial={{ x: p.x * 0.25, y: p.y * 0.25, scale: 0, opacity: 1 }} animate={{ x: p.x * SPREAD, y: p.y * SPREAD, scale: [0, 1.15, 1, 0], opacity: 1, ...(p.shape === "square" ? { rotate: p.turn ?? 0 } : {}) }} transition={{ ...t, scale: { duration: BURST_S, delay: p.delay ?? 0, times: [0, 0.3, 0.6, 1] } }} />;
      })}
    </span>
  );
}

export default function ReactionButtonsWithDotAndRingBursts() {
  const reduce = useReducedMotion();
  const still = !!reduce;
  const root = useRef<HTMLElement>(null);
  const inView = useInView(root, { amount: 0.3 });
  const [on, setOn] = useState<Record<string, boolean>>({});
  const [fired, setFired] = useState<Record<string, number>>({});
  const [demo, setDemo] = useState(true);

  const react = (id: string, value?: boolean) => {
    setOn((s) => {
      const next = value ?? !s[id];
      if (next) setFired((f) => ({ ...f, [id]: (f[id] ?? 0) + 1 }));
      return { ...s, [id]: next };
    });
  };
  const likePhoto = () => {
    setOn((s) => ({ ...s, like: true }));
    setFired((f) => ({ ...f, like: (f.like ?? 0) + 1, photo: (f.photo ?? 0) + 1 }));
  };

  // Self-playing demo. It stops for good the first time someone presses anything in the card.
  useEffect(() => {
    if (!demo || still || !inView) return;
    let timers: number[] = [];
    const run = () => {
      timers = SCRIPT.map(([at, id]) =>
        window.setTimeout(() => {
          if (id === "reset") setOn({});
          else if (id === "photo") likePhoto();
          else react(id, true);
        }, at),
      );
    };
    run();
    const loop = window.setInterval(run, LOOP_MS);
    return () => {
      timers.forEach((t) => window.clearTimeout(t));
      window.clearInterval(loop);
    };
  }, [demo, still, inView]);

  const stopDemo = () => demo && setDemo(false);
  return (
    <section ref={root} className="overflow-hidden bg-[#0b0b0f] px-5 py-14 text-zinc-100 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-2 lg:gap-16">
        <div className="max-w-md">
          <p className="font-mono text-xs uppercase tracking-[0.25em] text-[#ff7d98]">Micro-interactions</p>
          <h2 className="mt-4 text-4xl font-semibold tracking-tight text-white sm:text-5xl">Every tap gets a small celebration.</h2>
          <p className="mt-5 text-base text-zinc-300 sm:text-lg">Four reactions, four different bursts built from dots, rings and bars. Double-tap the photo to like it, or press any button to see its burst.</p>
          <ul className="mt-7 flex flex-wrap gap-2">
            {reactions.map((r) => (
              <li key={r.id} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200">
                <span aria-hidden="true" className="h-2 w-2 rounded-full" style={{ backgroundColor: r.colour }} />
                {r.label}
              </li>
            ))}
          </ul>
        </div>

        <article onPointerDownCapture={stopDemo} onKeyDownCapture={stopDemo} className="relative mx-auto w-full max-w-[440px] rounded-[28px] border border-white/10 bg-[#15151b] p-4 shadow-2xl shadow-black/60 sm:p-5">
          <header className="flex items-center gap-3">
            <span aria-hidden="true" className="grid h-10 w-10 place-items-center rounded-full bg-gradient-to-br from-[#ffb86b] to-[#ff4d73] text-sm font-bold text-[#0b0b0f]">OM</span>
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-semibold text-white">Odile Marchetti</p>
              <p className="truncate text-xs text-zinc-400">Cabo Espichel, 12 minutes ago</p>
            </div>
            <a href="#" className="inline-flex h-10 items-center rounded-full border border-white/15 px-4 text-xs font-semibold text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff7d98]">Follow</a>
          </header>

          {/* The photo, drawn with shapes. Double click or press Enter on it to like. */}
          <button type="button" aria-label="Like this photo" onClick={(e) => (e.detail === 0 || e.detail === 2) && likePhoto()} className="relative mt-4 block aspect-[4/3] w-full overflow-hidden rounded-2xl bg-gradient-to-b from-[#2b1f5c] via-[#c2456e] to-[#ffb86b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff7d98] focus-visible:ring-offset-2 focus-visible:ring-offset-[#15151b]">
            <span aria-hidden="true" className="absolute left-1/2 top-[34%] h-24 w-24 -translate-x-1/2 rounded-full bg-[#ffc56e]" />
            <span aria-hidden="true" className="absolute left-1/2 top-[34%] h-24 w-24 -translate-x-1/2 scale-[1.45] rounded-full border border-[#ffe3b0]/40" />
            <span aria-hidden="true" className="absolute left-1/2 top-[34%] h-24 w-24 -translate-x-1/2 scale-[1.95] rounded-full border border-[#ffe3b0]/20" />
            <span aria-hidden="true" className="absolute -left-[20%] bottom-[-34%] h-[70%] w-[90%] rounded-[100%] bg-[#6d2a63]" />
            <span aria-hidden="true" className="absolute -right-[25%] bottom-[-42%] h-[72%] w-[100%] rounded-[100%] bg-[#3a1b4d]" />
            <span aria-hidden="true" className="absolute bottom-[-52%] left-[10%] h-[70%] w-[80%] rounded-[100%] bg-[#1c1030]" />
            <AnimatePresence>
              {!still && !!fired.photo && (
                <motion.span key={fired.photo} aria-hidden="true" className="absolute left-1/2 top-1/2 block" initial={{ opacity: 1 }} animate={{ opacity: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.4, delay: 1.1 }}>
                  <Burst pieces={reactions[0].pieces} scale={2.6} />
                  <motion.span className="absolute left-0 top-0 block" style={{ x: "-50%", y: "-50%" }} initial={{ scale: 0, rotate: -18 }} animate={{ scale: [0, 1.3, 0.95, 1], rotate: [-18, 6, 0, 0] }} transition={{ duration: 0.55, ease: "easeOut" }}>
                    <Heart className="h-24 w-24 text-white drop-shadow-lg" fill="#ffffff" strokeWidth={0} />
                  </motion.span>
                </motion.span>
              )}
            </AnimatePresence>
          </button>

          <p className="mt-4 text-sm leading-relaxed text-zinc-200">Walked out to the lighthouse for the last ten minutes of light. Worth every one of the 4,000 steps back in the dark.</p>

          <div className="mt-4 grid grid-cols-4 gap-2 border-t border-white/10 pt-3">
            {reactions.map((r) => {
              const active = !!on[r.id];
              const Icon = r.icon;
              return (
                <button key={r.id} type="button" aria-pressed={active} aria-label={`${active ? r.done : r.label}, ${r.count + (active ? 1 : 0)}`} onClick={() => react(r.id)} className="group relative flex h-12 items-center justify-center gap-2 rounded-2xl text-sm font-medium tabular-nums transition-colors hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70" style={{ color: active ? r.colour : "#a1a1aa" }}>
                  <span className="relative grid h-6 w-6 place-items-center">
                    <motion.span key={`${r.id}-${fired[r.id] ?? 0}`} className="block" animate={active && !still && fired[r.id] ? r.move : { scale: 1, rotate: 0, y: 0, scaleY: 1 }} transition={{ duration: 0.55, ease: "easeOut" }} style={{ originY: r.id === "save" ? 1 : 0.5 }}>
                      <Icon className="h-6 w-6" fill={active && r.id !== "repost" ? r.colour : "none"} strokeWidth={2} />
                    </motion.span>
                    {!still && active && !!fired[r.id] && <Burst key={fired[r.id]} pieces={r.pieces} />}
                  </span>
                  <span aria-hidden="true" className="relative block h-5 w-8 overflow-hidden text-left">
                    <AnimatePresence initial={false}>
                      <motion.span key={active ? "on" : "off"} className="absolute left-0 top-0 leading-5" initial={still ? false : { y: active ? 16 : -16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: active ? 16 : -16, opacity: 0 }} transition={{ type: "spring", stiffness: 420, damping: 30 }}>
                        {r.count + (active ? 1 : 0)}
                      </motion.span>
                    </AnimatePresence>
                  </span>
                </button>
              );
            })}
          </div>
        </article>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/reaction-buttons-with-dot-and-ring-bursts.json

When to use it

How to customize it

Accessibility

Questions

How is the burst animation built?

Each burst is a zero-sized element centred on the icon that holds a handful of absolutely positioned spans. Every span animates from near the centre to its own `x` and `y` while its scale goes from 0 up to 1 and back to 0, so it appears, travels and vanishes. Rings are the same idea with a border and a fading opacity instead.

How does the burst replay on every press?

The component keeps a counter per reaction and uses it as the React `key` of the burst. A new key mounts a fresh burst, which plays its animation once from the start. Turning a reaction off does not change the counter, so no burst plays.

How do the numbers roll?

The count sits in a small box with hidden overflow. When the value changes, the old number slides out and the new one slides in from the opposite side using `AnimatePresence`. Turning a reaction on rolls upwards and turning it off rolls back down.

Why do the pieces not cause layout shifts?

They are positioned absolutely inside a zero-sized wrapper and only animate `transform` and `opacity`. The browser can draw that on the compositor without measuring the page again.

Can I use just one of the buttons?

Yes. Keep the `Burst` component and one entry of `reactions`, then render that single button. The like button alone is about forty lines.

Related: Button That Bursts Particles on Press, Subscribe Button with Animated Confirmed State, Sun to Moon Theme Toggle with Circular Reveal.

More toggles and switches or all free UI components. Start for free to have a full site built around it.

More in Toggles

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