Add to Bag Button with Morphing Letter Label

A product panel for a candle shop with one long add-to-bag button. When the size changes, or the button moves through adding and added, the label does not swap in one jump: letters that both labels share slide to their new positions and the others fade. 'Add small to bag' becomes 'Adding to bag' and then 'Added to bag' with most of the letters staying on screen. The demo steps through sizes on its own until someone clicks. On phones the picture sits above the details and the button fills the width.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/text-morph)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";

const sizes = [
  { id: "small", label: "Small", weight: "120 g", burn: "25 hours", price: 18 },
  { id: "medium", label: "Medium", weight: "220 g", burn: "45 hours", price: 28 },
  { id: "large", label: "Large", weight: "400 g", burn: "80 hours", price: 42 },
];

const LETTER_SPRING = { type: "spring" as const, stiffness: 280, damping: 18, mass: 0.3 };

/**
 * Text whose letters are shared between one string and the next. A letter that
 * exists in both glides to its new place; the rest fade in or out.
 */
function TextMorph({ children, className = "" }: { children: string; className?: string }) {
  const prefix = useId();
  const characters = useMemo(() => {
    const seen: Record<string, number> = {};
    return children.split("").map((char) => {
      const key = char.toLowerCase();
      seen[key] = (seen[key] || 0) + 1;
      return { id: `${prefix}-${key}${seen[key]}`, label: char === " " ? " " : char };
    });
  }, [children, prefix]);

  return (
    <span className={className} aria-label={children}>
      <AnimatePresence mode="popLayout" initial={false}>
        {characters.map((character) => (
          <motion.span
            key={character.id}
            layoutId={character.id}
            aria-hidden="true"
            className="inline-block"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={LETTER_SPRING}
          >
            {character.label}
          </motion.span>
        ))}
      </AnimatePresence>
    </span>
  );
}

