Frontend

How to Build a Circular Image Carousel with GSAP and Trigonometry

12 min read21 views
GSAPAnimationJavaScriptCSSTrigonometryUI/UX
Share
Cover image for How to Build a Circular Image Carousel with GSAP and Trigonometry

A few weeks ago, I was tasked with building a hero section that needed to feel different — not the usual horizontal slider or fade-through gallery, but a circular carousel where images orbit around a central content area like planets around a star.

The result? A smooth, auto-rotating ring of images powered by GSAP, plain CSS, and a bit of high-school trigonometry. In this article, I'll break down exactly how it works — every line of math, every animation trick — so you can build your own.

See the live demo on CodePen — fork it, tweak it, make it yours.


The End Result

Picture this: a hero section with a bold headline in the center. Surrounding it, 12 images are arranged in a perfect circle. Every 2.5 seconds, the ring rotates to bring the next image to the top — with the active image scaling up slightly for emphasis. Each image also tilts based on its angular position, creating a natural sense of depth. The whole thing is responsive, with the orbit radius adapting to screen size.


HTML Structure

The markup is clean — a section with a carousel container full of images, and a centered content block layered on top:

<section class="section">
  <div class="carousel" id="carousel">
    <img src="https://images.unsplash.com/photo-1552664730-d307ca884978?w=400&q=80" alt="Creative workspace">
    <img src="https://images.unsplash.com/photo-1561070791-2526d30994b5?w=400&q=80" alt="Design tools">
    <img src="https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=400&q=80" alt="Coding">
    <img src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=400&q=80" alt="Team collaboration">
    <!-- ... 12 images total -->
  </div>

  <div class="center">
    <span class="pill">Discover Innovation</span>
    <h1>Transform Your<br />Creative Vision</h1>
    <p class="lead">
      Unlock your potential with cutting-edge tools designed
      for creators, innovators, and dreamers.
    </p>
    <a class="cta" href="#" role="button">
      Join Now
      <span class="arrow-bg">
        <svg class="arrow-icon" ...>...</svg>
      </span>
    </a>
  </div>
</section>

The images are flat <img> tags inside the carousel container — no wrappers needed. JavaScript handles all the positioning. The .center div sits on top with a higher z-index.


CSS Foundation

Here's the core styling that makes the layout work:

.section {
  position: relative;
  height: 1000px;
  max-width: 1600px;
  margin: 0 auto;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: #ffffff;
  overflow: hidden;
}

.carousel {
  position: absolute;
  width: 100%;
  height: 1909px;
  top: 10%;
  left: 0;
  display: flex;
  justify-content: center;
  align-items: center;
}

.carousel img {
  position: absolute;
  width: 250px;
  height: 250px;
  object-fit: cover;
  border-radius: 10px;
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
  transition: transform 0.3s ease;
}

.carousel img:hover {
  transform: scale(1.1);
  z-index: 3;
}

Key decisions:

  • position: absolute on images — GSAP will set x and y transforms to position each one around the circle.
  • The carousel container is much taller than the section — this gives the circle room to extend above and below the visible area, so images at the top and bottom aren't clipped awkwardly.
  • box-shadow adds depth to each card, making them feel like physical objects floating in space.
  • Hover scale — a subtle CSS interaction that works independently of the GSAP animation.

The center content uses z-index: 2 and position: relative to sit above the carousel:

.center {
  position: relative;
  text-align: center;
  margin-top: 300px;
  max-width: min(720px, 92vw);
  padding-inline: clamp(12px, 3vw, 18px);
  z-index: 2;
}

The Math: Placing Items in a Circle

This is where it gets interesting. To place 12 items evenly around a circle, we need trigonometry. The concept:

x = radius × sin(angle)
y = -radius × cos(angle)

With 12 items and 360 degrees, each item is separated by 30 degrees (360 / 12). Using sin for X and -cos for Y (negative because Y grows downward in the browser) places item 0 at the top of the circle — the 12 o'clock position.

Here's the core positioning function:

const carousel = document.getElementById("carousel");
const images = carousel.querySelectorAll("img");
const total = images.length;  // 12
const stepDeg = 30;           // 360 / 12
let activeIndex = 0;

function getRadius() {
  if (window.innerWidth < 768) return 450;
  if (window.innerWidth < 1024) return 600;
  if (window.innerWidth < 1281) return 700;
  return 800;
}

Why Degrees Instead of Radians?

