Anatomy of the Image Accordion
How a hover-expand gallery animates flex-grow, a layout property, instead of following the usual transform-only rule, and why that trade works here.
↓Scroll to step through it
One panel, two states
Most "grows on hover" UI fakes width with a scale transform, because
animating layout properties is expensive. ImageAccordion 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.
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. It is the same
snappy-then-settling curve used across GodUI 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. Breaking the transform-only rule is fine here because the cost is
bounded and discrete, and it buys a change that transform can't express
cleanly.
Why not scale the active panel? A scale() on the active panel would
zoom its content without changing how much horizontal space it
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.
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 see the full 550ms settle.
idle
active
- Image
- saturate + brightness, 550ms ease
- Scrim
- static gradient, keeps text legible
- Label ↔ caption
- opacity + translateY swap, 400/500ms
<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>Anatomy of the Image Accordion
How a hover-expand gallery animates flex-grow, a layout property, instead of following the usual transform-only rule, and why that trade works here.
idle
active
- Image
- saturate + brightness, 550ms ease
- Scrim
- static gradient, keeps text legible
- Label ↔ caption
- opacity + translateY swap, 400/500ms
<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>One panel, two states
Most "grows on hover" UI fakes width with a scale transform, because
animating layout properties is expensive. ImageAccordion 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.
- Idle panels
- flexGrow: 1, shares the leftover space
- Active panel
- flexGrow: 5 · 550ms cubic-bezier(0.22,1,0.36,1)
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. It is the same
snappy-then-settling curve used across GodUI 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. Breaking the transform-only rule is fine here because the cost is
bounded and discrete, and it buys a change that transform can't express
cleanly.
Why not scale the active panel? A scale() on the active panel would
zoom its content without changing how much horizontal space it
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.
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 see 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 with no roving tabindex or
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
flex-growpanels share one flex track and siblings must reflow; there is no compositor equivalenttranslatePosition / lift via translatescaleScale spring / pressrotateRotationopacityFade / cross-fade