All patterns

Expandable Detail Card

A compact card that expands in place into a detail view — image and title travel continuously.

navigationpremiumelegantinteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

238 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Expandable Detail Card
 *
 * A compact card that expands in place into a detail view. The
 * thumbnail and title are shared elements: Motion's layout animation
 * keeps them continuous while the card reflows around them.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the card reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`. Click or press Enter/Space
 * to toggle. Sample content is embedded so the file runs as-is.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ExpandableDetailCardProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Image for the thumbnail/hero. Falls back to a gradient placeholder. */
  imageSrc?: string;
  /** Notified after each toggle. */
  onToggle?: (expanded: boolean) => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  contentDelay: number;
};

// Quality rule: layout springs stay at/near critical damping — the
// surface may land with at most one soft settle, and text never bounces.
// Variants differ in speed and energy, not in wobble.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Settles straight in, with the detail arriving almost on top of the
  // card — closest to Airbnb's restraint.
  subtle: {
    spring: { type: "spring", stiffness: 700, damping: 53 },
    contentDelay: 0.04,
  },
  // Barely-there softness on landing. The all-purpose setting.
  default: {
    spring: { type: "spring", stiffness: 360, damping: 36 },
    contentDelay: 0.1,
  },
  // The card takes its time growing and the detail waits a beat longer
  // behind it, so the two read as one unfolding rather than one event.
  // Still lands without wobble.
  playful: {
    spring: { type: "spring", stiffness: 180, damping: 25 },
    contentDelay: 0.2,
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const SAMPLE = {
  name: "Kyoto, Japan",
  tagline: "Temples, gardens & tea houses",
  body: "Slip into the old capital: dawn at Kiyomizu-dera before the crowds arrive, matcha in a backstreet tea house, and lantern-lit alleys through Gion after dark.",
  stats: [
    ["Best time", "April"],
    ["Flight", "2h 40m"],
    ["Rating", "4.8"],
  ] as const,
};

export default function ExpandableDetailCard({
  variant = "default",
  imageSrc,
  onToggle,
}: ExpandableDetailCardProps) {
  const [expanded, setExpanded] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: the layout switches instantly, content still fades briefly.
  const layoutTransition = reduceMotion ? { duration: 0 } : cfg.spring;
  const contentTransition = reduceMotion
    ? { duration: 0.15 }
    : { duration: 0.25, ease: "easeOut" as const, delay: cfg.contentDelay };

  const toggle = () => {
    const next = !expanded;
    setExpanded(next);
    onToggle?.(next);
  };

  return (
    <motion.div
      layout
      role="button"
      tabIndex={0}
      aria-expanded={expanded}
      aria-label={`${SAMPLE.name} card, press to ${expanded ? "collapse" : "expand"}`}
      onClick={toggle}
      onKeyDown={(event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          toggle();
        }
      }}
      transition={layoutTransition}
      style={{
        width: 320,
        display: "flex",
        flexDirection: expanded ? "column" : "row",
        alignItems: expanded ? "stretch" : "center",
        gap: expanded ? 0 : 14,
        padding: expanded ? 0 : 12,
        borderRadius: 20,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: expanded
          ? "0 18px 44px rgba(0,0,0,0.22)"
          : "0 6px 18px rgba(0,0,0,0.14)",
        cursor: "pointer",
        overflow: "hidden",
        userSelect: "none",
      }}
    >
      <motion.div
        layoutId="vibary-edc-tile"
        aria-hidden
        transition={layoutTransition}
        style={{
          flexShrink: 0,
          width: expanded ? "100%" : 56,
          height: expanded ? 132 : 56,
          borderRadius: expanded ? 0 : 14,
          // A stand-in for the photograph, not a UI surface — it stays a
          // fixed gradient in both themes, exactly as a real image would.
          background: "linear-gradient(135deg, #2E3D59 0%, #16203A 100%)",
          overflow: "hidden",
        }}
      >
        {imageSrc ? (
          <img
            src={imageSrc}
            alt=""
            draggable={false}
            style={{
              width: "100%",
              height: "100%",
              objectFit: "cover",
              display: "block",
            }}
          />
        ) : null}
      </motion.div>

      <motion.div
        layout
        transition={layoutTransition}
        style={{
          display: "flex",
          flexDirection: "column",
          gap: 2,
          padding: expanded ? "16px 18px 0" : 0,
          minWidth: 0,
        }}
      >
        {/* Text moves, it never scales: layout="position" + a constant
            font size keep glyphs rock-solid while the card reflows. */}
        <motion.div
          layoutId="vibary-edc-name"
          layout="position"
          transition={layoutTransition}
          style={{
            fontSize: 16,
            fontWeight: 650,
            lineHeight: 1.3,
            whiteSpace: "nowrap",
          }}
        >
          {SAMPLE.name}
        </motion.div>
        <motion.div
          layout="position"
          transition={layoutTransition}
          style={{ fontSize: 13, opacity: 0.55 }}
        >
          {SAMPLE.tagline}
        </motion.div>
      </motion.div>

      <AnimatePresence>
        {expanded && (
          <motion.div
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, transition: { duration: 0.12 } }}
            transition={contentTransition}
            style={{ padding: "12px 18px 18px" }}
          >
            <p
              style={{
                margin: 0,
                fontSize: 13.5,
                lineHeight: 1.6,
                opacity: 0.75,
              }}
            >
              {SAMPLE.body}
            </p>
            <div style={{ display: "flex", gap: 8, marginTop: 14 }}>
              {SAMPLE.stats.map(([statLabel, statValue]) => (
                <div
                  key={statLabel}
                  style={{
                    flex: 1,
                    padding: "8px 10px",
                    borderRadius: 12,
                    background: tone(8),
                    border: `1px solid ${tone(12)}`,
                  }}
                >
                  <div style={{ fontSize: 11, opacity: 0.5 }}>{statLabel}</div>
                  <div style={{ fontSize: 13, fontWeight: 600, marginTop: 2 }}>
                    {statValue}
                  </div>
                </div>
              ))}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

About this pattern

List-to-detail without a page change. Tapping the compact card grows it in place: the thumbnail and title keep their identity while the layout reflows around them, and the detail content fades in once the surfaces have settled. Continuity is the point — the user never loses track of what they tapped, which makes this the pattern of choice for galleries, product lists and profile cards.

List → detailProduct cardArticle previewProfile card

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Photo gallery

    The canonical card-to-page expansion: artwork and title stay continuous.

Related patterns