All components

Accordion Frames Spotlight Row

JavaScript animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Centered accordion row of 20 vertical image slivers (10 on mobile) where hovering a panel (tapping on mobile) expands it to a 400px frame while the others collapse to 20px, eased by a slow decelerating CSS transition. A white focus frame with full-viewport vertical crosshair lines glides along to track the focused panel, and a ResizeObserver recomputes the pixel layout on resize.

How it's built

Categoryhover
Techvanilla JS
Complexitysection
Performance costmedium
Mobile-safeyes

accordion spotlight hover gallery panels css-transition resize-observer responsive

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 the ResizeObserver; 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; (the overflow: hidden is 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 to 260px at max-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. left and width are 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's overflow: hidden to 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 to 200px and the expanded panel width to 100px.

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;:
  • ::beforebottom: 100%; height: 100svh; (line shooting up from the frame's top edge to the top of the viewport).
  • ::aftertop: 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 mathgetPanelPosition(panelIndex):

  1. panelCount = mobile ? 10 : 20. expandedWidth = mobile ? 100 : 400.
  2. 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).
  3. offsetToCenter = (trackWidth - totalTrackWidth) / 2 — horizontal offset that centers the whole row inside the track. This is the starting left.
  4. Walk from index 0 up to panelIndex, adding each preceding panel's width plus PANEL_GAP: a preceding panel contributes expandedWidth if it is the focused one, else PANEL_WIDTH_COLLAPSED (20). Accumulate into left.
  5. width = panelIndex === focusedPanel ? expandedWidth : PANEL_WIDTH_COLLAPSED.
  6. Return { left, width }.

ApplyapplyPositions(): 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.

FocusfocusPanel(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.

BuildbuildPanels(): 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 → mouseenter calls focusPanel(i)
  • mobile → click calls focusPanel(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.jpgspotlight-20.jpg).

Behavior notes

  • Fully responsive via the single 1000px breakpoint: 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: hidden to clip cleanly at the viewport edges; keep the 100svh stage and svh units for correct mobile viewport height.