All components

SpencerGabor Magnetic Cards

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A fanned stack of four cards centered on screen reacts magnetically to the cursor: mouse velocity within a 500px proximity radius (cubic falloff) pushes and tilts the cards, with neighbor influence and an elastic spring-and-friction return to their resting fan layout. Custom physics driven per-frame by gsap.ticker and gsap.set, triggered by moving the mouse over the section.

How it's built

Categorycards
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

magnetic cursor physics spring hover cards ticker

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

Magnetic Cards — Cursor-Reactive Physics Fan

Goal

Build a single full-viewport dark section holding a fanned stack of four square image cards centered on screen. The star effect: the cards react magnetically to cursor velocity. When the mouse moves quickly near the stack, the cards nearest the pointer are shoved and tilted in the direction of the swipe; slower/farther motion barely nudges them. Neighboring cards get dragged along a little, and when the cursor stops or leaves, every card springs back to its resting fan layout with an elastic, slightly-bouncy settle. There is no GSAP tween or timeline — the whole thing is a hand-rolled spring-and-friction physics integrator run every frame by gsap.ticker, with gsap.set writing the transforms.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) onlyno GSAP plugins, no Lenis, no Three.js, no smooth scroll. GSAP is used purely for gsap.set() (write transforms) and gsap.ticker.add() (the per-frame loop). Ship one index.html (<link rel="stylesheet" href="./styles.css"> and <script type="module" src="./script.js">), one styles.css, one ES-module script.js. Must run in a fresh Vite + npm project.

Layout / HTML

<section class="spotlight">
  <div class="cards">
    <div class="card"><img src="<card image 1>" alt="" /></div>
    <div class="card"><img src="<card image 2>" alt="" /></div>
    <div class="card"><img src="<card image 3>" alt="" /></div>
    <div class="card"><img src="<card image 4>" alt="" /></div>
  </div>
</section>
  • One .spotlight section is the interaction surface (mouse events bind here).
  • One .cards wrapper is the geometric center reference — it is centered in the section and every card is positioned relative to this wrapper's center (not the viewport).
  • Exactly four .card elements, each wrapping one <img>. Card order in the DOM (1→4) is left→right in the fan and also determines zIndex (later cards render on top).

