Product Card with Rising Details Panel on Hover

A row of three shop cards where each card rearranges itself to show more. At rest a card shows a tag, a price, a drawn product picture and a dark title bar along the bottom. Hover it, tap the title bar or press Enter on it and the bar grows upward into a panel with a description, spec chips and a buy button, the picture lifts and fades, and an extra delivery line fades in under the tag. The demo opens each card in turn until someone interacts. Cards sit three across on desktop, two on tablets and one per row on phones.

Code

// Adapted from Cult UI (https://www.cult-ui.com/docs/components/shift-card)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowUpRight, ChevronUp } from "lucide-react";

const products = [
  {
    id: "kettle",
    name: "Pour-over kettle",
    price: "$84",
    tag: "Back in stock",
    tint: "from-amber-100 to-orange-50",
    accent: "#b45309",
    blurb: "A slow, steady pour from a long gooseneck spout. Holds 900 ml and works on every hob, induction included.",
    specs: ["Brushed steel", "900 ml", "2 year cover"],
  },
  {
    id: "grinder",
    name: "Hand grinder",
    price: "$129",
    tag: "Staff pick",
    tint: "from-sky-100 to-cyan-50",
    accent: "#0369a1",
    blurb: "Forty click settings from espresso to cold brew. The steel burrs come apart without tools for cleaning.",
    specs: ["Steel burrs", "30 g hopper", "Travel case"],
  },
  {
    id: "dripper",
    name: "Ceramic dripper",
    price: "$38",
    tag: "New glaze",
    tint: "from-rose-100 to-pink-50",
    accent: "#be123c",
    blurb: "Thrown and glazed by hand in small batches. The ribbed walls keep the paper off the sides for an even brew.",
    specs: ["Stoneware", "1 to 2 cups", "Dishwasher safe"],
  },
];

/** Height of the bottom panel when closed (title bar only) and open. */
const CLOSED = 56;
const OPEN = 244;

