Aduok Code

A Photo Card Deck, Built from One CSS Variable Per Card, a GSAP Stagger, and a Pointer-Driven Tilt

Introduction

This component is a loose deck of photo cards that drops in from above, settles into a gentle idle float, drifts with the cursor like a shallow diorama, and tilts individually in 3D when you hover one. There is no scroll-trigger and no per-card animation written by hand — the whole deck is driven by a handful of CSS custom properties and a small GSAP script that reads those properties at runtime.

The technique rests on four ideas: giving every card its size, position, rotation, and parallax depth as CSS custom properties on the element itself, instead of a bespoke class per card — animating the drop-in as one staggered tween from the center outward instead of one timeline entry per card — separating "motion that is always quietly running" (the idle float, the parallax) from "motion that only happens because the cursor is over this card" (the tilt) so each can be tuned or disabled independently — and building each card as a real focusable button with alt text, so the deck is usable without a mouse before any of the animation is even considered.

What you will learn

How to drive per-element layout and animation from CSS custom properties read at runtime instead of duplicating numbers in JS — how a "from: center" stagger reads as a deck settling rather than a list appearing — how to smooth a cursor-follow effect with a lerp instead of snapping straight to the pointer — how to give a hover tilt real 3D depth with rotateX/rotateY and a transform perspective — and how to keep ambient motion, cursor motion, and reduced-motion handling from tangling into one unmaintainable block.

01
Part One
Cards Are Data, Not Classes

Step 1 — Read Layout From Custom Properties

A tempting first draft is a .card-1, .card-2, .card-3 class for every photo, each with its own width, position and rotation baked into the stylesheet. That works until you want a ninth card, or to nudge card four two pixels to the left — now you are editing CSS and re-reading which class maps to which photo. Instead, every card is the same .photo-card class, and its size, position, rest rotation, and parallax depth live as inline custom properties on the element itself. The stylesheet has one rule; the markup has the data.

HTML
<div class="card-deck" id="cardDeck">
  <button class="photo-card"
    style="--w:130px; --h:180px; --x:4%; --y:30px; --rot:-9deg; --depth:14;">
    <img src="portrait-1.jpg" alt="Team member portrait" loading="lazy" />
  </button>
  <button class="photo-card"
    style="--w:230px; --h:310px; --x:60%; --y:0px; --rot:0deg; --depth:6;">
    <img src="portrait-2.jpg" alt="Team member portrait" loading="lazy" />
  </button>
</div>
CSS
.photo-card {
  --w: 180px;
  --h: 240px;
  --x: 0%;
  --y: 0;
  --rot: 0deg;
  --depth: 10;

  position: absolute;
  left: var(--x);
  top: var(--y);
  width: var(--w);
  height: var(--h);
  transform-style: preserve-3d;
}

The stylesheet has one rule for every card; the markup has the data. Adding a ninth card never means touching CSS again.

02
Part Two
A Center-Out Drop-In

Step 2 — Stagger From the Middle, Not the Start

A plain left-to-right stagger reads like a list loading. Setting GSAP's stagger.from to "center" instead makes the middle card land first and the outer cards catch up a beat later on both sides at once — the same visual logic as a deck of cards being dropped and settling, rather than photos appearing one after another in a queue. Each card also starts rotated 25° past its own rest rotation and falls from off-screen, so the settle motion has somewhere to travel from.

JavaScript
const cardState = cards.map((card) => ({
  el: card,
  restRotation: cssNumber(card, '--rot'),
  depth: cssNumber(card, '--depth', 10)
}));

gsap.set(cards, {
  y: -400,
  rotation: (i) => cardState[i].restRotation + 25,
  opacity: 0,
  scale: 0.7
});

gsap.to(cards, {
  y: 0,
  opacity: 1,
  scale: 1,
  rotation: (i) => cardState[i].restRotation,
  duration: 1.1,
  stagger: { each: 0.08, from: 'center' },
  ease: 'back.out(1.4)'
});

Reading --rot back out of the computed style with a small cssNumber() helper means the entrance animation never hard-codes a per-card rotation — it always settles to whatever the markup says that card's rest angle is, so changing a card's layout in HTML automatically updates its landing angle too.

03
Part Three
Idle Float That Never Syncs Up

Step 3 — Deliberately Mistuned Loops

Once settled, every card gets its own infinite yoyo tween nudging it up and down. If every card used the same duration and the same distance, the whole deck would breathe in and out in perfect unison, which reads as mechanical rather than alive. Instead, both the travel distance and the loop duration are derived from each card's index with a modulo, so neighboring cards drift at slightly different rates and slowly fall out of phase with each other.

JavaScript
cardState.forEach(({ el, restRotation }, i) => {
  gsap.to(el, {
    y: `+=${8 + (i % 3) * 5}`,
    rotation: restRotation + (i % 2 === 0 ? 1.5 : -1.5),
    duration: 3 + (i % 4) * 0.5,
    delay: 1.4 + i * 0.1,
    ease: 'sine.inOut',
    yoyo: true,
    repeat: -1
  });
});
04
Part Four
Cursor Parallax Across the Deck

