MynduiMyndui
0

Anatomy of the Image Accordion

How a hover-expand gallery gets away with animating flex-grow directly — a layout property — instead of chasing the usual transform-only rule, and why that's the right trade here.

01/03One panel, two states

idle

active

Image
saturate + brightness, 550ms ease
Scrim
static gradient, keeps text legible
Label ↔ caption
opacity + translateY swap, 400/500ms
tsx
<Tagdata-active={isActive}aria-expanded={isActive}style={{ flexGrow: isActive ? activeGrow : 1, flexBasis: 0 }}className="[transition:flex-grow_550ms_cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"><img  className="scale-105 brightness-90 saturate-[0.6] [transition:transform_700ms_ease,filter_550ms_ease]              group-data-[active=true]:scale-100 group-data-[active=true]:brightness-100 group-data-[active=true]:saturate-100"/>{/* collapsed vertical label, opacity-only */}{/* expanded caption: opacity + translateY, underline delayed 120ms */}</Tag>
01

One panel, two states

Most "grows on hover" UI fakes width with a scale transform, because animating layout properties is expensive. ImageAccordion doesn't bother — it transitions flex-grow on the panel itself. That's only safe because the whole system is small, bounded, and interaction-driven rather than continuous: five panels, one property, triggered once per hover.

Every panel is a single button (or anchor) that carries all of its own state through data-active. There's no separate "expanded" component — the image, the scrim, the label, and the caption are always in the DOM; only their filters and opacity/transform swap.

The image gets its own timeline (700ms transform, 550ms filter) — slightly slower than the panel's own grow, so the zoom keeps drifting for a beat after the width settles instead of finishing in lockstep with it.

02/03The grow
Idle panels
flexGrow: 1 — share the leftover space
Active panel
flexGrow: 5 · 550ms cubic-bezier(0.22,1,0.36,1)
02

The grow

flexGrow flips between 1 and activeGrow (default 5) on onPointerEnter / onClick / onFocus, and the whole row redistributes along one 550ms cubic-bezier(0.22,1,0.36,1) transition — the same snappy-then-settling curve used across Myndui for anything that "arrives." onPointerLeave resets to defaultIndex, so moving off the whole strip always returns to a known panel rather than leaving whichever was last active stuck open.

The diagram loops the same mutual handoff: one slot takes the 5fr, the other three stay at 1fr, so the outgoing panel visibly compresses as the incoming one expands. (It drives that with grid-template-columns so a continuous loop doesn't thrash flex-grow forever.) The real component only pays the layout cost for 550ms per hover — then the row is stable again. That's the trade that makes it fine to break the transform-only rule here: bounded, discrete, and it buys a change that transform genuinely can't express cleanly.

Why not just scale the active panel? A scale() on the active panel would zoom its content without changing how much horizontal space it actually occupies — the neighbors wouldn't compress, and the image would need a matching inverse-scale correction to avoid distorting alongside the container. Real flex-grow gets both effects — growth and compression — from a single property, at the cost of one bounded reflow instead of zero.

03/03Result
03

The result

One state (active), one CSS transition, and every visual — width, image color, caption — reads it independently through data-[active=true]. Hover or tab across the panels above to feel the full 550ms settle.

Accessibility

Each panel is a real <button> (or <a> when href is set), so the whole strip is keyboard-reachable in document order — no roving tabindex, no custom key handling. onFocus opens a panel exactly the way onPointerEnter does, so tabbing through the strip previews every caption in turn. aria-expanded mirrors data-active for assistive tech, and motion-reduce:transition-none on both the panel and the image drops every transition to an instant switch under prefers-reduced-motion.

Motion Score

Image AccordionDDLayout-triggering
Dflex-growpanels share one flex track; siblings must reflow — no compositor equivalent
StranslatePosition / lift via translate
SscaleScale spring / press
SrotateRotation
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 →