Magnetic Spotlight Marquee
Goal
Build a full-viewport dark hero with a single horizontal strip of website-screenshot thumbnails that auto-scrolls infinitely to the left, and, as the star effect, magnetically follows the cursor's vertical position: on mousemove the whole strip lerps up/down to sit under the pointer (clamped to a band inset from the top and bottom edges). Centered over the moving images is a block of title + tagline + copy set in mix-blend-mode: difference, so wherever the strip passes behind the text the colors invert. As the strip descends toward the text, the text lines rise out of its way, and every line also gets a subtle velocity "wake" — a gaussian-weighted nudge in the direction the strip is moving, strongest for the lines nearest the strip. Everything is spring-smooth: nothing snaps, it all eases.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText. No ScrollTrigger, no Lenis, no smooth-scroll library, no Three.js. Import as:
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
All motion is driven by (a) one infinite gsap.to tween for the marquee and (b) a single gsap.ticker.add(...) per-frame loop that does the lerping. It runs in a fresh Vite project with just gsap installed.
Layout / HTML
One <section class="spotlight"> containing four absolutely/relatively positioned blocks: a top nav, the marquee strip, the centered content, and a bottom footer. The marquee has a track wrapping exactly 6 items, each holding one landscape <img>.
<section class="spotlight">
<div class="spotlight-nav">
<p>hello@yourstudio.com</p>
<p>Instagram, YouTube, X</p>
</div>
<div class="spotlight-marquee">
<div class="spotlight-marquee-track">
<div class="spotlight-marquee-item"><img src="/img-1.jpg" alt="" /></div>
<div class="spotlight-marquee-item"><img src="/img-2.jpg" alt="" /></div>
<div class="spotlight-marquee-item"><img src="/img-3.jpg" alt="" /></div>
<div class="spotlight-marquee-item"><img src="/img-4.jpg" alt="" /></div>
<div class="spotlight-marquee-item"><img src="/img-5.jpg" alt="" /></div>
<div class="spotlight-marquee-item"><img src="/img-6.jpg" alt="" /></div>
</div>
</div>
<div class="spotlight-content-wrapper">
<h1>MP Studio</h1>
<h3>Making stuff others try to copy</h3>
<div class="spotlight-copy">
<p>Two short paragraphs of neutral studio blurb about web design, animation and front-end development…</p>
<p>…and a second paragraph about GSAP animations, interactive interfaces and clean code.</p>
</div>
</div>
<div class="spotlight-footer">
<p>One centered narrow line of small footer copy about the studio's craft.</p>
</div>
<script type="module" src="./script.js"></script>
</section>
Load-bearing structural facts:
.spotlight-marqueeis the strip (the element that translates vertically to chase the cursor)..spotlight-marquee-trackis the track (the element that translates horizontally for the marquee). They are separate elements — vertical and horizontal transforms live on different nodes.- The 6
.spotlight-marquee-items are the "source set"; JS clones them to fill the width (see effect). .spotlight-content-wrapperholdsh1,h3and.spotlight-copy(two<p>side by side). These headings/paragraphs are what SplitText splits into lines.- Use neutral demo copy. Title "MP Studio", a short tagline, two blurb paragraphs, one footer sentence. No real brand names.
Styling
Fonts via Google Fonts: Instrument Serif (for h1 and the nav) and Instrument Sans (for h3, p). Import both at the top of the CSS.
Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
Type:
h1:font-family: "Instrument Serif"; font-size: 6rem; font-weight: 500; line-height: 0.9;(drops to4remundermax-width: 1000px).h3:text-transform: uppercase; font-family: "Instrument Sans"; font-size: 1.5rem; font-weight: 500;p:font-family: "Instrument Sans"; font-weight: 400; font-size: 0.8rem; line-height: 1.25;.spotlight-nav p:font-family: "Instrument Serif"; font-weight: 500; font-size: 0.9rem;.spotlight-footer p:text-align: center; font-size: 0.6rem; width: 30%;(goeswidth: 100%under 1000px).
Color tokens (exact hex):
- Section background:
#0f0f0f(near-black). - Nav + footer text:
#7a7a7a(mid grey). - Content text:
#fff. - Marquee image fallback bg:
#ddd.
Layout / positioning:
.spotlight:position: relative; width: 100%; height: 100svh; overflow: hidden; background: #0f0f0f;.spotlight-nav,.spotlight-footer:position: absolute; width: 100%; padding: 2rem; display: flex; flex-direction: column; justify-content: center; align-items: center; color: #7a7a7a;— nav pinnedtop: 0, footer pinnedbottom: 0..spotlight-marquee(strip):position: absolute; top: 0; left: 0; width: 100%; overflow: hidden; display: flex; will-change: transform; pointer-events: none; z-index: 0;— sits at the very top of the section and is moved down via a JS transform.pointer-events: noneso it never eats the section'smousemove..spotlight-marquee-track:display: flex; flex-shrink: 0; will-change: transform;.spotlight-marquee-item:flex-shrink: 0; width: 18rem; padding: 0 0.5rem;.spotlight-marquee-item img:width: 100%; height: 10rem; object-fit: cover; display: block; border-radius: 6px; background: #ddd;.spotlight-content-wrapper:position: relative; width: 30%; height: 100%; padding-top: 8rem; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 2rem; margin: 0 auto; color: #fff; mix-blend-mode: difference; z-index: 2;(goeswidth: 100%; padding: 2rem;under 1000px). Themix-blend-mode: differenceis essential — it makes the centered text invert against the images sliding behind it..spotlight-content-wrapper h1:text-align: center; width: 15rem;.spotlight-content-wrapper h3:width: 20rem; text-align: center;.spotlight-copy:display: flex; gap: 1rem;and.spotlight-copy p:nth-child(1) { text-align: right; }(left paragraph is right-aligned, so the two columns hug the gutter)..line(SplitText's per-line class):position: relative; white-space: nowrap; will-change: transform;— each split line is its own transformable box.
Stacking: strip z-index: 0, content z-index: 2. The content sits above the images and blends with them.
GSAP effect (exhaustive — this is the whole component)
Config constants (name them exactly; every number below matters)
const config = {
marqueeScrollSpeed: 100, // px/sec horizontal marquee speed
stripFollowEase: 0.05, // lerp factor for strip chasing the cursor Y
stripEdgeInset: 175, // px: strip rest-center from top; also the clamp inset top & bottom
contentRiseRate: 0.85, // how much the text rises per px the strip descends past rest
risenTopGap: 100, // px: don't let text rise above this gap from section top
liftHeadStart: 125, // px: strip "reaches" a line this many px before its center
wakeStrength: 2.5, // multiplier on strip velocity for the wake nudge
wakeReach: 125, // px: gaussian sigma for how far the wake reaches
lineSettleEase: 0.09, // lerp factor for each line settling to its target Y
};
1) Infinite horizontal marquee (clone-to-fill + one repeating tween)
- Read the 6 source items:
const sourceItems = Array.from(track.children); - Measure one full set width:
const oneSetWidth = sourceItems.reduce((sum, i) => sum + i.offsetWidth, 0);(6 × 18rem ≈ 1728px). - Decide how many extra copies are needed to cover the viewport plus one:
const setsNeeded = Math.ceil(window.innerWidth / oneSetWidth) + 1; - Clone the whole source set
setsNeededtimes and append the clones to the track (so the track is much wider than the viewport and has no visible gap when it wraps):
``js for (let c = 0; c < setsNeeded; c++) sourceItems.forEach((i) => track.appendChild(i.cloneNode(true))); ``
- Drive the marquee with one infinitely repeating linear tween whose x is wrapped by a modifier so it loops seamlessly:
``js gsap.to(track, { x: -=${oneSetWidth}, duration: oneSetWidth / config.marqueeScrollSpeed, // ≈ 17.3s per set ease: "none", repeat: -1, modifiers: { x: (x) => ${gsap.utils.wrap(-oneSetWidth, 0, parseFloat(x))}px, }, }); ` gsap.utils.wrap(-oneSetWidth, 0, x) keeps x always in [-oneSetWidth, 0), so as it passes -oneSetWidth it jumps back to 0` — invisible because a full set has just scrolled by. Net effect: continuous leftward scroll at 100 px/s, forever.
2) SplitText — split heading + tagline + copy into lines
- Targets:
gsap.utils.toArray(".spotlight-content-wrapper h1, .spotlight-content-wrapper h3, .spotlight-copy p"). - For each element,
SplitText.create(element, { type: "lines", linesClass: "line", onSplit(instance){ … } }). - Keep a flat array
textLines = []of{ el, restCenterY: 0, currentY: 0, source: element }, one entry per split line. InonSplit, first remove any prior lines from the samesource(so re-splits on resize don't duplicate), then push the newinstance.lines, then callmeasureGeometry(). (type: "lines"only, no mask, no chars/words.)
3) Geometry measurement (measureGeometry, run on split, load and resize)
Compute and cache:
sectionHeight = section.getBoundingClientRect().heightstripBaseTop = strip.offsetTop(0 — strip istop:0)stripHeight = strip.offsetHeight(≈ 160px, the 10rem image row)stripRestCenterY = config.stripEdgeInset(175) — the strip's resting center measured from the section top.- For each line, walk the
offsetParentchain up to the section summingoffsetTop, thenline.restCenterY = y + line.el.offsetHeight / 2. TrackcontentTopAtRest = min over lines of (restCenterY - offsetHeight/2)(the top edge of the highest line); fall back tosectionHeight * 0.4if there are no lines yet. - If the pointer hasn't moved yet (
!hasPointerMoved), seed the strip's vertical state to its rest offset:const restY = config.stripEdgeInset - stripBaseTop - stripHeight/2;thenstripTargetY = stripCurrentY = stripPrevY = restY;. (restYis the y-translate that puts the strip's center at 175px, i.e. ≈ 95px down.)
4) Pointer input (mousemove on the section)
section.addEventListener("mousemove", (e) => {
hasPointerMoved = true;
const b = section.getBoundingClientRect();
const cursorY = e.clientY - b.top;
const wantedY = cursorY - stripBaseTop - stripHeight / 2; // translate so strip center is under cursor
const highestY = config.stripEdgeInset - stripBaseTop - stripHeight / 2; // clamp top (175 from top)
const lowestY = sectionHeight - config.stripEdgeInset - stripBaseTop - stripHeight / 2; // clamp bottom (175 from bottom)
stripTargetY = gsap.utils.clamp(highestY, lowestY, wantedY);
});
So the target for the strip is the cursor's Y (converted to a center-aligned translate), clamped to a vertical band inset 175px from both the top and bottom of the section. Only the target is set here — the actual move is lerped in the ticker.
5) The per-frame loop (gsap.ticker.add) — the heart of the effect
Runs every frame for the page lifetime. State vars: stripCurrentY, stripPrevY (both seeded in measureGeometry), and per-line currentY.
gsap.ticker.add(() => {
// (a) strip chases target
stripCurrentY += (stripTargetY - stripCurrentY) * config.stripFollowEase; // lerp 0.05
gsap.set(strip, { y: stripCurrentY });
const stripCenterY = stripBaseTop + stripCurrentY + stripHeight / 2;
const stripVelocityY = stripCurrentY - stripPrevY; // per-frame delta = signed speed
stripPrevY = stripCurrentY;
// (b) how far the strip has descended past its rest center, and how much the text should rise
const descentBelowRest = Math.max(0, stripCenterY - stripRestCenterY);
const maxRise = Math.max(0, contentTopAtRest - config.risenTopGap);
const contentRise = -Math.min(descentBelowRest * config.contentRiseRate, maxRise); // negative = up
// (c) per line: rise-out-of-the-way + velocity wake
textLines.forEach((line) => {
const gapToStrip = line.restCenterY - stripCenterY;
const reachedLine = stripCenterY + config.liftHeadStart >= line.restCenterY;
const wakeInfluence = Math.exp(
-(gapToStrip * gapToStrip) / (2 * config.wakeReach * config.wakeReach)
); // gaussian, 1 at strip center, →0 far away
const wakeOffset = stripVelocityY * wakeInfluence * config.wakeStrength;
const lineTarget = (reachedLine ? contentRise : 0) + wakeOffset;
line.currentY += (lineTarget - line.currentY) * config.lineSettleEase; // lerp 0.09
gsap.set(line.el, { y: line.currentY });
});
});
What each piece does:
- (a) Strip follow:
stripCurrentYeases towardstripTargetYby factor0.05every frame — the smooth magnetic lag. Applied as aytransform on the strip viagsap.set. - Strip velocity:
stripVelocityY = stripCurrentY - stripPrevYis the signed per-frame movement — positive when the strip is moving down, negative when moving up, ~0 when settled. This is the "wake" driver. - (b) Content rise: once the strip's center passes its rest center (175px) and descends,
descentBelowRestgrows; the text rises bydescentBelowRest * 0.85(upward, hence negative), but never higher thanmaxRise(so the top line never rises aboverisenTopGap = 100px from the section top). This is a single shared rise value for all lines that have been "reached". reachedLine: a line only participates in the rise oncestripCenterY + 125 >= line.restCenterY— i.e. the strip gets a 125px head start, lifting each line slightly before the strip's center actually reaches it. Lines the strip hasn't reached yet stay put (target 0 for the rise part).- (c) Wake:
wakeInfluenceis a gaussian of the line's distance to the strip center with sigmawakeReach = 125— 1 for lines right at the strip, falling off smoothly for distant lines.wakeOffset = stripVelocityY * wakeInfluence * 2.5shoves each nearby line in the direction the strip is currently moving, proportional to strip speed. So a fast pointer flick makes the closest lines "kick" and then settle. - Per-line settle: each line's own
currentYlerps towardlineTarget(rise + wake) by factor0.09, applied as itsytransform. This is why lines glide rather than snap, and why the wake decays back to the rest/rise value once the strip stops moving.
Utilities used
gsap.utils.wrap (marquee modifier), gsap.utils.clamp (pointer band), gsap.utils.toArray (split targets), gsap.set (per-frame transforms), gsap.ticker.add (the rAF loop). No CustomEase, no ScrollTrigger, no timelines beyond the single infinite marquee tween.
Resize
window.addEventListener("resize", measureGeometry) — re-measures section/strip/line geometry (and re-seeds the strip's rest position if the pointer hasn't moved). SplitText re-runs its onSplit on responsive re-split, rebuilding textLines.
Assets / images
6 distinct landscape photographic thumbnails, one per marquee item, forming a deliberately eclectic mood-board of subjects and color moods (not website screenshots). They are displayed at 18rem wide × 10rem tall and cropped with object-fit: cover, so any wide source (~16:9 / ~1.8:1) reads fine. Use these six, in order:
- Neon cyberpunk street at night — a foggy city avenue with a lone car headlamps-on, glowing storefronts and pedestrians. Dominant colors: saturated purple/magenta and violet with warm amber glints.
- Black candle product still-life — three matte-black tin candles arranged on angular light-grey surfaces in dramatic directional light. Monochrome: deep black objects against white/pale-grey.
- Retro-futuristic orange architecture — a vintage sci-fi cityscape of rounded orange-and-cream towers, domes and spires with a car in the foreground and autumn trees. Dominant colors: warm orange, cream and tan under a pale sky.
- White marble waves — a close-up of glossy, sculpted white marble in flowing wave-like folds with grey veining. Near-monochrome white and soft grey.
- Aerial river landscape — an overhead shot of a turquoise river meandering in S-curves through flat green grassland toward distant hills. Dominant colors: vivid teal water and bright green fields under blue sky.
- Dark cinematic interior — a dim vintage living room where a figure stands beside an old CRT television glowing red, the only light source in near-total shadow. Dominant colors: deep black shadow with a red glow.
No real brand names or logos.
Behavior notes
- Desktop / pointer-driven. The strip only chases the cursor via
mousemoveon the section; there is no scroll, click, or touch interaction. Before the first pointer move, the strip sits at its rest position (center 175px from top) and the text is at rest. - Marquee always runs, independent of the pointer — continuous leftward scroll at 100 px/s, looping seamlessly via the wrap modifier.
- Everything is lerped, so on a static/fresh load a screenshot shows the strip parked near the top and the content centered; the magnetic follow, rise and wake only appear once the pointer moves. No snapping anywhere.
- The centered content is
mix-blend-mode: differenceover the images — expect inverted colors where text overlaps the passing thumbnails; that inversion is the intended look. - No reduced-motion branch in the original; the ticker loop always runs.