Building a 3D Infinite Carousel with Reactive Background Gradients

0
6


In this tutorial, you’ll learn how to build an elegant, infinitely looping card slider that feels fluid, responsive, and visually cohesive. The key idea is to have a soft, evolving background gradient extracted from the active image and painted onto a <canvas>, creating a seamless transition between content and background.

As you follow along, you’ll create a smooth 3D carousel that scrolls endlessly while dynamically adapting its colors to each image. You’ll explore how to combine motion, depth, and color extraction techniques to craft a polished, high-performance experience that feels immersive and alive.


Free GSAP 3 Express Course


Learn modern web animation using GSAP 3 with 34 hands-on video lessons and practical projects — perfect for all skill levels.


Check it out

Concept & Architecture

Before diving into code, let’s understand the structure that makes this effect possible.
The carousel is built on two coordinated layers that work together to create
depth, motion, and color harmony.

  1. Foreground (DOM): a sequence of absolutely positioned .card elements arranged in a seamless horizontal loop. Each card receives a 3D transform (translateZ + rotateY + scale) that adjusts dynamically based on its distance from the viewport center, giving the illusion of depth and rotation in space.
  2. Background (Canvas): a softly blurred, drifting field of color created from multiple radial gradients. This layer continuously moves and evolves. When the active card changes, we extract dominant colors from its image and smoothly tween the gradient palette, creating a subtle visual connection between the image and its ambient background.

Minimal Markup

Let’s start with the bare essentials.
Our carousel is built on a clean, minimal HTML structure — just a few key elements
that give us everything we need to position, render, and animate the experience.

We’ll use a stage element to hold everything, a background <canvas> to paint the dynamic gradient and an empty #cards container that we’ll fill in later using JavaScript.

<main class="stage" aria-live="polite">
  <canvas id="bg" aria-hidden="true"></canvas>
  <section id="cards" class="cards" aria-label="Infinite carousel of images"></section>
</main>

Styling Essentials

Now that we have our structure, it’s time to give it some visual grounding.
A few carefully chosen CSS rules will establish the illusion of depth,
define rendering boundaries, and keep performance smooth while everything moves in 3D space.

Here are the key styling principles we’ll rely on:

  • Perspective on the stage defines the viewer’s point of depth, making 3D transforms feel tangible and cinematic.
  • Preserve-3d ensures each card keeps its spatial relationship when rotated or translated in 3D.
  • Containment limits the layout and paint scope to improve performance by isolating each card’s render area.
  • Canvas blur applies a soft Gaussian blur that keeps the background gradient smooth and dreamlike without drawing attention away from the foreground.
:root {
  --perspective: 1800px;
}

.stage {
  perspective: var(--perspective);
  overflow: hidden;
}

.cards {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

.card {
  position: absolute;
  top: 50%;
  left: 50%;
  transform-style: preserve-3d;
  backface-visibility: hidden;
  contain: layout paint;
  transform-origin: 90% center;
}

#bg {
  position: absolute;
  inset: 0;
  filter: blur(24px) saturate(1.05);
}

Creating Cards

With our layout and styles ready, the next step is to populate the carousel. All cards are dynamically generated from the IMAGES array and injected into the #cards container. This keeps the markup lightweight and flexible while allowing us to easily swap or extend the image set later on.

We begin by clearing any existing content inside the container, then loop over each image path.
For each one, we create an <article class="card"> element containing an
<img>. To keep things performant, we use a DocumentFragment so that
all cards are appended to the DOM in one efficient operation instead of multiple reflows.

Each card reference is also stored in an items array, which will later help us
manage positioning, transformations, and animations across the carousel.

const IMAGES = [
  './img/img01.webp',
  './img/img02.webp',
  './img/img03.webp',
  './img/img04.webp',
  './img/img05.webp',
  './img/img06.webp',
  './img/img07.webp',
  './img/img08.webp',
  './img/img09.webp',
  './img/img10.webp',
];

const cardsRoot = document.getElementById('cards');
let items = [];

function createCards() {
  cardsRoot.innerHTML = '';
  items = [];

  const fragment = document.createDocumentFragment();

  IMAGES.forEach((src, i) => {
    const card = document.createElement('article');
    card.className = 'card';
    card.style.willChange = 'transform'; // Force GPU compositing

    const img = new Image();
    img.className = 'card__img';
    img.decoding = 'async';
    img.loading = 'eager';
    img.fetchPriority = 'high';
    img.draggable = false;
    img.src = src;

    card.appendChild(img);
    fragment.appendChild(card);
    items.push({ el: card, x: i * STEP });
  });

  cardsRoot.appendChild(fragment);
}

