Expandable Detail Card
A compact card that expands in place into a detail view — image and title travel continuously.
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.
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.
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
- Gallery Lightbox OpenThe pressed thumbnail flies out of the grid into the large view while the backdrop dims behind it.
- Fullscreen ToggleA panel expands to fill the frame while surrounding chrome fades.
- Modal Scale InA dialog grows the last few percent into place behind a darkening scrim, and leaves the same way.