In this implementation, we work in degrees and convert to radians only when calling Math.sin and Math.cos. This makes the code more readable — it's easier to reason about "30 degrees per step" than "0.5236 radians per step":

let deg = relIndex * stepDeg;       // e.g. 0, 30, 60, 90...
let rad = deg * Math.PI / 180;      // convert to radians
let x = Math.sin(rad) * radius;     // horizontal position
let y = -Math.cos(rad) * radius;    // vertical position (negative = up)

Breaking Down the Math

For item 0 at the top (deg = 0):

  • sin(0) = 0x = 0 (centered horizontally)
  • -cos(0) = -1y = -radius (above center)

For item 3 at the right (deg = 90):

  • sin(90°) = 1x = radius (far right)
  • -cos(90°) = 0y = 0 (vertically centered)

For item 6 at the bottom (deg = 180):

  • sin(180°) = 0x = 0 (centered)
  • -cos(180°) = 1y = radius (below center)

It traces a perfect circle, starting from the top.


The Rotation Engine

Here's the heart of the carousel — the setCarouselActive function. Instead of animating a single offset angle, this approach calculates each image's position relative to the active index:

function setCarouselActive(index) {
  const radius = getRadius();

  images.forEach((img, i) => {
    // Calculate relative position from active item
    let relIndex = (index - i + total) % total;
    let deg = relIndex * stepDeg;
    let rad = deg * Math.PI / 180;
    let x = Math.sin(rad) * radius;
    let y = -Math.cos(rad) * radius;

    if (i === index) {
      // Active image: pin to top, scale up
      gsap.to(img, {
        duration: 0.7,
        x: 0,
        y: -radius,
        rotation: "0_short",
        scale: 1.13,
        ease: "power3.inOut"
      });
    } else {
      // Other images: position around the circle
      gsap.to(img, {
        duration: 0.7,
        x: x,
        y: y,
        rotation: deg,
        scale: 1,
        ease: "power3.inOut"
      });
    }
  });
}

The Clever Part: Relative Indexing

The line let relIndex = (index - i + total) % total is doing the heavy lifting. It calculates how many steps each image is away from the active image, wrapping around using modular arithmetic.

If index = 2 (item 2 is active):

  • Item 0: (2 - 0 + 12) % 12 = 2 → 60° from top
  • Item 2: (2 - 2 + 12) % 12 = 0 → 0° (at the top)
  • Item 5: (2 - 5 + 12) % 12 = 9 → 270° from top
  • Item 11: (2 - 11 + 12) % 12 = 3 → 90° from top

This means the active item always gets relIndex 0 (top position), and every other item is positioned relative to it. When the active index changes, the entire ring effectively rotates.

Active Item Treatment

The active image gets special treatment:

  • Pinned to the top: x: 0, y: -radius — hardcoded to 12 o'clock
  • Scaled up: scale: 1.13 — 13% larger than the others
  • No tilt: rotation: "0_short" — GSAP's directional rotation shorthand ensures the shortest rotation path back to 0°

Every other image uses its calculated rotation: deg, which tilts each card to match its angular position — images on the right lean clockwise, images on the left lean counter-clockwise. This creates a natural, 3D-like perspective.


Responsive Radius

The getRadius() function uses fixed breakpoints that match the CSS media queries:

function getRadius() {
  if (window.innerWidth < 768) return 450;    // mobile
  if (window.innerWidth < 1024) return 600;   // tablet
  if (window.innerWidth < 1281) return 700;   // small desktop
  return 800;                                  // large desktop
}

These values are intentionally larger than the visible area. Since the section has overflow: hidden, images at the far sides and bottom of the circle are partially or fully hidden — this is by design. It creates the illusion that the ring extends beyond the viewport, making the visible arc feel like part of something larger.

The CSS media queries match these breakpoints by adjusting the section height, carousel height, and image dimensions:

@media (max-width: 767px) {
  .section { height: 800px; }
  .carousel { height: 800px; top: 20%; }
  .carousel img { width: 150px; height: 150px; }
}

@media (max-width: 480px) {
  .section { height: 650px; }
  .carousel { height: 600px; top: 40%; }
  .carousel img { width: 120px; height: 120px; }
}

Auto-Rotation

The carousel auto-advances every 2.5 seconds:

// Initial render
setCarouselActive(activeIndex);

// Auto-rotate
setInterval(() => {
  activeIndex = (activeIndex + 1) % total;
  setCarouselActive(activeIndex);
}, 2500);

