GodUIGodUI
129Follow on X

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.

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 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, shares 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. 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.

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 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

Image AccordionDD: Layout-triggering
Dflex-growpanels share one flex track and siblings must reflow; there is 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 →