Screen-Space Transform

Now that we’ve created our cards, we need to give them a sense of depth and perspective.
This is where the 3D transform comes in. For each card, we calculate its position relative
to the center of the viewport and use that value to determine how it should appear in space.

We start by normalizing the card’s X position. This tells us how far it is from the center of the screen. That normalized value then drives three key visual properties:

  • Rotation (Y-axis): how much the card turns toward or away from the viewer.
  • Depth (Z translation): how far the card is pushed toward or away from the camera.
  • Scale: how large or small the card appears depending on its distance from the center.

Cards closer to the center appear larger and nearer, while those on the sides shrink slightly
and tilt away. The result is a subtle yet convincing sense of 3D curvature as the carousel moves.
The function below returns both the full transform string and the calculated depth value,
which we’ll use later for proper layering.

const MAX_ROTATION = 28;   // deg
const MAX_DEPTH    = 140;  // px
const MIN_SCALE    = 0.8;
const SCALE_RANGE  = 0.20;

let VW_HALF = innerWidth * 0.5;

function transformForScreenX(screenX) {
  const norm = Math.max(-1, Math.min(1, screenX / VW_HALF));
  const absNorm = Math.abs(norm);
  const invNorm = 1 - absNorm;

  const ry = -norm * MAX_ROTATION;
  const tz = invNorm * MAX_DEPTH;
  const scale = MIN_SCALE + invNorm * SCALE_RANGE;

  return {
    transform: `translate3d(${screenX}px,-50%,${tz}px) rotateY(${ry}deg) scale(${scale})`,
    z: tz,
  };
}

With this transform logic in place, our cards naturally align into a curved layout,
giving the illusion of a continuous 3D ring. Here’s what it looks like in action:

3D card carousel visual example

Input & Inertia

To make the carousel feel natural and responsive, we don’t move the cards directly based on scroll or drag events. Instead, we translate user input into a velocity value that drives smooth, inertial motion. A continuous animation loop updates the position each frame and gradually slows it down using friction, just like physical momentum fading out over time. This gives the carousel that effortless, gliding feel when you release your touch or stop scrolling.

Here’s how the system behaves conceptually:

  • Wheel or drag → adds to the current velocity, nudging the carousel forward or backward.
  • Each frame → the position moves according to the velocity, which is then reduced by a friction factor.
  • The position is wrapped seamlessly so the carousel loops infinitely in either direction.
  • Very small velocities are clamped to zero to prevent micro-jitters when motion should be at rest.
  • This approach keeps movement smooth and consistent across all input types (mouse, touch, or trackpad).

Here’s a quick example of how we capture scroll or wheel input and translate it into velocity:

stage.addEventListener(
  'wheel',
  (e) => {
    if (isEntering) return;
    e.preventDefault();

    const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    vX += delta * WHEEL_SENS * 20;
  },
  { passive: false }
);

Then, within the main animation loop, we apply the velocity, gradually decay it, and update the transforms
so the cards slide and settle naturally:

function tick(t) {
  const dt = lastTime ? (t - lastTime) / 1000 : 0;
  lastTime = t;

  // Apply velocity to scroll position
  SCROLL_X = mod(SCROLL_X + vX * dt, TRACK);

  // Apply friction to velocity
  const decay = Math.pow(FRICTION, dt * 60);
  vX *= decay;
  if (Math.abs(vX) < 0.02) vX = 0;

  updateCarouselTransforms();
  rafId = requestAnimationFrame(tick);
}

Image → Color Palette

To make our background gradients feel alive and connected to the imagery,
we’ll need to extract colors directly from each image.
This allows the canvas background to reflect the dominant hues of the active card,
creating a subtle harmony between foreground and background.

The process is lightweight and fast. We first measure the image’s aspect ratio so we can scale it down proportionally to a tiny offscreen canvas with up to 48 pixels on the longest side. This step keeps color data accurate without wasting memory on unnecessary pixels. Once drawn to this mini-canvas, we grab its pixel data using getImageData(), which gives us an array of RGBA values we can later analyze to build our gradient palettes.

function extractColors(img, idx) {
  const MAX = 48;
  const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 1;
  const tw = ratio >= 1 ? MAX : Math.max(16, Math.round(MAX * ratio));
  const th = ratio >= 1 ? Math.max(16, Math.round(MAX / ratio)) : MAX;

  const c = document.createElement('canvas');
  c.width = tw; 
  c.height = th;

  const ctx = c.getContext('2d');
  ctx.drawImage(img, 0, 0, tw, th);
  const data = ctx.getImageData(0, 0, tw, th).data;
}