export default function AddToBagButtonWithMorphingLetterLabel() {
  const reduceMotion = useReducedMotion();
  const [sizeId, setSizeId] = useState("medium");
  const [stage, setStage] = useState<"idle" | "adding" | "added">("idle");
  const [bag, setBag] = useState(0);
  const touched = useRef(false);
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const size = sizes.find((item) => item.id === sizeId) ?? sizes[1];

  const label = stage === "adding" ? "Adding to bag" : stage === "added" ? "Added to bag" : `Add ${size.label.toLowerCase()} to bag`;

  const add = () => {
    if (stage !== "idle") return;
    setStage("adding");
    timers.current.push(
      setTimeout(() => {
        setStage("added");
        setBag((count) => count + 1);
      }, 900),
      setTimeout(() => setStage("idle"), 2500)
    );
  };

  // Demo: step through the sizes and one purchase so the letters keep rearranging.
  const addRef = useRef(add);
  addRef.current = add;
  useEffect(() => {
    if (reduceMotion) return;
    const script = ["large", "add", "", "small", "medium"];
    let step = 0;
    const timer = setInterval(() => {
      if (touched.current) return;
      const action = script[step % script.length];
      step += 1;
      if (action === "add") addRef.current();
      else if (action) setSizeId(action);
    }, 1500);
    return () => clearInterval(timer);
  }, [reduceMotion]);
  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  return (
    <MotionConfig reducedMotion="user">
      <section className="bg-amber-50 px-5 py-14 text-stone-900 sm:px-8 sm:py-16">
        <div className="mx-auto grid max-w-4xl items-center gap-8 md:grid-cols-2 md:gap-12">
          {/* Product picture, drawn */}
          <div className="relative mx-auto aspect-square w-full max-w-sm overflow-hidden rounded-[2rem] bg-amber-200">
            <svg viewBox="0 0 320 320" aria-hidden="true" className="absolute inset-0 h-full w-full">
              <defs>
                <linearGradient id="morph-label-jar" x1="0" y1="0" x2="1" y2="0">
                  <stop offset="0" stopColor="#44403c" />
                  <stop offset="0.5" stopColor="#78716c" />
                  <stop offset="1" stopColor="#292524" />
                </linearGradient>
                <radialGradient id="morph-label-halo" cx="0.5" cy="0.5" r="0.5">
                  <stop offset="0" stopColor="#fef3c7" />
                  <stop offset="1" stopColor="#fef3c7" stopOpacity="0" />
                </radialGradient>
              </defs>
              <circle cx="160" cy="96" r="120" fill="url(#morph-label-halo)" />
              <ellipse cx="160" cy="270" rx="110" ry="14" fill="#92400e" opacity="0.25" />
              <rect x="88" y="138" width="144" height="132" rx="16" fill="url(#morph-label-jar)" />
              <rect x="88" y="138" width="144" height="18" rx="9" fill="#fef3c7" />
              <rect x="112" y="186" width="96" height="52" rx="4" fill="#fffbeb" />
              <path d="M124 202h72M124 214h48M124 226h60" stroke="#44403c" strokeWidth="3" strokeLinecap="round" />
              <path d="M160 138v-14" stroke="#1c1917" strokeWidth="3" strokeLinecap="round" />
              <path className="add-to-bag-morph-flame" d="M160 122c-12-10-12-26 0-44 12 18 12 34 0 44Z" fill="#f59e0b" />
              <path d="M160 120c-5-6-5-13 0-21 5 8 5 15 0 21Z" fill="#fef3c7" />
            </svg>
            <p className="absolute left-5 top-5 rounded-full bg-stone-900 px-3 py-1 text-xs font-semibold text-amber-50">Poured in small batches</p>
            <p className="absolute bottom-5 right-5 flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-xs font-semibold text-stone-800 shadow">
              <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
                <path d="M5 7h10l-.8 9H5.8L5 7Zm2.5 0a2.5 2.5 0 0 1 5 0" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
              </svg>
              <span aria-live="polite">Bag: {bag}</span>
            </p>
          </div>

          <div>
            <p className="text-xs font-semibold uppercase tracking-widest text-amber-800">Wickwell Candle Co.</p>
            <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">Cedar and woodsmoke</h2>
            <p className="mt-3 text-base leading-7 text-stone-700">Soy wax with cedar, birch tar and a little black pepper. Smells like the last hour of a campfire.</p>

            <fieldset className="mt-6">
              <legend className="text-sm font-semibold">Size</legend>
              <div className="mt-2 grid grid-cols-3 gap-2">
                {sizes.map((item) => (
                  <label
                    key={item.id}
                    className={`cursor-pointer rounded-2xl border px-3 py-2.5 text-center transition-colors focus-within:ring-2 focus-within:ring-amber-600 ${
                      item.id === sizeId ? "border-stone-900 bg-stone-900 text-amber-50" : "border-stone-300 bg-white text-stone-800 hover:border-stone-500"
                    }`}
                  >
                    <input
                      type="radio"
                      name="add-to-bag-morph-size"
                      aria-label={`${item.label}, ${item.weight}`}
                      value={item.id}
                      checked={item.id === sizeId}
                      onChange={() => {
                        touched.current = true;
                        setSizeId(item.id);
                      }}
                      className="sr-only"
                    />
                    <span className="block text-sm font-semibold">{item.label}</span>
                    <span className="block text-xs opacity-80">{item.weight}</span>
                  </label>
                ))}
              </div>
            </fieldset>

            <p className="mt-5 flex items-baseline justify-between text-sm text-stone-700">
              <span>Burns for about {size.burn}</span>
              <span className="text-2xl font-bold text-stone-900">${size.price}</span>
            </p>

            <motion.button
              type="button"
              layout
              onClick={() => {
                touched.current = true;
                add();
              }}
              whileTap={reduceMotion ? undefined : { scale: 0.97 }}
              style={{ borderRadius: 9999 }}
              className={`mt-4 flex h-14 w-full items-center justify-center gap-3 px-6 text-base font-semibold transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-600 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50 ${
                stage === "added" ? "bg-emerald-700 text-white" : "bg-stone-900 text-amber-50"
              }`}
            >
              <span aria-live="polite">
                <TextMorph>{label}</TextMorph>
              </span>
              <AnimatePresence mode="popLayout" initial={false}>
                {stage === "added" && (
                  <motion.svg
                    key="tick"
                    layout
                    viewBox="0 0 20 20"
                    fill="none"
                    aria-hidden="true"
                    className="h-5 w-5"
                    initial={{ opacity: 0, scale: 0.4 }}
                    animate={{ opacity: 1, scale: 1 }}
                    exit={{ opacity: 0, scale: 0.4 }}
                    transition={LETTER_SPRING}
                  >
                    <path d="m4.5 10.5 3.5 3.5 7.5-8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
                  </motion.svg>
                )}
              </AnimatePresence>
            </motion.button>
            <p className="mt-3 text-center text-xs text-stone-600">Free delivery over $40. Posted the next working day.</p>
          </div>
        </div>
        <style>{`
          @keyframes add-to-bag-morph-flicker { 0%, 100% { transform: scale(1) rotate(-2deg); } 50% { transform: scale(1.08, 0.94) rotate(3deg); } }
          .add-to-bag-morph-flame { transform-origin: 160px 122px; animation: add-to-bag-morph-flicker 1.4s ease-in-out infinite; }
          @media (prefers-reduced-motion: reduce) { .add-to-bag-morph-flame { animation: none; } }
        `}</style>
      </section>
    </MotionConfig>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/add-to-bag-button-with-morphing-letter-label.json

When to use it

How to customize it

Accessibility

Questions

How does the text morph between two labels?

Every letter gets an id made of the character and how many times it has appeared so far, for example the second 'a'. That id is used as both the React key and the `layoutId`. A letter with the same id in the next label is the same element, so framer-motion slides it to its new position.

Why do some letters fade instead of moving?

A letter that is not in the next label has nowhere to go, so it fades out. A letter that is new fades in. `AnimatePresence` with `mode="popLayout"` takes leaving letters out of the flow at once so the rest can close the gap.

Does the button change width smoothly?

Here the button is full width, so it does not change size. On an auto-width button, keep the `layout` prop on the button so its width animates along with the letters.

Will long labels be slow?

Each letter is one small element moved with a transform. Button and heading lengths are fine. For whole paragraphs, use a word-level effect instead.

Does it work with upper and lower case?

Yes. Matching is done on the lower-case letter, so a capital 'A' can become a small 'a' in place. The visible letter always comes from the new label.

Adapted from Text Morph in Motion Primitives, used under its open-source licence. Licences and credits

Related: Shimmer Button with Light Sweep in Three Sizes, Hold to Confirm Button with Progress Ring, Product Card with Price, Rating, Colour Swatches and Add to Cart.

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