function Drawing({ id, color }: { id: string; color: string }) {
  const common = { fill: "none", stroke: color, strokeWidth: 3, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
  return (
    <svg viewBox="0 0 120 120" className="h-28 w-28" aria-hidden="true">
      <ellipse cx="60" cy="104" rx="34" ry="5" fill={color} opacity="0.12" />
      {id === "kettle" && (
        <g {...common}>
          <path d="M40 98h40l-5-46H45z" fill="#fff" />
          <path d="M45 52c0-8 30-8 30 0" />
          <path d="M57 44h6" />
          <path d="M43 80C22 76 30 46 14 40" />
          <path d="M78 58c18-2 18 26 1 28" />
        </g>
      )}
      {id === "grinder" && (
        <g {...common}>
          <rect x="44" y="40" width="32" height="58" rx="6" fill="#fff" />
          <path d="M44 66h32" />
          <path d="M60 40V28h26" />
          <circle cx="90" cy="28" r="5" fill="#fff" />
        </g>
      )}
      {id === "dripper" && (
        <g {...common}>
          <path d="M30 40h60L72 80H48z" fill="#fff" />
          <path d="M38 86h44" />
          <path d="M52 86v8h16v-8" />
          <path d="M48 48l8 24M72 48l-8 24M60 48v24" opacity="0.5" />
          <path d="M90 46c12 0 12 16 0 16" />
        </g>
      )}
    </svg>
  );
}

export default function ProductCardWithRisingDetailsPanelOnHover() {
  const reduce = useReducedMotion();
  const [open, setOpen] = useState<string | null>(null);
  const [touched, setTouched] = useState(false);

  // Until someone interacts, open each card in turn so the preview shows the effect.
  useEffect(() => {
    if (touched || reduce) return;
    let i = 1;
    setOpen(products[1].id);
    const t = window.setInterval(() => {
      i = (i + 1) % products.length;
      setOpen(products[i].id);
    }, 2600);
    return () => window.clearInterval(t);
  }, [touched, reduce]);

  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 28 };

  return (
    <section className="bg-stone-100 px-4 py-14 sm:px-6 sm:py-20">
      <div className="mx-auto max-w-5xl">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="text-sm font-medium text-stone-600">Slow Hours Coffee Supply</p>
            <h2 className="mt-1 text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">The brewing shelf</h2>
          </div>
          <a href="#" className="inline-flex h-10 items-center gap-1 rounded-full px-1 text-sm font-medium text-stone-900 underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-stone-900">
            See all 24 pieces <ArrowUpRight className="h-4 w-4" aria-hidden="true" />
          </a>
        </div>

        <ul className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
          {products.map((p, index) => {
            const isOpen = open === p.id;
            return (
              <motion.li
                key={p.id}
                initial={reduce ? false : { y: 20, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                transition={{ delay: index * 0.08, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
                onMouseEnter={() => {
                  setTouched(true);
                  setOpen(p.id);
                }}
                onMouseLeave={() => setOpen(null)}
                className="relative h-[340px] overflow-hidden rounded-2xl bg-white shadow-[0_1px_1px_rgba(0,0,0,0.05),0_0_0_1px_rgba(28,27,26,0.08),0_10px_30px_-18px_rgba(28,27,26,0.35)]"
              >
                {/* Top row: stays put, and gains a line of text when the card is open */}
                <div className="relative z-10 flex items-start justify-between gap-3 p-4">
                  <div>
                    <span className="inline-flex rounded-full bg-stone-900 px-2.5 py-1 text-xs font-medium text-white">{p.tag}</span>
                    <AnimatePresence>
                      {isOpen && (
                        <motion.p
                          initial={reduce ? false : { opacity: 0, y: -6, filter: "blur(4px)" }}
                          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(4px)" }}
                          transition={{ duration: 0.25 }}
                          className="mt-2 text-xs font-medium text-stone-600"
                        >
                          Ships tomorrow from Leeds
                        </motion.p>
                      )}
                    </AnimatePresence>
                  </div>
                  <span className="text-lg font-semibold tabular-nums text-stone-900">{p.price}</span>
                </div>

                {/* Middle: the picture lifts and fades out of the way */}
                <motion.div
                  animate={isOpen ? { y: -18, scale: 0.92, opacity: 0 } : { y: 0, scale: 1, opacity: 1 }}
                  transition={spring}
                  className={`absolute inset-x-4 top-16 grid h-48 place-items-center rounded-xl bg-gradient-to-b ${p.tint}`}
                >
                  <Drawing id={p.id} color={p.accent} />
                </motion.div>

                {/* Bottom panel: a title bar that grows into the details */}
                <motion.div
                  initial={false}
                  animate={{ height: isOpen ? OPEN : CLOSED }}
                  transition={spring}
                  className="absolute inset-x-0 bottom-0 overflow-hidden rounded-t-2xl bg-stone-900 text-white"
                >
                  <h3 className="text-base font-semibold">
                    <button
                      type="button"
                      aria-expanded={isOpen}
                      aria-controls={`details-${p.id}`}
                      onClick={() => {
                        setTouched(true);
                        setOpen(isOpen ? null : p.id);
                      }}
                      className="flex w-full items-center justify-between gap-3 px-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-300"
                      style={{ height: CLOSED }}
                    >
                      {p.name}
                      <motion.span animate={{ rotate: isOpen ? 180 : 0 }} transition={spring} className="grid h-7 w-7 place-items-center rounded-full bg-white/10">
                        <ChevronUp className="h-4 w-4" aria-hidden="true" />
                      </motion.span>
                    </button>
                  </h3>
                  <div id={`details-${p.id}`} aria-hidden={!isOpen} className="px-4 pb-4">
                    <p className="text-sm leading-relaxed text-stone-300">{p.blurb}</p>
                    <ul className="mt-3 flex flex-wrap gap-1.5">
                      {p.specs.map((s) => (
                        <li key={s} className="rounded-full border border-white/15 px-2.5 py-1 text-xs text-stone-200">
                          {s}
                        </li>
                      ))}
                    </ul>
                    <a
                      href="#"
                      tabIndex={isOpen ? 0 : -1}
                      className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-full bg-white text-sm font-semibold text-stone-900 outline-none transition hover:bg-stone-200 focus-visible:ring-2 focus-visible:ring-amber-300"
                    >
                      Add to basket ยท {p.price}
                    </a>
                  </div>
                </motion.div>
              </motion.li>
            );
          })}
        </ul>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/product-card-with-rising-details-panel-on-hover.json

When to use it

How to customize it

Accessibility

Questions

How does the panel grow without moving the grid?

The card has a fixed height and hides overflow. The bottom panel is positioned inside it and only its own `height` animates, from the height of the title bar to the open height, so nothing outside the card changes size.

Does it work on touch screens?

Yes. Hover is only a shortcut. Tapping the title bar toggles the same state, and tapping it again closes the panel.

Can more than one card be open at once?

Not as written: `open` holds one product id. To allow several, store a `Set` of ids and toggle membership in the click handler.

Can the picture stay visible while the panel is open?

Yes. In the `animate` prop of the picture block, keep `opacity` at 1 and use a smaller `scale` with a negative `y` instead, then lower `OPEN` so the panel leaves room for it.

How do I stop the cards cycling on load?

Remove the effect that calls `setInterval`. The cards will then start closed and open only on hover, tap or keyboard.

Adapted from Shift Card in Cult UI, used under its open-source licence. Licences and credits

Related: Flip Cards with Details on the Back, Expanding Card Grid on Hover and Focus, Product Card with Price, Rating, Colour Swatches and Add to Cart.

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