At this stage, we’re not yet selecting which colors to keep but we’re simply capturing the raw pixel data. In the next steps, this information will be analyzed to find dominant tones and gradients that visually match each image.

Canvas Background: Multi-Blob Field

Now that we can extract colors, let’s bring the background to life. We’ll paint two softly drifting radial blobs across a full-screen <canvas> element. Each blob uses colors pulled from the current image’s palette, and when the active card changes, those hues are smoothly blended using GSAP tweening. The result is a living, breathing gradient field that evolves with the carousel’s motion.

To maintain responsiveness, the system adjusts its rendering cadence dynamically:
it paints at a higher frame rate during transitions for smooth color shifts,
then idles around 30 fps once things settle.
This balance keeps visuals fluid without taxing performance unnecessarily.

Why canvas? Frequent color updates in DOM-based or CSS gradients are costly,
often triggering layout and paint recalculations.
With <canvas>, we gain fine-grained control over
how often and how precisely each frame is drawn,
making the rendering predictable, efficient, and beautifully smooth.

const g1 = bgCtx.createRadialGradient(x1, y1, 0, x1, y1, r1);
g1.addColorStop(0, `rgba(${gradCurrent.r1},${gradCurrent.g1},${gradCurrent.b1},0.68)`);
g1.addColorStop(1, 'rgba(255,255,255,0)');
bgCtx.fillStyle = g1;
bgCtx.fillRect(0, 0, w, h);

By layering and animating several of these gradients with slightly different positions and radii, we create a soft, organic color field with a perfect ambient backdrop that enhances the carousel’s mood without distracting from the content.

Performance

To make the carousel feel instantaneous and buttery-smooth from the very first interaction,
we can apply a couple of small but powerful performance techniques.
These optimizations help the browser prepare resources in advance and warm up
the GPU so animations start seamlessly without visible lag or stutter.

Decode images before start

Before any animations or transforms begin, we make sure all our card images are
fully decoded and ready for rendering. We iterate through each item, grab its
<img> element, and if the browser supports the asynchronous
img.decode() method, we call it to pre-decode the image into memory.
All these decoding operations return promises, which we collect and
await using Promise.allSettled(). This ensures the setup
continues even if one or two images fail to decode properly, and it gives the
browser a chance to prepare texture data ahead of time — helping our first animations
feel far smoother.

async function decodeAllImages() {
  const tasks = items.map((it) => {
    const img = it.el.querySelector('img');
    if (!img) return Promise.resolve();

    if (typeof img.decode === 'function') {
      return img.decode().catch(() => {});
    }

    return Promise.resolve();
  });

  await Promise.allSettled(tasks);
}

Compositing warmup

Next, we use a clever trick to “pre-scroll” the carousel and warm up the GPU’s compositing cache. The idea is to gently move the carousel off-screen in small increments, updating transforms each time. This process forces the browser to pre-compute texture and layer data for all the cards. Every few steps, we await requestAnimationFrame to yield control back to the main thread, preventing blocking or jank. Once we’ve covered the full loop, we restore the original position and give the browser a couple of idle frames to settle.

The result: when the user interacts for the first time, everything is already cached and ready to move —
no delayed paints, no cold-start hiccups, just instant fluidity.

async function warmupCompositing() {
  const originalScrollX = SCROLL_X;
  const stepSize = STEP * 0.5;
  const numSteps = Math.ceil(TRACK / stepSize);

  for (let i = 0; i < numSteps; i++) {
    SCROLL_X = mod(originalScrollX + i * stepSize, TRACK);
    updateCarouselTransforms();

    if (i % 3 === 0) {
      await new Promise((r) => requestAnimationFrame(r));
    }
  }

  SCROLL_X = originalScrollX;
  updateCarouselTransforms();
  await new Promise((r) => requestAnimationFrame(r));
  await new Promise((r) => requestAnimationFrame(r));
}

Putting It Together

Now it’s time to bring all the moving pieces together.
Before the carousel becomes interactive, we go through a precise initialization sequence
that prepares every visual and performance layer for smooth playback.
This ensures that when the user first interacts, the experience feels instant, fluid, and visually rich.

