MynduiMyndui
0

Anatomy of Progressive Card Reveal

How one activeIndex, a distance-based funnel, and a layout-driven width morph combine so exactly one card in a stack ever expands at a time.

01/03One activeIndex, five cards react
Root
owns activeIndex, the only state
Card
compares its index, measures its own layout
Collapsed
pill, width from distance to active
Expanded
full width, distance 0
tsx
const expanded = index === reveal.activeIndex;const distance = Math.abs(index - reveal.activeIndex);const depth = reveal.maxDepth != null ? Math.min(distance, reveal.maxDepth) : distance;
01

One activeIndex, five cards react

Progressive Card Reveal never toggles a card's own "am I open" flag — every card compares its position to one shared activeIndex and funnels its width accordingly. Here's how five cards asking the same question produces a symmetric funnel, and the one place this component chooses a real layout animation over a transform trick.

The root is fully controlled: it holds activeIndex and hands it down through context. Each Card reads its own position from a second context (assigned automatically by React.Children.map, so you never pass an index by hand), then derives expanded and distance itself.

tsx
 
function collapsedWidth(distance: number) {
  const base = COLLAPSED_BASE_WIDTH - (distance - 1) * COLLAPSED_WIDTH_STEP; // 90 - (d-1)*7
  return `${Math.max(base, COLLAPSED_MIN_WIDTH)}%`; // floors at 60%
}

No card knows about its siblings. The funnel — wider near the active card, narrower toward the edges — is an emergent property of five independent distance calculations, not a hand-authored gradient.

02/03The funnel morph

width: expanded ? "100%" : collapsedWidth(depth)

Active card
100% width, 20px radius
Collapsed cards
narrower per step away, pill radius
tsx
<motion.divlayout={!reducedMotion}transition={reducedMotion ? { duration: 0 } : LAYOUT_TRANSITION}style={{  width: expanded ? EXPANDED_WIDTH : collapsedWidth(depth),  borderRadius: expanded ? EXPANDED_RADIUS : COLLAPSED_RADIUS,}}/>
02

The funnel morph

width and borderRadius are the only two properties that change when a card expands or collapses, and both ride on motion.div's layout prop — a real FLIP measure-and-tween, the one deliberate exception to transform-only motion in this component. It's justified here because the funnel is a genuine width handoff: the active card actually needs more of the row's real width, and its neighbours actually need to give some up — a scale transform can't redistribute space between siblings, only a layout animation can.

LAYOUT_TRANSITION is { stiffness: 320, damping: 32, mass: 0.9 } — kept close to critically damped (critical ≈ 2·√stiffness ≈ 35.8) on purpose. Underdamping here would make the moving edges wobble as the width settles, which reads as a flick rather than a resize.

borderRadius is set via style, not animate. Framer only applies its inverse-scale correction to radius when it's a style value — animating it through animate instead would let the layout scale-correction warp the corners as the box resizes.

The collapsed and expanded content are two different subtrees, swapped by AnimatePresence mode="popLayout". popLayout pulls the exiting view out of document flow, so the parent's width/height morph and the content's crossfade run together instead of the crossfade waiting for the resize to finish. Each subtree also carries layout="position" — it keeps its own box unscaled while the parent resizes around it, so text never stretches mid-morph.

tsx
 
<AnimatePresence mode="popLayout" initial={false}>
  {expanded ? (
    <motion.div key="expanded" layout="position" ... />
  ) : (
    <motion.button key="collapsed" layout="position" exit={{ opacity: 0, transition: { duration: 0 } }} ... />
  )}
</AnimatePresence>

The collapsed pill's exit has duration: 0 — on click it vanishes instantly instead of fading out, so the expanded view never crossfades over a lingering ghost of the pill it replaced.

Hovering a collapsed card scales it to 1.03 via whileHover — a transform, not a width change. Reusing width for the hover lift would re-lay-out the card's right-aligned content on every spring frame, which reads as a flicker. scale and width are independent animated values on the same element, so a later activeIndex change still updates width correctly even mid-hover.

03/03Result
Flight

Distance

2,400 miles

Time

~3 hours

03

The result

One shared index, a distance formula five cards each run for themselves, and one deliberate layout animation for the width handoff a transform can't fake — that's the whole funnel.

Accessibility and reduced motion

Collapsed cards are real <button> elements with aria-expanded={false}, so the whole stack is keyboard- and screen-reader-navigable without any custom logic. Under prefers-reduced-motion, layout is disabled entirely and every transition collapses to { duration: 0 } — cards jump straight to their target width instead of morphing.

Motion Score

Progressive Card RevealSSCompositor-only
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →