Morphing Pricing Card with Reflowing Features

A single pricing card that changes shape as the plan changes, next to a three-row plan picker with a sliding highlight. Switching plan counts the price to its new value, pops team avatars in or out, swaps wording inside rows that changed, slides the features a plan adds into the top of the list and moves the carried-over ones down through a two-column grid while the card grows to fit. The glow behind the price drifts and changes colour with the plan. A demo cycles the plans until a visitor picks one. On phones the picker stacks above the card and the list becomes one column.

Code

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

const plans = [
  { id: "solo", name: "Solo", price: 12, seats: 1, blurb: "One person shipping client sites.", note: "What you get", cta: "Start with Solo", accent: "#f59e0b", orb: { x: -30, y: -50, scale: 0.8 } },
  { id: "studio", name: "Studio", price: 39, seats: 5, blurb: "A small team with shared projects.", note: "Everything in Solo, plus", cta: "Start with Studio", accent: "#f97316", orb: { x: 150, y: -10, scale: 1.1 } },
  { id: "agency", name: "Agency", price: 96, seats: 12, blurb: "Many clients, strict sign-off, one bill.", note: "Everything in Studio, plus", cta: "Start with Agency", accent: "#f43f5e", orb: { x: 300, y: -60, scale: 1.4 } },
];

/** One row per feature. `text` holds the wording for each plan in order; null means the plan does not have it. */
const features: { key: string; text: (string | null)[] }[] = [
  { key: "projects", text: ["3 live projects", "20 live projects", "Unlimited projects"] },
  { key: "domain", text: ["Custom domains", "Custom domains", "Custom domains"] },
  { key: "storage", text: ["5 GB of storage", "100 GB of storage", "1 TB of storage"] },
  { key: "support", text: ["Email support", "Priority support", "Named account manager"] },
  { key: "analytics", text: [null, "Visitor analytics", "Analytics with exports"] },
  { key: "review", text: [null, "Shared review links", "Shared review links"] },
  { key: "roles", text: [null, "Roles and permissions", "Roles and permissions"] },
  { key: "staging", text: [null, "Staging sites", "Staging sites"] },
  { key: "sso", text: [null, null, "Single sign-on"] },
  { key: "billing", text: [null, null, "Client billing"] },
  { key: "audit", text: [null, null, "Audit log, 12 months"] },
  { key: "uptime", text: [null, null, "Uptime agreement"] },
];

const people = ["MO", "JT", "AK", "RS", "LV", "DB", "NE", "HC", "PW", "SI", "TG", "YU"];
const tones = ["#fbbf24", "#fb923c", "#f472b6", "#a78bfa", "#34d399", "#38bdf8"];
/** Milliseconds before the demo first changes plan, and between changes after that. */
const FIRST = 900;
const EVERY = 2000;
const spring = { type: "spring" as const, stiffness: 300, damping: 30 };