We start by preloading and creating all cards, then measure their layout and apply the initial 3D transforms.
Once that’s done, we wait for every image to fully load and decode so the browser has them
ready in GPU memory. We even force a paint to make sure everything is rendered once before motion begins.

Next, we extract the color data from each image to build a gradient palette and find which card sits
closest to the viewport center. That card’s dominant tones become our initial background gradient,
bridging the carousel and its ambient backdrop in a single unified scene.

We then initialize the background canvas, fill it with a base color, and perform a GPU “warmup” pass to cache layers and textures. A short idle delay gives the browser a moment to settle before we start the background animation loop. Finally, we reveal the visible cards with a soft entry animation, hide the loader, and enable user input, handing control over to the main carousel loop.

async function init() {
  // Preload images for faster loading
  preloadImageLinks(IMAGES);
  
  // Create DOM elements
  createCards();
  measure();
  updateCarouselTransforms();
  stage.classList.add('carousel-mode');

  // Wait for all images to load
  await waitForImages();

  // Decode images to prevent jank
  await decodeAllImages();

  // Force browser to paint images
  items.forEach((it) => {
    const img = it.el.querySelector('img');
    if (img) void img.offsetHeight;
  });

  // Extract colors from images for gradients
  buildPalette();

  // Find and set initial centered card
  const half = TRACK / 2;
  let closestIdx = 0;
  let closestDist = Infinity;

  for (let i = 0; i < items.length; i++) {
    let pos = items[i].x - SCROLL_X;
    if (pos < -half) pos += TRACK;
    if (pos > half) pos -= TRACK;
    const d = Math.abs(pos);
    if (d < closestDist) {
      closestDist = d;
      closestIdx = i;
    }
  }

  setActiveGradient(closestIdx);

  // Initialize background canvas
  resizeBG();
  if (bgCtx) {
    const w = bgCanvas.clientWidth || stage.clientWidth;
    const h = bgCanvas.clientHeight || stage.clientHeight;
    bgCtx.fillStyle = '#f6f7f9';
    bgCtx.fillRect(0, 0, w, h);
  }

  // Warmup GPU compositing
  await warmupCompositing();

  // Wait for browser idle time
  if ('requestIdleCallback' in window) {
    await new Promise((r) => requestIdleCallback(r, { timeout: 100 }));
  }

  // Start background animation
  startBG();
  await new Promise((r) => setTimeout(r, 100)); // Let background settle

  // Prepare entry animation for visible cards
  const viewportWidth = window.innerWidth;
  const visibleCards = [];
  
  for (let i = 0; i < items.length; i++) {
    let pos = items[i].x - SCROLL_X;
    if (pos < -half) pos += TRACK;
    if (pos > half) pos -= TRACK;

    const screenX = pos;
    if (Math.abs(screenX) < viewportWidth * 0.6) {
      visibleCards.push({ item: items[i], screenX, index: i });
    }
  }

  // Sort cards left to right
  visibleCards.sort((a, b) => a.screenX - b.screenX);

  // Hide loader
  if (loader) loader.classList.add('loader--hide');

  // Animate cards entering
  await animateEntry(visibleCards);

  // Enable user interaction
  isEntering = false;

  // Start main carousel loop
  startCarousel();
}

This is the result:

Adaptations & Tweaks

Once the main setup is working, you can start tuning the feel and depth of the carousel to match your project’s style. Below are the key tunable constants. Each one adjusts a specific aspect of the 3D motion, spacing, or background atmosphere. A few subtle changes here can dramatically shift the experience from playful to cinematic.

// 3D look
const MAX_ROTATION = 28;   // higher = stronger “page-flip”
const MAX_DEPTH    = 140;  // translateZ depth
const MIN_SCALE    = 0.80; // higher = flatter look
const SCALE_RANGE  = 0.20; // focus boost at center

// Layout & spacing
let GAP  = 28;             // visual spacing between cards

// Motion feel
const FRICTION = 0.9;           // Velocity decay (0-1, lower = more friction)
const WHEEL_SENS = 0.6;         // Mouse wheel sensitivity
const DRAG_SENS = 1.0;          // Drag sensitivity

// Background (canvas)
/// (in CSS) #bg { filter: blur(24px) }  // increase for creamier background

Experiment with these values to find the right balance between responsiveness and depth. For example, a higher MAX_ROTATION and MAX_DEPTH will make the carousel feel more sculptural, while a lower FRICTION will add a more kinetic, free-flowing motion. The background blur also plays a big role in mood: a softer blur creates a dreamy, immersive feel, while a sharper one feels crisp and modern.