Styling

  • Reset: * { margin:0; padding:0; box-sizing:border-box; }
  • img { width:100%; height:100%; object-fit:cover; }
  • .spotlight { position:relative; width:100%; height:100svh; background-color:#141414; overflow:hidden; } — near-black background; overflow:hidden matters so cards that get flung outward are clipped by the section.
  • .cards { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); } — a zero-size anchor pinned to the exact center of the section. (Note: JS overwrites the cards' own transforms via gsap.set, but this wrapper keeps its centering transform.)
  • .card { position:absolute; width:250px; height:250px; border-radius:1rem; overflow:hidden; }fixed 250×250 (1:1) squares, rounded corners, image clipped to the rounded box. All four cards are absolutely positioned and stacked at the wrapper origin, then offset by JS.

No web fonts, no text — the section is purely the four image cards on the dark field.

GSAP effect (be exhaustive)

Imports & element handles
import gsap from "gsap";

const cards = document.querySelectorAll(".card");
const spotlight = document.querySelector(".spotlight");
const cardsContainer = document.querySelector(".cards");
Tuning constants (exact values)
const PROXIMITY_RADIUS  = 500;   // px — cursor must be within this of a card to push it
const PUSH_FORCE        = 10;    // multiplier on cursor velocity → push strength
const TILT_AMOUNT       = 0.1;   // how much horizontal push force converts to rotation
const NEIGHBOR_INFLUENCE= 0.2;   // per-step falloff of force bleeding to neighbor cards
const SPRING_STIFFNESS  = 0.05;  // spring constant pulling a card back toward its target
const BOUNCE_FRICTION   = 0.85;  // velocity retained each frame (damping)
const CURSOR_SMOOTHING  = 0.75;  // low-pass on cursor velocity (0.75 old / 0.25 new)
Resting fan layout (per card, index 0→3)
const layout = {
  rotation: [5, -5, 7.5, -10],   // degrees
  x:        [-275, -100, 100, 275], // px offset from the .cards center
  y:        [10, -10, 25, -10],     // px offset from the .cards center
};

So at rest the four squares fan across the center: card 0 far-left rotated +5°, card 1 left rotated −5°, card 2 right rotated +7.5°, card 3 far-right rotated −10°, with small vertical jitter. They overlap because each is 250px wide but only ~175–200px apart.

Cursor state & per-card physics state
const cursor = { x: 0, y: 0, vx: 0, vy: 0 };
let prevCursorX = 0;
let prevCursorY = 0;

Initialize each card with gsap.set and build a physics object:

const cardPhysics = [...cards].map((el, i) => {
  gsap.set(el, {
    x: layout.x[i],
    y: layout.y[i],
    rotation: layout.rotation[i],
    zIndex: i,          // DOM order stacking
    xPercent: -50,      // center each card on the .cards origin
    yPercent: -50,
  });
  return {
    el,
    restX: layout.x[i], restY: layout.y[i], restR: layout.rotation[i], // targets
    x: layout.x[i], y: layout.y[i], r: layout.rotation[i],             // current
    vx: 0, vy: 0, vr: 0,                                               // velocities
  };
});

Note xPercent:-50, yPercent:-50 — cards are centered on the wrapper origin, then translated by the layout x/y.

Cursor velocity tracking (mousemove on .spotlight)

On every mousemove, compute a smoothed cursor velocity with an exponential low-pass filter, then store the raw position:

spotlight.addEventListener("mousemove", (e) => {
  cursor.vx = cursor.vx * CURSOR_SMOOTHING + (e.clientX - prevCursorX) * (1 - CURSOR_SMOOTHING);
  cursor.vy = cursor.vy * CURSOR_SMOOTHING + (e.clientY - prevCursorY) * (1 - CURSOR_SMOOTHING);
  prevCursorX = cursor.x = e.clientX;
  prevCursorY = cursor.y = e.clientY;
});

So vx/vy are 75% previous velocity + 25% of the latest per-event pixel delta — a heavily smoothed, momentum-ish swipe velocity in screen pixels.

On mouseleave, zero the velocity so the push force decays to nothing and the cards spring home:

spotlight.addEventListener("mouseleave", () => { cursor.vx = cursor.vy = 0; });
Per-card push force (calculatePushForce(card))

For each card, compute the force the cursor exerts this frame:

  1. speed = Math.sqrt(cursor.vx2 + cursor.vy2). If speed < 0.5, return {fx:0, fy:0} — a still (or barely moving) cursor exerts no force, no matter how close.
  2. Get the live center of the stack from the DOM: rect = cardsContainer.getBoundingClientRect(), then the card's resting center in screen space is cx = rect.left + rect.width/2 + card.restX, cy = rect.top + rect.height/2 + card.restY.
  3. dist = Math.sqrt((cursor.x - cx)2 + (cursor.y - cy)2). If dist > PROXIMITY_RADIUS (500px), return {fx:0, fy:0}.
  4. Proximity weight with a cubic falloff: weight = (1 - dist / PROXIMITY_RADIUS) ** 3 — 1 at the card's center, easing sharply toward 0 at 500px.
  5. Force = smoothed cursor velocity × push constant × weight:

``js return { fx: cursor.vx * PUSH_FORCE * weight, fy: cursor.vy * PUSH_FORCE * weight }; `` The push is in the direction the cursor is moving (velocity vector), scaled by how fast and how close.

Neighbor influence (applyNeighborInfluence(forces, index))

Each card also feels a fraction of every *other* card's force, so a shove propagates through the fan:

function applyNeighborInfluence(forces, index) {
  let fx = forces[index].fx;
  let fy = forces[index].fy;
  forces.forEach((f, j) => {
    if (j === index) return;
    const falloff = NEIGHBOR_INFLUENCE ** Math.abs(j - index); // 0.2^distance
    fx += f.fx * falloff;
    fy += f.fy * falloff * 0.6;   // vertical bleed is dampened to 60%
  });
  return { fx, fy };
}

0.2 ** |j-index| means an adjacent card contributes 20% of its force, a two-away card 4%, etc. Vertical influence is further multiplied by 0.6 so neighbors drag more horizontally than vertically.

The per-frame integrator (gsap.ticker.add)

The whole simulation runs inside one ticker callback (≈60fps), not a tween:

gsap.ticker.add(() => {
  const forces = cardPhysics.map(calculatePushForce);   // raw force per card this frame

  cardPhysics.forEach((card, i) => {
    const { fx, fy } = applyNeighborInfluence(forces, i); // + neighbor bleed

    // Damped spring toward (rest + force). Velocity is integrated then scaled by friction.
    card.vx = (card.vx + (card.restX + fx - card.x) * SPRING_STIFFNESS) * BOUNCE_FRICTION;
    card.vy = (card.vy + (card.restY + fy - card.y) * SPRING_STIFFNESS) * BOUNCE_FRICTION;
    card.vr = (card.vr + (card.restR + fx * TILT_AMOUNT - card.r) * SPRING_STIFFNESS) * BOUNCE_FRICTION;

    card.x += card.vx;
    card.y += card.vy;
    card.r += card.vr;

    gsap.set(card.el, { x: card.x, y: card.y, rotation: card.r });
  });
});

Read the spring line carefully — it is the heart of the effect:

  • The target for each card is restX + fx (its home position displaced by the current push). Rotation targets restR + fx * TILT_AMOUNT, so a horizontal push also tilts the card (leftward shove rotates one way, rightward the other), by 10% of the horizontal force.
  • (target - current) * SPRING_STIFFNESS (0.05) is the spring pull added to the existing velocity → an under-damped spring that overshoots and oscillates.
  • Multiplying the whole velocity by BOUNCE_FRICTION (0.85) each frame bleeds off 15% per frame, so oscillations decay and the card settles — giving the springy, slightly-bouncy return. Higher friction toward 1 = looser/bouncier; this 0.85 gives a quick but visibly elastic settle.
  • Because the force term (fx,fy) drops to 0 the instant the cursor slows below speed 0.5 / leaves / moves beyond 500px, the target collapses back to rest*, and the same spring carries every card home.
Feel summary
  • Fast swipe through the stack: nearby cards fling in the swipe direction and tilt, neighbors trail, then all bounce back.
  • Slow drift: almost no motion (velocity < 0.5 or cubic falloff kills it).
  • Cursor leaves the section: velocity zeroed, cards spring home.
  • No easing functions, no durations, no timeline — spring stiffness (0.05) and friction (0.85) fully define the motion, integrated every ticker frame.

Assets / images

Four square (1:1) card images, each filling a 250×250 rounded box (object-fit:cover). Use flat, graphic, single-figure character illustrations in a muted vector/editorial poster style — one hooded or costumed figure per card, waist-up or full-body, centered on a flat, softly-textured single-color background, with subtle grain and simple shading (no photos, no text, no client or third-party branding). The four are distinct, earthy-toned action characters, each on its own muted field:

  • Card 1 (leftmost): a hooded figure in an olive-green tunic gripping a long-handled scythe, on a pale warm grey-green (sage/tan) background.
  • Card 2: a man in a tan trench coat, white shirt, dark tie and black glasses, shown in profile pointing to the right, on a desaturated dark teal/slate-green background.
  • Card 3: a black-clad ninja crouching with a dagger, on a muted steel-blue background.
  • Card 4 (rightmost): a grey-hooded, cloaked archer drawing a bow with arrows on the back, on a warm off-white/cream background.

Card 1 is the leftmost in the fan, card 4 the rightmost. Any four distinct, high-contrast single-figure illustrations of this kind read well — the specific characters and muted backgrounds are interchangeable so long as each card is one bold figure on a flat, low-saturation field.

Behavior notes

  • Desktop / pointer-driven only — the entire effect is mousemove velocity on the .spotlight section; there is no touch, click, scroll, or load animation, and no reduced-motion branch (the ticker always runs).
  • Live-anchored math: the stack center is re-read from getBoundingClientRect() every frame, so resizing the window keeps the physics correctly centered without extra code.
  • The cards are a fixed 250px fan; the x/y/rotation layout arrays and the seven tuning constants define the exact look and response — keep them as given.