export default function MorphingPricingCardWithReflowingFeatures() {
  const reduce = useReducedMotion();
  const still = Boolean(reduce);
  const rootRef = useRef<HTMLElement>(null);
  const innerRef = useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { amount: 0.3 });
  const [index, setIndex] = useState(0);
  const [auto, setAuto] = useState(true);
  const [moved, setMoved] = useState(false);
  const [height, setHeight] = useState<number | null>(null);
  const plan = plans[index];

  // The demo walks through the plans until a visitor picks one.
  useEffect(() => {
    if (!auto || still || !inView) return;
    const id = window.setTimeout(() => {
      setMoved(true);
      setIndex((i) => (i + 1) % plans.length);
    }, moved ? EVERY : FIRST);
    return () => window.clearTimeout(id);
  }, [auto, still, inView, index, moved]);

  // The list changes length, so its box is measured and the height is animated to match.
  useEffect(() => {
    const el = innerRef.current;
    if (!el) return;
    const measure = () => setHeight(el.offsetHeight);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  const price = useMotionValue(plans[0].price);
  const shownPrice = useTransform(price, (v) => Math.round(v));
  useEffect(() => {
    const controls = animate(price, plan.price, still ? { duration: 0 } : { duration: 0.7, ease: [0.22, 1, 0.36, 1] });
    return () => controls.stop();
  }, [plan.price, price, still]);

  // Features this plan adds come first, so the ones carried over visibly move down.
  const firstPlanWith = (f: (typeof features)[number]) => f.text.findIndex((t) => t !== null);
  const list = features
    .filter((f) => f.text[index] !== null)
    .map((f) => ({ key: f.key, label: f.text[index] as string, fresh: firstPlanWith(f) === index }))
    .sort((a, b) => Number(b.fresh) - Number(a.fresh));
  const swap = still ? { duration: 0 } : { duration: 0.28, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] };

  return (
    <section ref={rootRef} className="overflow-hidden bg-amber-50 px-5 py-16 text-stone-900 sm:px-6 sm:py-20">
      <div className="mx-auto grid max-w-5xl grid-cols-[minmax(0,1fr)] items-start gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] lg:gap-14">
        <motion.div initial={still ? false : { opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }} className="min-w-0 lg:pt-6">
          <p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Pricing</p>
          <h2 className="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">One card. Pick the size that fits.</h2>
          <p className="mt-4 max-w-md text-base leading-7 text-stone-600">Choose a plan and watch what changes. Nothing is hidden in a comparison table three screens down.</p>

          <div role="group" aria-label="Choose a plan" className="mt-8 space-y-1.5">
            {plans.map((p, i) => {
              const on = i === index;
              return (
                <button
                  key={p.id}
                  type="button"
                  aria-pressed={on}
                  onClick={() => {
                    setAuto(false);
                    setIndex(i);
                  }}
                  className="relative flex w-full items-center gap-4 rounded-2xl px-5 py-3.5 text-left transition-colors hover:bg-white/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50"
                >
                  {on ? <motion.span layoutId="morphing-pricing-card-with-reflowing-features-pick" aria-hidden="true" className="absolute inset-0 rounded-2xl bg-white shadow-md shadow-orange-900/5 ring-1 ring-stone-900/5" transition={still ? { duration: 0 } : spring} /> : null}
                  <span aria-hidden="true" className="relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors" style={{ borderColor: on ? p.accent : "#a8a29e" }}>
                    <span className="h-2.5 w-2.5 rounded-full transition-transform duration-300 motion-reduce:transition-none" style={{ backgroundColor: p.accent, transform: on ? "scale(1)" : "scale(0)" }} />
                  </span>
                  <span className="relative min-w-0 flex-1">
                    <span className="block text-base font-semibold">{p.name}</span>
                    <span className="block truncate text-sm text-stone-600">{p.blurb}</span>
                  </span>
                  <span className="relative text-base font-semibold tabular-nums">
                    ${p.price}
                    <span className="text-sm font-normal text-stone-600">/mo</span>
                  </span>
                </button>
              );
            })}
          </div>
          <p className="mt-5 px-1 text-sm text-stone-600">US dollars, billed yearly. Change plan or cancel whenever you like.</p>
        </motion.div>

        {/* The space is reserved for the tallest plan, so the page below does not jump. */}
        <div className="min-w-0 lg:min-h-[640px]">
          <motion.div
            className="relative rounded-[32px] bg-white p-1.5 shadow-2xl shadow-orange-900/10 ring-1 ring-stone-900/5"
            initial={still ? false : { opacity: 0, y: 36, scale: 0.95 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            transition={{ type: "spring", stiffness: 130, damping: 18, delay: 0.1 }}
          >
            <div className="relative overflow-hidden rounded-[27px] bg-stone-950 p-6 text-white sm:p-7">
              <motion.span
                aria-hidden="true"
                className="pointer-events-none absolute left-0 top-0 h-64 w-64 rounded-full blur-3xl"
                initial={false}
                animate={{ backgroundColor: plan.accent, x: plan.orb.x, y: plan.orb.y, scale: plan.orb.scale, opacity: 0.55 }}
                transition={still ? { duration: 0 } : { type: "spring", stiffness: 60, damping: 16 }}
              />
              <div className="relative flex items-center justify-between">
                <p className="relative h-6 overflow-hidden text-sm font-semibold uppercase tracking-widest">
                  <AnimatePresence initial={false} mode="popLayout">
                    <motion.span key={plan.id} className="block" initial={still ? false : { y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: -20, opacity: 0 }} transition={swap}>
                      {plan.name}
                    </motion.span>
                  </AnimatePresence>
                </p>
                <AnimatePresence initial={false}>
                  {plan.id === "studio" ? (
                    <motion.span key="badge" className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-stone-950" initial={still ? false : { opacity: 0, scale: 0.6, x: 12 }} animate={{ opacity: 1, scale: 1, x: 0 }} exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.15 } }} transition={{ type: "spring", stiffness: 420, damping: 24 }}>
                      Most teams pick this
                    </motion.span>
                  ) : null}
                </AnimatePresence>
              </div>

              <p className="relative mt-5 flex items-baseline gap-1" aria-label={`${plan.price} dollars per month`}>
                <span aria-hidden="true" className="text-3xl font-semibold text-white/70">$</span>
                <motion.span aria-hidden="true" className="text-7xl font-semibold tabular-nums leading-none tracking-tighter">
                  {shownPrice}
                </motion.span>
                <span aria-hidden="true" className="ml-1 text-base text-white/70">per month</span>
              </p>

              <div className="relative mt-6 flex flex-wrap items-center gap-x-4 gap-y-3">
                <div aria-hidden="true" className="flex h-8 items-center">
                  <AnimatePresence initial={false} mode="popLayout">
                    {people.slice(0, plan.seats).map((who, i) => (
                      <motion.span
                        key={who}
                        layout={still ? false : "position"}
                        className="-ml-1.5 flex h-7 w-7 items-center justify-center rounded-full text-[9px] font-bold text-stone-950 ring-2 ring-stone-950 first:ml-0"
                        style={{ backgroundColor: tones[i % tones.length] }}
                        initial={still ? false : { opacity: 0, scale: 0, x: -10 }}
                        animate={{ opacity: 1, scale: 1, x: 0 }}
                        exit={{ opacity: 0, scale: 0, transition: { duration: 0.18 } }}
                        transition={{ type: "spring", stiffness: 480, damping: 26, delay: still ? 0 : Math.max(0, i - 1) * 0.035 }}
                      >
                        {who}
                      </motion.span>
                    ))}
                  </AnimatePresence>
                </div>
                <p className="text-sm text-white/80">
                  {plan.seats} {plan.seats === 1 ? "seat" : "seats"} included
                </p>
              </div>
            </div>

            <motion.div className="overflow-hidden" initial={false} animate={{ height: height ?? "auto" }} transition={still ? { duration: 0 } : { type: "spring", stiffness: 210, damping: 28 }}>
              <div ref={innerRef} className="px-5 pb-2 pt-6 sm:px-6">
                <p className="relative h-5 overflow-hidden text-sm font-semibold text-stone-900">
                  <AnimatePresence initial={false} mode="popLayout">
                    <motion.span key={plan.note} className="block leading-5" initial={still ? false : { y: 16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: -16, opacity: 0 }} transition={swap}>
                      {plan.note}
                    </motion.span>
                  </AnimatePresence>
                </p>
                <ul className="relative mt-3 grid gap-x-6 sm:grid-cols-2">
                  <AnimatePresence initial={false} mode="popLayout">
                    {list.map((f, i) => (
                      <motion.li
                        key={f.key}
                        layout={still ? false : "position"}
                        className="flex h-10 items-center gap-3 text-sm text-stone-700"
                        initial={still ? false : { opacity: 0, x: -14, filter: "blur(6px)" }}
                        animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
                        exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.16 } }}
                        transition={{ ...spring, delay: still || !f.fresh ? 0 : 0.08 + i * 0.05 }}
                      >
                        <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-white transition-colors duration-500" style={{ backgroundColor: f.fresh ? plan.accent : "#44403c" }}>
                          <svg viewBox="0 0 20 20" aria-hidden="true" className="h-3 w-3">
                            <path d="M5 10.5 8.5 14 15 6.5" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
                          </svg>
                        </span>
                        <span className="relative block h-5 min-w-0 flex-1 overflow-hidden">
                          <AnimatePresence initial={false} mode="popLayout">
                            <motion.span key={f.label} className={`block truncate leading-5 ${f.fresh ? "font-semibold text-stone-900" : ""}`} initial={still ? false : { y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={still ? { opacity: 0 } : { y: -14, opacity: 0 }} transition={swap}>
                              {f.label}
                            </motion.span>
                          </AnimatePresence>
                        </span>
                      </motion.li>
                    ))}
                  </AnimatePresence>
                </ul>
              </div>
            </motion.div>

            <div className="px-5 pb-5 pt-4 sm:px-6 sm:pb-6">
              <a
                href="#"
                className="flex h-12 items-center justify-center rounded-full text-base font-semibold text-stone-950 transition-colors duration-500 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
                style={{ backgroundColor: plan.accent }}
              >
                {plan.cta}
              </a>
              <p className="mt-3 text-center text-xs text-stone-600">14 days free. No card needed to start.</p>
            </div>
          </motion.div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/morphing-pricing-card-with-reflowing-features.json

When to use it

How to customize it

Accessibility

Questions

How does the feature list re-flow?

Every row is a motion element with the `layout` prop and a stable key. When the plan changes, React renders the list in its new order. The library measures where each row was and where it is now, then animates the difference with a transform, so rows glide to their new cell, including across columns. Rows that are new fade in and rows that are gone fade out through `AnimatePresence`.

How does the card change height smoothly?

The list sits inside a wrapper with hidden overflow. A `ResizeObserver` measures the inner box and the wrapper animates its height to that number on a spring. The rows inside are already at their final positions, so the growing wrapper simply reveals them.

How does the sliding highlight on the plan picker work?

The white panel behind the active plan is one element with a `layoutId`. It is rendered inside whichever button is active, and the library animates it from its old position to the new one. That is the shared-layout technique.

Can I add a monthly and yearly switch?

Yes. Give each plan a second price, keep a `yearly` state and pass the right number to the price animation. The counting effect already reacts to any change in the price.

Why does the page not jump when the card grows?

The column that holds the card reserves the height of the tallest plan on large screens with a `min-h` class. On phones the content below moves down, which is the expected behaviour for a stacked layout.

Related: Odometer Pricing Cards with Orbiting Glow, Pricing Table with Monthly and Yearly Toggle, Segmented Control with Spring Pill and Morphing Views.

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

More in Animated Cards

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