Message Bubble Text That Shakes and Explodes into Letters

A loud message inside a chat thread. The text in the last bubble tightens its letter spacing and shrinks, trembles for half a second, then bursts: each letter leaves on its own path with a hop, a spin and a fade, and the bubble springs back into place ready to go again. It loops on its own in the preview, and a button replays it on demand. The thread sits in a phone-shaped panel beside a short heading and paragraph; on phones the copy stacks above the thread.

Code

// Adapted from Animata (https://animata.design/docs/text/text-explode-imessage)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "framer-motion";
import type { Variants } from "framer-motion";

const thread = [
  { from: "them", text: "Any news from the venue?" },
  { from: "me", text: "They just called back" },
  { from: "them", text: "And??" },
];
const LOUD_MESSAGE = "WE GOT IT";
const PAUSE_BETWEEN_LOOPS = 1400;

const bubbleVariants: Variants = {
  initial: { opacity: 1, y: 0, scale: 1, letterSpacing: "0em" },
  shrink: { scale: 0.8, letterSpacing: "-0.08em" },
  jitter: {
    x: [0, -3, 3, -3, 3, 0],
    y: [0, -2, 2, -2, 2, 0],
    transition: { duration: 0.5, times: [0, 0.2, 0.4, 0.6, 0.8, 1], ease: "easeInOut" },
  },
  explode: { scale: [0.8, 1.05, 1], letterSpacing: "0em", transition: { duration: 0.9, times: [0, 0.3, 1] } },
  end: { opacity: 0, scale: 1, y: 24, transition: { duration: 0.01 } },
};

/** Each letter gets its own arc: sideways drift, a hop up, a spin and a fade. */
const explosion = ({ index, total }: { index: number; total: number }) => {
  const direction = Math.random() > 0.5 ? -1 : 1;
  const x = Math.random() * 9 * total * direction;
  const angle = (index / Math.max(total - 1, 1)) * Math.PI;
  const y = total * 6 * -Math.sin(angle) * (0.4 + Math.random() * 0.6) - 20;
  const rotation = Math.random() * 360 * direction;
  return {
    x: [0, x * 0.5, x * 0.8, x],
    y: [0, y, y * 0.4, 30],
    rotate: [0, rotation * 0.4, rotation * 0.8, rotation],
    scale: [1, 1.3, 1.5 + Math.random(), 1.4 + Math.random() * 1.6],
    opacity: [1, 1, 0.6, 0],
    transition: { duration: 0.9, ease: "easeOut" },
  };
};

const letterVariants: Variants = {
  initial: { opacity: 1, x: 0, y: 0, rotate: 0, scale: 1 },
  shrink: { scale: 1.08 },
  jitter: () => ({
    x: [0, -3 + Math.random() * 6, 3 - Math.random() * 6, 0],
    y: [0, -2 + Math.random() * 4, 2 - Math.random() * 4, 0],
    transition: { duration: 0.5, times: [0, 0.33, 0.66, 1], ease: "easeInOut" },
  }),
  explode: explosion,
  end: { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1, transition: { duration: 0.01 } },
};