The modular arithmetic (activeIndex + 1) % total ensures the index wraps from 11 back to 0, creating an infinite loop. Each rotation triggers 12 simultaneous gsap.to() calls — one per image — but since they all target transform properties, GSAP batches them efficiently.

Handling resize is a one-liner:

window.addEventListener('resize', () => setCarouselActive(activeIndex));

The CTA Button Arrow Effect

A small but delightful detail — the call-to-action button has an arrow icon that rotates on hover:

.cta .arrow-icon {
  transform: rotate(70deg);
  transition: transform 0.5s ease;
}

.cta:hover .arrow-icon {
  transform: rotate(20deg);
}

The arrow starts rotated at 70° and eases to 20° on hover — a 50° swing that feels like it's "pointing" toward the action. Combined with a subtle translateY(-1px) scale(1.03) on the button itself, it creates a premium micro-interaction with zero JavaScript.


Performance Considerations

  1. 12 simultaneous GSAP tweens — Sounds expensive, but GSAP batches these internally. Each tween only animates x, y, rotation, and scale — all GPU-composited transform properties. No layout or paint is triggered.

  2. box-shadow on images — This is painted once and cached. Since we're only animating transforms, the shadow doesn't need to be repainted on each frame.

  3. transition: transform 0.3s on hover — This CSS transition works alongside GSAP without conflict because GSAP uses inline transform styles while the CSS transition handles the same property. The hover scale composes on top of GSAP's transform.

  4. No will-change needed — GSAP automatically promotes animated elements to compositor layers when using transform properties. Adding will-change: transform explicitly would be redundant.


The Complete JavaScript

Here's the full script — just 35 lines:

const carousel = document.getElementById("carousel");
const images = carousel.querySelectorAll("img");
const total = images.length;
const stepDeg = 30; // 360/12
let activeIndex = 0;

function getRadius() {
  if (window.innerWidth < 768) return 450;
  if (window.innerWidth < 1024) return 600;
  if (window.innerWidth < 1281) return 700;
  return 800;
}

function setCarouselActive(index) {
  const radius = getRadius();
  images.forEach((img, i) => {
    let relIndex = (index - i + total) % total;
    let deg = relIndex * stepDeg;
    let rad = deg * Math.PI / 180;
    let x = Math.sin(rad) * radius;
    let y = -Math.cos(rad) * radius;

    if (i === index) {
      gsap.to(img, {
        duration: 0.7, x: 0, y: -radius,
        rotation: "0_short", scale: 1.13,
        ease: "power3.inOut"
      });
    } else {
      gsap.to(img, {
        duration: 0.7, x, y,
        rotation: deg, scale: 1,
        ease: "power3.inOut"
      });
    }
  });
}

setCarouselActive(activeIndex);

setInterval(() => {
  activeIndex = (activeIndex + 1) % total;
  setCarouselActive(activeIndex);
}, 2500);

window.addEventListener('resize', () => setCarouselActive(activeIndex));

Try It Yourself

View the live demo on CodePen — you can fork it and experiment with different radii, step angles, easing functions, and image counts.


Wrapping Up

This circular carousel proves that you don't need a heavy library or framework to build impressive UI interactions. The entire implementation is:

  • ~35 lines of JavaScript — compact and readable
  • GSAP for smooth tweeningpower3.inOut easing gives that premium feel
  • Basic trigonometrysin and cos with degree-to-radian conversion
  • Responsive by design — breakpoint-aware radius + CSS media queries
  • Zero dependencies beyond GSAP — no framework, no build step

The core insight is that circular positioning is just trigonometry. Once you understand that sin(θ) and cos(θ) trace a unit circle and you can scale it by any radius, you can arrange elements in arcs, spirals, or full rings with confidence.

Try extending this: add drag-to-rotate with GSAP Draggable, layer in parallax by scaling items based on their Y position, or animate individual item opacity based on distance from the active position. The mathematical foundation supports all of it.

Free: The SaaS PageSpeed Checklist

12 things slowing your site down — and what fixing them means for your conversions. No jargon, just actionable fixes.

No spam. Unsubscribe anytime.

Comments

Sign in with Google to join the conversation

Ready to ship your SaaS?
Let's make it fast.

I partner with non-technical founders to build high-performance SaaS frontends, from landing pages to full product interfaces. Fixed scope. Fixed timeline. Guaranteed PageSpeed score.