Flip Cards with Details on the Back

Three product cards that turn over in 3D. The front shows an illustration, a name and a price; the back shows tasting notes, a few facts and an add to bag button. A card turns when you press its button or click the front, and turns back with the button on the reverse. Until someone interacts, the turned card moves along the row on its own. On phones the cards stack in one column.

Code

import { useEffect, useRef, useState } from "react";

const coffees = [
  {
    no: "01",
    name: "Huila Sunrise",
    origin: "Colombia, washed",
    price: 19,
    face: "bg-orange-600",
    sun: "bg-amber-300",
    hillFar: "#9a3412",
    hillNear: "#7c2d12",
    button: "bg-orange-500 hover:bg-orange-400",
    notes: ["Red apple", "Panela", "Milk chocolate"],
    blurb: "Sweet and round, with a clean finish. The one we drink on the roasting floor.",
    roast: 2,
    altitude: "1,750 m",
    best: "Filter and batch brew",
  },
  {
    no: "02",
    name: "Guji Bloom",
    origin: "Ethiopia, natural",
    price: 23,
    face: "bg-teal-700",
    sun: "bg-rose-300",
    hillFar: "#115e59",
    hillNear: "#134e4a",
    button: "bg-teal-500 hover:bg-teal-400",
    notes: ["Blueberry", "Jasmine", "Lemon peel"],
    blurb: "Bright, floral and a little wild. Dried on raised beds for three weeks.",
    roast: 1,
    altitude: "2,100 m",
    best: "Pour over",
  },
  {
    no: "03",
    name: "Night Shift",
    origin: "Brazil and Sumatra blend",
    price: 17,
    face: "bg-rose-900",
    sun: "bg-orange-300",
    hillFar: "#701a34",
    hillNear: "#4c0519",
    button: "bg-rose-500 hover:bg-rose-400",
    notes: ["Dark cocoa", "Toasted hazelnut", "Molasses"],
    blurb: "Heavy, low in acid and built to cut through milk at any hour.",
    roast: 4,
    altitude: "1,200 m",
    best: "Espresso and moka pot",
  },
];