export default function MessageBubbleTextThatShakesAndExplodesIntoLetters() {
  const reduce = useReducedMotion();
  const controls = useAnimationControls();
  const alive = useRef(true);
  const playing = useRef(false);
  const [auto, setAuto] = useState(true);
  const letters = Array.from(LOUD_MESSAGE);

  const play = useCallback(async () => {
    if (playing.current || reduce) return;
    playing.current = true;
    await Promise.all([
      controls.start("shrink", { duration: 0.9, ease: "easeOut" }),
      controls.start("jitter", { delay: 0.35 }),
    ]);
    if (alive.current) await controls.start("explode");
    if (alive.current) await controls.start("end");
    if (alive.current) await controls.start("initial", { type: "spring", stiffness: 260, damping: 18, delay: 0.35 });
    playing.current = false;
  }, [controls, reduce]);

  useEffect(() => {
    alive.current = true;
    return () => {
      alive.current = false;
      controls.stop();
    };
  }, [controls]);

  // Loop on its own until the visitor presses the button, then it only plays on demand.
  useEffect(() => {
    if (!auto || reduce) return;
    let timer = 0;
    const loop = async () => {
      await play();
      if (alive.current) timer = window.setTimeout(loop, PAUSE_BETWEEN_LOOPS);
    };
    timer = window.setTimeout(loop, 500);
    return () => window.clearTimeout(timer);
  }, [auto, play, reduce]);

  return (
    <section className="bg-slate-950 px-4 py-14 sm:px-6 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-10 md:grid-cols-2 md:gap-16">
        <div>
          <p className="text-sm font-semibold uppercase tracking-widest text-sky-400">Message effects</p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Some news is too big for a plain bubble.</h2>
          <p className="mt-4 max-w-md text-base leading-relaxed text-slate-300">
            The text squeezes together, trembles for half a second, then every letter flies off on its own arc before the message
            settles back in place.
          </p>
          <button
            type="button"
            onClick={() => {
              setAuto(false);
              play();
            }}
            className="mt-7 inline-flex h-11 items-center gap-2 rounded-full bg-sky-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-sky-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
          >
            <svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
              <path d="M6 4.5v11a.5.5 0 0 0 .77.42l8.5-5.5a.5.5 0 0 0 0-.84l-8.5-5.5A.5.5 0 0 0 6 4.5Z" />
            </svg>
            Send it again
          </button>
        </div>

        <div className="relative mx-auto w-full max-w-sm overflow-hidden rounded-[2rem] border border-slate-800 bg-slate-900 shadow-2xl shadow-sky-950/50">
          <div className="flex items-center gap-3 border-b border-slate-800 px-5 py-4">
            <span className="flex h-9 w-9 items-center justify-center rounded-full bg-rose-400 text-sm font-semibold text-rose-950">PM</span>
            <div>
              <p className="text-sm font-semibold text-white">Priya Menon</p>
              <p className="text-xs text-slate-400">Wedding planning</p>
            </div>
          </div>
          <ul className="flex h-[340px] flex-col justify-end gap-2 px-4 pb-5">
            {thread.map((message) => (
              <li
                key={message.text}
                className={
                  message.from === "me"
                    ? "max-w-[78%] self-end rounded-2xl rounded-br-md bg-sky-500 px-4 py-2 text-sm text-slate-950"
                    : "max-w-[78%] self-start rounded-2xl rounded-bl-md bg-slate-800 px-4 py-2 text-sm text-slate-100"
                }
              >
                {message.text}
              </li>
            ))}
            <li className="mt-1 self-end">
              <motion.div
                variants={bubbleVariants}
                initial="initial"
                animate={controls}
                className="flex origin-bottom-right items-center justify-center rounded-3xl rounded-br-md bg-sky-500 px-5 py-3 text-3xl font-extrabold text-slate-950"
              >
                <span aria-hidden="true" className="flex">
                  {letters.map((char, index) => (
                    <motion.span key={index} variants={letterVariants} custom={{ index, total: letters.length }} className="inline-block">
                      {char === " " ? " " : char}
                    </motion.span>
                  ))}
                </span>
                <span className="sr-only">{LOUD_MESSAGE}</span>
              </motion.div>
              <p className="mt-1 text-right text-xs text-slate-500">Delivered</p>
            </li>
          </ul>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/message-bubble-text-that-shakes-and-explodes-into-letters.json

When to use it

How to customize it

Accessibility

Questions

How are the letters animated separately?

The phrase is split into one `motion.span` per character. The bubble and the letters share variant names, so a single `controls.start("explode")` call plays the bubble's variant and each letter's variant at once. The letter variant is a function that receives the letter's index and returns random keyframes.

Why does every play look different?

The `explosion` function calls `Math.random()` each time it runs, so direction, distance, spin and final size are picked fresh on every loop. The index only shapes the height of the hop, which makes the middle letters fly highest.

Can I trigger it when a message is sent?

Yes. Call `play()` from your send handler after the new message is rendered. The function ignores calls while a run is in progress, so quick double sends will not stack animations.

Does it work with emoji or accented letters?

`Array.from` splits the string by code point, so accented letters and most emoji stay whole. Emoji built from several joined code points, such as family emoji, will be split apart, so keep those out of the phrase.

Will letters be cut off at the edge of the panel?

The phone panel has `overflow-hidden`, so letters that travel past its edge are clipped there. That is intended: it stops the burst from covering nearby content. Remove that class if you want the letters to fly across the page.

Adapted from Text Explode in Animata, used under its open-source licence. Licences and credits

Related: Streaming AI Chat Thread with Typing Indicator, Button That Bursts Particles on Press, Text Scramble Decode Headline.

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

More in Text Effects

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