Photo cluster
The four-frame floating collage from the public hero: one tall 3:4 frame with three overlapping squares, each gently rotated and drifting on its own float cycle. It composes PhotoFrame — the cluster owns only the arrangement (size, position, rotation, float, hover spread), never the frame treatment.
Hover spreads the frames apart. Each one drifts along its own vector so the overlap releases and all four read at once. Every slot's travel is a multiple of a single variable, --cluster-spread (prop: spread) — one number tunes the whole feel, and 0px disables it without touching the markup. The slot element carries the hover transform and its child carries the float animation, so the two never contend for transform.
The spread is meant to overshoot. Once scaled, the cluster fills the hero row, so an outward spread runs past the cluster's box and off the page edge — that overshoot is the effect, not a bug. Only the rest state is kept inside; a frame clipped while sitting still just looks broken. The surface clips the overshoot so it slides off the edge instead of growing a horizontal scrollbar (the hero sets overflow-x: clip).
Lift brings the hovered frame to the top. Prop lift (default on). The frame fades down where it sits, swaps to the top of the pile at the darkest point of the dip, then fades back up on top — so the restack happens while it is least visible. z-index cannot transition, but it can be keyframed (it animates as an integer), which is what lets the swap land on that beat instead of the moment the pointer arrives. The step is held flat on both sides of the dip, because an integer left to interpolate would ramp across the whole fade and restack early, in full view. Turn it off with lift={false} and the frames keep their DOM stacking order.
A frame already in front of everything it touches does not dip. There is nothing to reveal, so fading it would be motion that buys the viewer nothing. Which frames qualify is measured, not listed: the overlap set is a function of the arrangement's aspect ratio, and that moves with the column width. On the real hero only a–d overlap at 1280, 768 and 390 — but at 1024 it is a–b, a–c, a–d and c–d, which makes c occluded too. Any hard-coded list would be wrong at some width, so the component measures the untransformed layout boxes on mount and on resize and marks the covered frames.
Scale the arrangement, never the frames. --cluster-scale (prop: scale, default 1.15) is a transform on the stage, so the frames and every gap between them grow by the same factor and the composition is identical, just bigger. Growing the frames — or the cluster's height — instead skews it: the cluster's width is pinned by the row it sits in, so only the vertical gaps would open. The horizontal overflow spills into the row's gutters; the vertical half is reserved as margin so neighbours are pushed, not overlapped.
Layout ownership stops at the cluster's edge: how it is placed in a page is the surface's job, passed through className (the home hero passes .hw-hero-collage, which is only its flex sizing).
import from @halwel/ui/components/marketing/PhotoCluster
Variants

















