expand in place.
A list row that grows into a full card with a FLIP animation, keeping its corners round and its text sharp the whole way.
Loading component...
live component, try it
what to expect.
Click a row to expand it
Watch the corners stay round mid-flight
Close with the button, the backdrop or Escape
how it works.
A row growing into a card is a FLIP: measure the row, let the card lay out at its real size, then transform it back onto the row and animate that to nothing. One layout, and every frame after it is a transform. The corner is what gives a bad one away. A radius under a non-uniform scale paints as an ellipse, so a 14px corner on a card squashed to a row comes out as a smear, and the only fix is to divide the radius by each axis every frame. The text has the same problem in reverse: scaled, it is resampled, so the content carries the inverse transform and is never scaled at all.
the component.
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useReducedMotion } from "../_shared/media";
import { flipDelta, radiusCorrection, type Box, type Delta } from "./flip";
export interface ExpandCardProps {
open: boolean;
onClose: () => void;
duration: number;
radius: boolean;
counterScale: boolean;
from: Box | null;
children: ReactNode;
}
const RADIUS = 14;
const MAX_STEP = 1000 / 30;
const rect = (el: Element): Box => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
};
const easeOutQuart = (t: number) => 1 - Math.pow(1 - t, 4);
const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export default function ExpandCard({
open,
onClose,
duration,
radius,
counterScale,
from,
children,
}: ExpandCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const scrimRef = useRef<HTMLButtonElement>(null);
const rafRef = useRef(0);
const deltaRef = useRef<Delta | null>(null);
const reduced = useReducedMotion();
const [playing, setPlaying] = useState(false);
const [closing, setClosing] = useState(false);
const write = useCallback(
(progress: number, delta: Delta | null, isClosing = false) => {
const card = cardRef.current;
const inner = innerRef.current;
const scrim = scrimRef.current;
if (!card || !delta) return;
const p = Math.max(0, Math.min(1, progress));
const k = 1 - p;
const sx = 1 + (delta.sx - 1) * k;
const sy = 1 + (delta.sy - 1) * k;
const tx = delta.dx * k;
const ty = delta.dy * k;
if (p >= 1) {
card.style.transform = "none";
card.style.borderRadius = `${RADIUS}px`;
card.style.boxShadow = "";
card.style.opacity = "1";
if (inner) inner.style.transform = "none";
if (scrim) scrim.style.opacity = "1";
} else {
card.style.transform = `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})`;
// Divide the radius by each scale axis, or corners smear into ellipses.
card.style.borderRadius = radius ? radiusCorrection(RADIUS, sx, sy) : `${RADIUS}px`;
if (isClosing) {
const shadowAlpha1 = (0.16 * p).toFixed(3);
const shadowAlpha2 = (0.18 * p).toFixed(3);
card.style.boxShadow = `0 4px 20px -2px rgba(27, 27, 25, ${shadowAlpha1}), 0 12px 36px -8px rgba(27, 27, 25, ${shadowAlpha2})`;
card.style.opacity = p < 0.12 ? `${(p / 0.12).toFixed(3)}` : "1";
} else {
card.style.boxShadow = "";
card.style.opacity = "1";
}
if (inner) {
// Inverse scale keeps the content from being stretched and resampled.
inner.style.transform = counterScale ? `scale(${1 / sx}, ${1 / sy})` : "none";
}
if (scrim) {
scrim.style.opacity = `${p}`;
}
}
},
[radius, counterScale],
);
const startClose = useCallback(() => {
if (closing) return;
if (reduced) {
onClose();
return;
}
cancelAnimationFrame(rafRef.current);
setPlaying(true);
setClosing(true);
const delta = deltaRef.current;
if (!delta) {
onClose();
return;
}
let prev: number | null = null;
let elapsed = 0;
const closeDuration = Math.round(Math.max(200, duration * 0.95));
const frame = (now: number) => {
elapsed += prev === null ? 0 : Math.min(now - prev, MAX_STEP);
prev = now;
const t = Math.min(1, elapsed / closeDuration);
const p = 1 - easeInOutCubic(t);
write(p, delta, true);
if (t < 1) {
rafRef.current = requestAnimationFrame(frame);
} else {
setPlaying(false);
setClosing(false);
onClose();
}
};
rafRef.current = requestAnimationFrame(frame);
}, [closing, reduced, duration, write, onClose]);
useLayoutEffect(() => {
if (!open || !from) return;
if (closing) return;
if (reduced) {
write(1, { dx: 0, dy: 0, sx: 1, sy: 1 });
return;
}
const card = cardRef.current;
if (!card) return;
// FLIP: measure the final layout, then animate the transform back from the row.
card.style.transform = "none";
const last = rect(card);
const delta = flipDelta(from, last);
deltaRef.current = delta;
write(0, delta);
setPlaying(true);
let prev: number | null = null;
let elapsed = 0;
const frame = (now: number) => {
elapsed += prev === null ? 0 : Math.min(now - prev, MAX_STEP);
prev = now;
const t = Math.min(1, elapsed / duration);
const p = easeOutQuart(t);
write(p, delta);
if (t < 1) {
rafRef.current = requestAnimationFrame(frame);
} else {
setPlaying(false);
write(1, delta);
}
};
rafRef.current = requestAnimationFrame(frame);
return () => cancelAnimationFrame(rafRef.current);
}, [open, from, duration, reduced, write]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
startClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, startClose]);
if (!open) return null;
return (
<div className="xc-layer" data-closing={String(closing)}>
<button
type="button"
ref={scrimRef}
className="xc-scrim"
aria-label="close"
onClick={startClose}
/>
<div
className="xc-card"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-label="expanded"
data-playing={String(playing)}
data-radius={String(radius)}
data-counter={String(counterScale)}
data-closing={String(closing)}
>
<button
type="button"
className="xc-close"
aria-label="dismiss card"
onClick={startClose}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div className="xc-inner" ref={innerRef}>
{children}
</div>
</div>
</div>
);
}