Step 4 — Lerp Toward the Pointer, Never Snap to It

The whole deck also drifts toward the cursor, but each card moves a different amount based on its --depth value, which is what sells the illusion of the closer cards moving more than the farther ones. The raw pointer position is not applied directly — it is stored as a target, and a gsap.ticker callback eases the current position toward that target by a small fraction every frame. That single line of interpolation is what makes the drift feel like it has weight, instead of the deck snapping instantly to wherever the mouse happens to be.

JavaScript
let targetX = 0, targetY = 0;
let currentX = 0, currentY = 0;

deck.addEventListener('pointermove', (event) => {
  const rect = deck.getBoundingClientRect();
  targetX = ((event.clientX - rect.left) / rect.width - 0.5) * 2;
  targetY = ((event.clientY - rect.top) / rect.height - 0.5) * 2;
});
deck.addEventListener('pointerleave', () => { targetX = 0; targetY = 0; });

gsap.ticker.add(() => {
  currentX += (targetX - currentX) * 0.08;
  currentY += (targetY - currentY) * 0.08;
  cardState.forEach(({ el, depth }) => {
    el.style.translate = `${currentX * depth}px ${currentY * depth * 0.5}px`;
  });
});
A gotcha worth knowing about

Parallax is applied through the CSS translate property rather than transform, which leaves transform free for the per-card hover tilt in the next step. Writing both effects to transform directly would mean each one overwrites the other instead of combining.

05
Part Five
Per-Card 3D Tilt on Hover

Step 5 — rotateX/rotateY From Pointer Position Inside the Card

The tilt is calculated per card, from the pointer position relative to that card's own bounding box, not the deck's. The pointer's horizontal offset from center drives rotateY, the vertical offset drives rotateX (inverted, since moving the pointer up should tilt the top of the card toward the viewer), and a transformPerspective on the tween gives the rotation actual depth instead of looking like a flat skew. On leave, an elastic ease overshoots slightly past the rest state before settling, which reads as the card springing back rather than resetting.

JavaScript
el.addEventListener('pointermove', (event) => {
  const rect = el.getBoundingClientRect();
  const px = (event.clientX - rect.left) / rect.width - 0.5;
  const py = (event.clientY - rect.top) / rect.height - 0.5;
  gsap.to(el, {
    rotateX: -py * 16,
    rotateY: px * 16,
    scale: 1.12,
    zIndex: 20,
    duration: 0.4,
    ease: 'power2.out',
    transformPerspective: 700,
    overwrite: 'auto'
  });
});

el.addEventListener('pointerleave', () => {
  gsap.to(el, {
    rotateX: 0,
    rotateY: 0,
    scale: 1,
    zIndex: 'auto',
    duration: 0.8,
    ease: 'elastic.out(1, 0.6)',
    overwrite: 'auto'
  });
});

overwrite: "auto" matters more than it looks like it should — without it, moving the pointer quickly across a card queues up dozens of tilt tweens that fight each other, instead of each new pointer position simply replacing the last one in flight.

06
Part Six
Buttons, Alt Text, and Reduced Motion

Step 6 — The Deck Works Before Any Animation Runs

Each card is a <button>, not a <div> with a click handler, so it is reachable by keyboard and gets a visible :focus-visible outline for free. Every image carries real alt text. And because the entrance, float, and parallax are all opt-in JavaScript rather than baked into the markup's default appearance, checking prefers-reduced-motion once up front and short-circuiting the whole script is enough — cards are simply set to their rest position and shown, with no animation logic left half-run.

JavaScript
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (reduceMotion) {
  gsap.set(cards, { rotation: (i) => cardState[i].restRotation, opacity: 1 });
  return; // skip entrance, idle float, parallax, and tilt entirely
}

Tuning Reference

PropertyExampleEffect
--depth (per card)6 – 14How far a card drifts under cursor parallax; give closer-looking cards a lower number so they move less
stagger.each / from0.08s / "center"Gap between each card's entrance and which card leads; "center" reads as settling, "start" reads as a list
idle float duration3s + (i % 4) * 0.5How slow each card's breathing loop is; identical durations across all cards make the deck sync up and look mechanical
parallax lerp factor0.08How quickly the deck catches up to the cursor; lower feels heavier and more delayed, higher feels twitchy
tilt rotate multiplier16Maximum tilt angle in degrees at the card's edge; higher feels more dramatic but can clip corners at extreme angles
tilt transformPerspective700Simulated viewing distance for the 3D tilt; lower values exaggerate the depth, higher values flatten it

Full Source Code

The complete component is a single self-contained HTML document — one card deck, six photo cards driven entirely by custom properties, and one script handling entrance, idle float, parallax, and tilt. No build step, no external assets beyond the photos themselves.

HTML — image-drop-cards.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/image-drop-cards.html
Want to see it in action?Watch the full build on YouTube