const hidden = { backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" } as const;

export default function FlipCardsWithDetailsOnBack() {
  // The middle card starts turned over so the back is visible straight away.
  const [flipped, setFlipped] = useState<boolean[]>([false, true, false]);
  const [refocus, setRefocus] = useState<{ i: number; side: "front" | "back" } | null>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const frontBtn = useRef<(HTMLButtonElement | null)[]>([]);
  const backBtn = useRef<(HTMLButtonElement | null)[]>([]);
  const touched = useRef(false);

  const flip = (i: number, to: boolean) => {
    touched.current = true;
    setFlipped((f) => f.map((v, k) => (k === i ? to : v)));
    setRefocus({ i, side: to ? "back" : "front" });
  };

  // After a person flips a card, keyboard focus follows to the side now showing.
  useEffect(() => {
    if (!refocus) return;
    (refocus.side === "back" ? backBtn : frontBtn).current[refocus.i]?.focus({ preventScroll: true });
  }, [refocus]);

  // Idle demo: the turned card moves along the row until someone interacts.
  useEffect(() => {
    const list = listRef.current;
    if (!list || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let visible = true;
    let step = 1;
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
    });
    io.observe(list);
    const stop = () => {
      touched.current = true;
    };
    list.addEventListener("pointerdown", stop);
    list.addEventListener("focusin", stop);
    const tick = () => {
      if (touched.current || !visible || document.hidden) return;
      step = (step + 1) % coffees.length;
      setFlipped(coffees.map((_, k) => k === step));
    };
    const first = window.setTimeout(tick, 850);
    const loop = window.setInterval(tick, 2000);
    return () => {
      window.clearTimeout(first);
      window.clearInterval(loop);
      io.disconnect();
      list.removeEventListener("pointerdown", stop);
      list.removeEventListener("focusin", stop);
    };
  }, []);

  return (
    <section className="bg-stone-100 px-5 py-16 text-stone-900 sm:px-6 sm:py-20">
      <style>{`
        @keyframes flip-cards-with-details-on-back-rise {
          from { opacity: 0; transform: translateY(30px) rotate(-2deg); }
          to { opacity: 1; transform: none; }
        }
        .flip-cards-with-details-on-back-rise {
          animation: flip-cards-with-details-on-back-rise 0.75s cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        .flip-cards-with-details-on-back-turn {
          transform-style: preserve-3d;
          transition: transform 0.75s cubic-bezier(0.3, 1.3, 0.45, 1);
        }
        @media (prefers-reduced-motion: reduce) {
          .flip-cards-with-details-on-back-rise { animation: none; }
          .flip-cards-with-details-on-back-turn { transition: none; }
        }
      `}</style>
      <div className="mx-auto max-w-5xl">
        <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
          <div>
            <p className="text-sm font-semibold uppercase tracking-widest text-orange-700">Fieldnote Coffee Roasters</p>
            <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-5xl">This month on the roaster</h2>
          </div>
          <p className="max-w-xs text-sm leading-6 text-stone-600">Roasted every Tuesday in small batches. Turn a card over for tasting notes and brew advice.</p>
        </div>

        <ul ref={listRef} className="mt-12 grid gap-6 md:grid-cols-3">
          {coffees.map((c, i) => {
            const isBack = flipped[i];
            return (
              <li key={c.name} className="flip-cards-with-details-on-back-rise" style={{ animationDelay: `${i * 0.1}s` }}>
                <div className="transition-transform duration-300 hover:-translate-y-1.5 motion-reduce:transition-none" style={{ perspective: "1400px" }}>
                  <div className="flip-cards-with-details-on-back-turn relative h-[440px]" style={{ transform: `rotateY(${isBack ? 180 : 0}deg)` }}>
                    {/* Front */}
                    <div
                      aria-hidden={isBack}
                      onClick={() => flip(i, true)}
                      className="absolute inset-0 flex cursor-pointer flex-col overflow-hidden rounded-3xl bg-white shadow-xl shadow-stone-900/10"
                      style={hidden}
                    >
                      <div className={`relative flex-1 overflow-hidden ${c.face}`}>
                        <span className={`absolute left-1/2 top-[46%] h-28 w-28 -translate-x-1/2 -translate-y-1/2 rounded-full ${c.sun}`} />
                        <svg viewBox="0 0 400 200" preserveAspectRatio="none" aria-hidden="true" className="absolute inset-x-0 bottom-0 h-3/5 w-full">
                          <path d="M0 110 C70 60 120 70 190 104 C250 132 310 70 400 96 V200 H0Z" fill={c.hillFar} />
                          <path d="M0 150 C80 110 150 150 230 140 C300 130 340 110 400 140 V200 H0Z" fill={c.hillNear} />
                        </svg>
                        <span className="absolute left-5 top-5 text-xs font-semibold tracking-widest text-white/90">No. {c.no}</span>
                        <span className="absolute right-5 top-5 rounded-full bg-white/20 px-2.5 py-1 text-xs font-semibold text-white">250 g</span>
                      </div>
                      <div className="p-6">
                        <div className="flex items-baseline justify-between gap-3">
                          <h3 className="text-xl font-semibold tracking-tight">{c.name}</h3>
                          <span className="text-lg font-semibold">${c.price}</span>
                        </div>
                        <p className="mt-1 text-sm text-stone-600">{c.origin}</p>
                        <button
                          type="button"
                          ref={(el) => { frontBtn.current[i] = el; }}
                          tabIndex={isBack ? -1 : 0}
                          aria-label={`Show tasting notes for ${c.name}`}
                          className="mt-5 inline-flex h-11 w-full items-center justify-center gap-2 rounded-full border border-stone-300 text-sm font-semibold text-stone-900 transition hover:border-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2"
                        >
                          <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                            <path d="M3.5 10a6.5 6.5 0 0 1 11.3-4.4M16.5 10a6.5 6.5 0 0 1-11.3 4.4M15 2.5v3.3h-3.3M5 17.5v-3.3h3.3" />
                          </svg>
                          Tasting notes
                        </button>
                      </div>
                    </div>

                    {/* Back */}
                    <div
                      aria-hidden={!isBack}
                      className="absolute inset-0 flex flex-col overflow-hidden rounded-3xl bg-stone-900 p-6 text-stone-100 shadow-xl shadow-stone-900/20"
                      style={{ ...hidden, transform: "rotateY(180deg)" }}
                    >
                      <div className="flex items-start justify-between gap-3">
                        <div>
                          <p className="text-xs font-semibold tracking-widest text-stone-400">No. {c.no}</p>
                          <h3 className="mt-1 text-xl font-semibold tracking-tight text-white">{c.name}</h3>
                        </div>
                        <button
                          type="button"
                          ref={(el) => { backBtn.current[i] = el; }}
                          tabIndex={isBack ? 0 : -1}
                          onClick={() => flip(i, false)}
                          aria-label={`Turn ${c.name} back over`}
                          className="flex h-10 w-10 flex-none items-center justify-center rounded-full border border-stone-700 text-stone-200 transition hover:border-stone-400 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
                        >
                          <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                            <path d="M8 5 3.5 9.5 8 14M4 9.5h8a4.5 4.5 0 0 1 0 9h-1" transform="translate(0 -2.5)" />
                          </svg>
                        </button>
                      </div>
                      <p className="mt-4 text-sm leading-6 text-stone-300">{c.blurb}</p>
                      <ul className="mt-5 flex flex-wrap gap-2">
                        {c.notes.map((n) => (
                          <li key={n} className="rounded-full bg-stone-800 px-3 py-1.5 text-xs font-medium text-stone-100">{n}</li>
                        ))}
                      </ul>
                      <dl className="mt-6 space-y-3 border-t border-stone-800 pt-5 text-sm">
                        <div className="flex items-center justify-between">
                          <dt className="text-stone-400">Roast</dt>
                          <dd className="flex items-center gap-1.5" aria-label={`${c.roast} out of 5`}>
                            {[1, 2, 3, 4, 5].map((d) => (
                              <span key={d} className={`h-2 w-5 rounded-full ${d <= c.roast ? "bg-amber-300" : "bg-stone-700"}`} />
                            ))}
                          </dd>
                        </div>
                        <div className="flex items-center justify-between">
                          <dt className="text-stone-400">Grown at</dt>
                          <dd className="font-medium text-white">{c.altitude}</dd>
                        </div>
                        <div className="flex items-center justify-between gap-4">
                          <dt className="text-stone-400">Best as</dt>
                          <dd className="text-right font-medium text-white">{c.best}</dd>
                        </div>
                      </dl>
                      <a
                        href="#"
                        tabIndex={isBack ? 0 : -1}
                        className={`mt-auto inline-flex h-12 items-center justify-center rounded-full text-sm font-semibold text-stone-950 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 ${c.button}`}
                      >
                        Add to bag, ${c.price}
                      </a>
                    </div>
                  </div>
                </div>
              </li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/flip-cards-with-details-on-back.json

When to use it

How to customize it

Accessibility

Questions

How does a CSS flip card work?

The two faces are stacked in the same place inside a wrapper with `transform-style: preserve-3d`. The back face is pre-rotated 180 degrees and both faces have `backface-visibility: hidden`, so only the face pointing at the viewer is drawn. Rotating the wrapper by 180 degrees swaps them. A parent with `perspective` gives the turn its depth.

Why is the flip on click and not on hover?

Hover does not exist on touch screens and cannot be reached with a keyboard, so anything on the back would be lost to many visitors. A button works everywhere. Hover is only used for a small lift.

The back face shows mirrored or flickers in Safari. Why?

Safari needs the prefixed `-webkit-backface-visibility: hidden` as well as the standard property. Both are set here through the shared `hidden` style object. Also keep `overflow-hidden` on the faces, not on the rotating wrapper, or the 3D is flattened.

Can I put a form or links on the back?

Yes. Anything can go on the back face. Give each control `tabIndex={isBack ? 0 : -1}` as the link here does, so it can only be focused while that side is showing.

Does it need framer-motion?

No. The turn is a CSS transition on `transform`. React only holds which cards are flipped.

Related: Product Card with Price, Rating, Colour Swatches and Add to Cart, 3D Tilt Card with Parallax Layers and Glare, Spotlight Cards with Pointer Light.

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