Design System

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 ad overlap at 1280, 768 and 390 — but at 1024 it is ab, ac, ad and cd, 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

Props: photos (four; slot order a→d is part of the design), spread?, scale?, lift?, className?. Styles: .hw-photo-cluster* in packages/ui/styles/marketing.css. Hover is gated on @media (hover: hover) so a touch tap cannot leave the cluster stuck open; reduced motion keeps the spread and the restack but drops the float, the glide, and the fade.

Variants

Default — scale 1.15, spread 18px, lift on (hover it)
A family laughs together while playing with a ball in a neighborhood park
A physician in a white coat and blue scrubs speaks with a patient by video
A woman opens an insulated medication delivery kit at her dining table
A man reviews his weight trend in the Halwel portal on his phone
lift off — frames keep their DOM stacking order
A family laughs together while playing with a ball in a neighborhood park
A physician in a white coat and blue scrubs speaks with a patient by video
A woman opens an insulated medication delivery kit at her dining table
A man reviews his weight trend in the Halwel portal on his phone
spread 0 — effect off
A family laughs together while playing with a ball in a neighborhood park
A physician in a white coat and blue scrubs speaks with a patient by video
A woman opens an insulated medication delivery kit at her dining table
A man reviews his weight trend in the Halwel portal on his phone
spread 40px — exaggerated
A family laughs together while playing with a ball in a neighborhood park
A physician in a white coat and blue scrubs speaks with a patient by video
A woman opens an insulated medication delivery kit at her dining table
A man reviews his weight trend in the Halwel portal on his phone
scale 1 — the pre-scale arrangement
A family laughs together while playing with a ball in a neighborhood park
A physician in a white coat and blue scrubs speaks with a patient by video
A woman opens an insulated medication delivery kit at her dining table
A man reviews his weight trend in the Halwel portal on his phone
scale 1.3
A family laughs together while playing with a ball in a neighborhood park
A physician in a white coat and blue scrubs speaks with a patient by video
A woman opens an insulated medication delivery kit at her dining table
A man reviews his weight trend in the Halwel portal on his phone
Placeholder labels (before photography lands)