Accordion Frames — Spotlight Row
Goal
Build a full-viewport, dark hero section containing a single horizontal accordion row of tall, thin image slivers. All panels sit collapsed to a 20px-wide sliver by default; hovering one panel (tapping on mobile) makes it smoothly expand to a wide 400px "frame" while every other panel simultaneously collapses back to 20px, all sliding to make room via a slow, decelerating ease. A crisp white focus frame with two thin vertical crosshair lines running the full height of the viewport glides along the row to sit exactly over the currently focused panel. The star effect is this synchronized expand/collapse "accordion" motion plus the tracking frame.
Tech
Vanilla HTML/CSS/JS. No GSAP, no npm animation libraries, no smooth-scroll — the entire motion is a CSS transition on left/width driven by a small vanilla-JS layout engine, plus a ResizeObserver. Ship it as one index.html, one styles.css, one ES-module script.js (<script type="module">). It must run in a fresh Vite project with zero dependencies.
Layout / HTML
Minimal semantic skeleton — the JS injects the panels at runtime:
<main>
<section class="spotlight">
<div class="spotlight-track">
<div class="spotlight-panels">
<div class="spotlight-focus-indicator"></div>
</div>
</div>
</section>
</main>
.spotlight— the full-viewport dark stage..spotlight-track— a centered, width-constrained band (this is the element observed by theResizeObserver; its measured content width drives all the math)..spotlight-panels— the relative-positioned container the JS fills with absolutely-positioned panels..spotlight-focus-indicator— a single pre-existing white frame element that the JS positions over the focused panel.
Each panel the JS creates:
<div class="spotlight-panel">
<img src="<panel image>" />
</div>
Styling
Palette / dark stage
.spotlight:position: relative; width: 100%; height: 100svh; background-color: #0f0f0f; overflow: hidden;(theoverflow: hiddenis essential — panels and crosshairs must be clipped to the stage).
Track
.spotlight-track:position: absolute; top: 50%; left: 50%; width: 90%; max-width: 1400px; transform: translate(-50%, -50%);— a centered band.
Panels container
.spotlight-panels:position: relative; width: 100%; height: 400px;(drops to260pxatmax-width: 1000px).
Panel
.spotlight-panel:position: absolute; top: 0; height: 100%; overflow: hidden; cursor: pointer; will-change: left, width;- The motion lives here:
transition: all 1s cubic-bezier(0.075, 0.82, 0.165, 1);— a slow (1 second) ease-out ("easeOutCirc"-style) curve.leftandwidthare set inline by the JS in pixels; the CSS transition interpolates every change.
Panel image
.spotlight-panel img:position: absolute; left: 50%; transform: translateX(-50%); width: 400px; height: 100%; object-fit: cover; pointer-events: none; user-select: none;- Trick: the
<img>is a fixed 400px wide, horizontally centered inside its panel. When the panel is a 20px sliver, the image is cropped by the panel'soverflow: hiddento a 20px-wide vertical slice taken from the image's horizontal center. When the panel expands to 400px the full image is revealed — so the sliver is a live crop of the eventual full frame, and there is no image resize during the transition, only the panel window widening. - On mobile (
max-width: 1000px) the image width drops to200pxand the expanded panel width to100px.
Focus indicator (the white tracking frame + crosshairs)
.spotlight-focus-indicator:position: absolute; top: 0; height: 100%; border: 3px solid #fff; pointer-events: none; z-index: 100; will-change: left, width; transition: all 1s cubic-bezier(0.075, 0.82, 0.165, 1);— the exact same transition as the panels, so the frame glides in perfect sync with the accordion.- Two full-viewport-height vertical crosshair lines via pseudo-elements, both
content: ""; position: absolute; left: 50%; transform: translateX(-50%); width: 3px; background: #fff;: ::before→bottom: 100%; height: 100svh;(line shooting up from the frame's top edge to the top of the viewport).::after→top: 100%; height: 100svh;(line shooting down from the frame's bottom edge to the bottom of the viewport).- Net look: a bright white rectangle hugging the focused frame, with thin white vertical guide lines extending above and below it across the whole screen.
The effect (be exhaustive — this replaces the GSAP section)
There is no GSAP timeline. The animation is achieved entirely by (a) CSS transition: all 1s cubic-bezier(0.075, 0.82, 0.165, 1) on the panels and the focus indicator, and (b) a JS function that, on every focus change or resize, recomputes each panel's exact pixel left and width and writes them inline. The browser then tweens from old to new values over 1s with that ease. Reproduce the math precisely:
Constants
PANEL_WIDTH_COLLAPSED = 20 // px sliver width
PANEL_WIDTH_EXPANDED = 400 // px focused frame width (desktop)
PANEL_WIDTH_EXPANDED_MOBILE = 100 // px focused frame width (mobile)
PANEL_GAP = 5 // px gap between panels
PANEL_COUNT_DESKTOP = 20 // panels on desktop
PANEL_COUNT_MOBILE = 10 // panels on mobile
BREAKPOINT_MOBILE = 1000 // px; window.innerWidth < this => mobile
State: trackWidth (measured), isMobile (boolean), focusedPanel (index, default 0), panels (array of elements).
Per-panel position math — getPanelPosition(panelIndex):
panelCount= mobile ? 10 : 20.expandedWidth= mobile ? 100 : 400.totalTrackWidth = (panelCount - 1) * (PANEL_WIDTH_COLLAPSED + PANEL_GAP) + expandedWidth— total width of the row (every panel is a 20px collapsed sliver except one 400px expanded one, with 5px gaps).offsetToCenter = (trackWidth - totalTrackWidth) / 2— horizontal offset that centers the whole row inside the track. This is the startingleft.- Walk from index 0 up to
panelIndex, adding each preceding panel's width plusPANEL_GAP: a preceding panel contributesexpandedWidthif it is the focused one, elsePANEL_WIDTH_COLLAPSED(20). Accumulate intoleft. width=panelIndex === focusedPanel ? expandedWidth : PANEL_WIDTH_COLLAPSED.- Return
{ left, width }.
Apply — applyPositions(): loop all panels, compute {left,width}, set panel.style.left = left+"px" and panel.style.width = width+"px". Then compute the focused panel's {left,width} and set the focus indicator's left/width to the same, so the white frame lands exactly on the focused panel. Because both the panels and the indicator carry the 1s cubic-bezier transition, changing these inline values makes everything glide together.
Focus — focusPanel(index): set focusedPanel = index, then applyPositions(). This one call causes the newly-focused panel to widen 20→400, its neighbors to shift, the previously-focused panel to shrink 400→20, and the frame to slide — all in a single synchronized 1s ease.
Build — buildPanels(): remove any existing panels, reset focusedPanel = 0, then for i in 0..panelCount: create .spotlight-panel, append an <img> whose src is the i-th panel image, and attach the interaction:
- desktop →
mouseentercallsfocusPanel(i) - mobile →
clickcallsfocusPanel(i)
Append each panel to .spotlight-panels, push to panels, then applyPositions(). (Panel index 0 is focused/expanded on first paint.)
Trigger & interaction model
- Desktop: hover — moving the mouse onto any panel expands it; sweeping the mouse across the row makes the frames "accordion" open one after another following the cursor, the white frame chasing along.
- Mobile: tap/click to focus a panel.
- No scroll, no click-to-toggle, no auto-play. Idle state = panel 0 expanded.
ResizeObserver — observe .spotlight-track. On every callback: read entry.contentRect.width into trackWidth; compute nextIsMobile = window.innerWidth < 1000. If the mobile state flipped, set isMobile and rebuild the panels (count changes 20↔10). Otherwise just applyPositions() to re-center for the new width. Kick everything off by calling buildPanels() once, then observer.observe(track). Run all of this inside a DOMContentLoaded listener.
Easing note: the single most important motion detail is the ease — cubic-bezier(0.075, 0.82, 0.165, 1) over 1s. It starts fast and decelerates hard into a long slow settle, giving the panels a weighty, glidey "drawer" feel. Do not substitute a linear or default ease.
Assets / images
20 square (1:1) grainy, moody editorial fashion-film photographs, all filling the same role: interchangeable panel fills, one per accordion sliver. There is no single subject type — the set is a curated mix that shares a soft, filmic, high-art mood rather than a literal theme. Two visual families run through it:
- Monochrome portraits — tight black-and-white studio-style headshots against flat light-grey grounds: a woman's sharp profile with pearl-drop earrings in a black blazer; a bald young face carved by hard directional shadow; a soft, near-overexposed face seen through a veil of translucent white gauze. High grain, deep blacks, luminous pale skin.
- Ethereal desaturated-color scenes — pale, almost-albino figures in white dresses inside dreamlike environments: a white-gowned figure flanked by two black swans on a misty green pond with water lilies; a white-clad figure seated in a decayed flower-filled interior with a gilt mirror; a red-blindfolded bald figure under ivy-wrapped gothic stone arches; a platinum-haired woman crowned with clear-crystal butterflies in a sunlit rose garden. Only one image (the opening panel) is a genuine motion-blurred figure — a runner in a loose grey jacket and cream trousers smeared across a muted grey-blue backdrop.
Dominant colors across the set: creamy whites and pale skin, deep blacks and charcoal greys, soft muted greens and misty pastels, with the occasional saturated accent (a red blindfold). Overall low-saturation, high-grain, cinematic and quiet — not clean commercial studio work.
Each image is displayed at a fixed 400px render width (200px on mobile) and cropped to a 20px vertical sliver when collapsed, so the horizontal center of each image is what shows in the sliver — the central subject (a face, a standing figure) generally sits mid-frame and is what reads in the collapsed sliver. The native files are square, and since the desktop panel is 400px tall the effective render is ~400×400; object-fit: cover fills the panel so any square or tall crop works. Only the first 10 are needed on mobile. No logos, no brand marks — neutral, editorial-artistic film photography. File paths follow a simple indexed pattern (e.g. spotlight-1.jpg … spotlight-20.jpg).
Behavior notes
- Fully responsive via the single
1000pxbreakpoint: 20 panels / 400px expanded / 400px image on desktop, 10 panels / 100px expanded / 200px image on mobile; interaction switches hover↔tap accordingly. - The layout is recomputed in pixels on every resize, keeping the row centered inside the
max-width: 1400px, 90%-wide track. - No infinite loops or timers; motion only occurs in response to hover/tap/resize.
- The full-height crosshair lines rely on the stage's
overflow: hiddento clip cleanly at the viewport edges; keep the100svhstage andsvhunits for correct mobile viewport height.