In this tutorial, we’ll be looking at how to set up this panoramic, curved 3D slider in Bricks builder. This was based on a request from a member of the Bricks Builder Community. After a few hours of research and vibe-coding, I found a way to make it work

It not only plays nicely with the other elements in the section but is also fully responsive from mobile to ultrawide screens. 

For this to be possible, we will need GSAP, JavaScript and CSS. 

I’ve also taken additional steps to make the code very easy to customize so you can adapt it to your requirements.

*Please note that some details of this tutorial may vary slightly from the video due to updates to the code or general process optimizations beyond the date of recording.

1. Import GSAP

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.6.1/gsap.min.js"></script>

2. Setup your HTML structure

Prepare your HTML structure and assign the corresponding CSS classes as follows:

  • <div> .ls-curved-carousel
    • <div> .stage
      • <div> .ring
        • <div> .slide
          • <img> .media
  • Code Element

3. Add the code snippets

IAdd a code element and clear all unnecessaries. Then paste the following snippets in the areas provided for CSS and JavaScript.

3D Curved Slider CSS
.ls-curved-carousel {
  --viewport-height: 40rem;
  --viewport-height-m: 35rem;
  --perspective: 600px;
  --perspective-m: 400px;
  --block-offset: 0;
  --block-offset-m: 0;
  /*You can add a fadeout effect by adding a class named "fadeout" to ls-curved-carousel*/
}

.ls-curved-carousel.fadeout {
  --fadeout: linear-gradient(90deg, transparent, white 20%, white 80%, transparent 100%);
}

.ls-curved-carousel {
  --fadeout: none;
}

body.bricks-is-frontend .ls-curved-carousel {
  position: relative;
  width: 100%;
  height: var(--viewport-height);
  transform-style: preserve-3d;
  user-select: none;
  overflow: visible;
  z-index: 1;
  margin-block: var(--block-offset);
}

body.bricks-is-frontend .ls-curved-carousel__stage {
  perspective: var(--perspective);
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

.ls-curved-carousel {
  -webkit-mask-image: var(--fadeout);
  mask-image: var(--fadeout);
}

@media (max-width:767px) {
  body.bricks-is-frontend .ls-curved-carousel {
    height: var(--viewport-height-m);
    margin-block: var(--block-offset-m);
  }
  
  body.bricks-is-frontend .ls-curved-carousel__stage {
    perspective: var(--perspective-m);
  }
}

body.bricks-is-frontend .ls-curved-carousel__ring {
  position: absolute;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  gap:0;
}

body:not(.bricks-is-frontend) .ls-curved-carousel__ring {
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
}

body.bricks-is-frontend .ls-curved-carousel__slide {
  position: absolute;
  /* Width and height will be set by JavaScript */
  transform-style: preserve-3d;
  overflow: hidden;
}

body.bricks-is-frontend .ls-curved-carousel__media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 24px;
}

body.bricks-is-frontend .ls-curved-carousel:after {
  content: none;
}
3D Curved Slider JavaScript
/**************************************
 * 3D CURVED CAROUSEL CONFIGURATION
 **************************************/
const carouselConfig = {
  // User-specified parameters
  slideHeight: 600,         // Height of each slide in pixels
  slidesInRing: 21,         // Number of slides visible in the ring
  slideSpacing: 1,          // Spacing between slides in degrees
  
  // Visual settings
  radius: 1200,             // Radius of the carousel circle (px)
  initialRotation: 180,     // Initial rotation of the carousel (degrees)
  
  // Animation settings
  autoRotate: true,         // Set to false to disable auto-rotation
  rotationSpeed: 0.1,       // Target speed of rotation (degrees per frame)
  rotationDirection: 1,     // 1 for clockwise, -1 for counter-clockwise
  
  // Interaction settings
  pauseOnHover: true,       // Pause rotation when hovering
  resumeDelay: 0,        // Delay before resuming rotation after hover (milliseconds)
  pauseEaseDuration: 0.5,   // Duration for easing in/out of pause (seconds)
  
  // Entrance animation settings
  entranceAnimation: 'fadeIn',  // 'fadeIn', 'fadeUp', or 'none'
  entranceDuration: 1.5,        // Duration of entrance animation (seconds)
  entranceStagger: 0.1,         // Stagger time between slide animations (seconds)
  entranceDistance: 100         // Distance for fadeUp animation (pixels)
};

document.addEventListener('DOMContentLoaded', function() {
  // Initialize the carousel
  initCarousel();
});

function initCarousel() {
  const ring = document.querySelector('.ls-curved-carousel__ring');
  const originalSlides = document.querySelectorAll('.ls-curved-carousel__slide');
  const originalSlideCount = originalSlides.length;
  
  // Check if we need to duplicate slides
  if (originalSlideCount < carouselConfig.slidesInRing) {
    duplicateSlides(originalSlides, carouselConfig.slidesInRing - originalSlideCount);
  }
  
  // Get the updated slides collection after potential duplication
  const slides = document.querySelectorAll('.ls-curved-carousel__slide');
  const slideCount = slides.length;
  
  // Calculate angle per slide for even distribution
  const anglePerSlide = 360 / carouselConfig.slidesInRing;
  
  // Calculate ideal slide width based on arc length
  const arcLength = (anglePerSlide - carouselConfig.slideSpacing) * (Math.PI / 180) * carouselConfig.radius;
  const slideWidth = arcLength;
  
  console.log(`Original slides: ${originalSlideCount}, Total slides: ${slideCount}`);
  console.log(`Angle per slide: ${anglePerSlide}°, Calculated width: ${slideWidth}px`);
  
  // Set container dimensions
  const container = document.querySelector('.ls-curved-carousel__stage');
  container.style.width = `${slideWidth}px`;
  container.style.height = `${carouselConfig.slideHeight}px`;
  
  // Set up autorotation variables
  let autoRotate = carouselConfig.autoRotate;
  let targetRotationSpeed = carouselConfig.rotationSpeed; // Target speed
  let currentRotationSpeed = 0; // Current speed, managed by GSAP tween
  let rotationDirection = carouselConfig.rotationDirection;
  let autoRotateTimeout;
  let lastUpdateTime = Date.now();
  let updateRotationFunction;
  let speedTween; // Store the GSAP tween for speed control

  // Proxy object for tweening the speed
  let speedController = { value: 0 };

  // Initialize the carousel timeline
  const timeline = gsap.timeline();
  
  // Set initial rotation
  timeline.set(ring, { rotationY: carouselConfig.initialRotation });
  
  // Apply calculated dimensions and position all slides in a circle
  slides.forEach((slide, index) => {
    // Set slide dimensions
    slide.style.width = `${slideWidth}px`;
    slide.style.height = `${carouselConfig.slideHeight}px`;
    
    // Position slide in 3D space - evenly distribute around the circle
    gsap.set(slide, {
      rotateY: index * -anglePerSlide,
      transformOrigin: `50% 50% ${carouselConfig.radius}px`,
      z: -carouselConfig.radius,
      backfaceVisibility: 'hidden'
    });
  });
  
  // Add entrance animation if enabled
  if (carouselConfig.entranceAnimation !== 'none') {
    let entranceAnimation = {};
    
    switch (carouselConfig.entranceAnimation) {
      case 'fadeIn':
        entranceAnimation = {
          opacity: 0,
          duration: carouselConfig.entranceDuration,
          stagger: carouselConfig.entranceStagger,
          ease: 'power2.out'
        };
        break;
        
      case 'fadeUp':
        entranceAnimation = {
          y: carouselConfig.entranceDistance,
          opacity: 0,
          duration: carouselConfig.entranceDuration,
          stagger: carouselConfig.entranceStagger,
          ease: 'power3.out'
        };
        break;
    }
    
    timeline.from('.ls-curved-carousel__slide', entranceAnimation)
      .add(() => {
        startAutoRotation(); // Start rotation after entrance animation
      });
  } else {
    // Start auto-rotation immediately if no entrance animation
    startAutoRotation();
  }

  // Function to update rotation
  function updateRotation() {
    // Use the current speed from the tweened proxy object
    currentRotationSpeed = speedController.value;
    if (currentRotationSpeed === 0) return; // If speed is 0, no need to update
    
    const currentTime = Date.now();
    const deltaTime = currentTime - lastUpdateTime;
    lastUpdateTime = currentTime;
    
    // Calculate rotation based on time passed, current speed, and direction
    const rotationAmount = (deltaTime / 16.67) * currentRotationSpeed * rotationDirection;
    
    gsap.to(ring, {
      rotationY: `+=${rotationAmount}`,
      duration: 0,
      overwrite: true // Ensure this tween doesn't interfere with speed tween
    });
  }
  
  function startAutoRotation() {
    if (!autoRotate) return; // Only start if autoRotate is globally enabled
    
    lastUpdateTime = Date.now();
    updateRotationFunction = updateRotation;
    gsap.ticker.add(updateRotationFunction);

    // Kill any existing speed tween before starting a new one
    if (speedTween) {
      speedTween.kill();
    }
    // Ease the speed up to the target speed
    speedTween = gsap.to(speedController, {
      value: targetRotationSpeed,
      duration: carouselConfig.pauseEaseDuration,
      ease: 'power1.out'
    });
  }

  function stopAutoRotation() {
    // Kill any existing speed tween before starting a new one
    if (speedTween) {
      speedTween.kill();
    }
    // Ease the speed down to 0
    speedTween = gsap.to(speedController, {
      value: 0,
      duration: carouselConfig.pauseEaseDuration,
      ease: 'power1.in',
      onComplete: () => {
        // Remove ticker only after speed reaches 0
        if (updateRotationFunction) {
          gsap.ticker.remove(updateRotationFunction);
          updateRotationFunction = null; // Clear the reference
        }
      }
    });
  }
  
  function resumeAutoRotation() {
    // Clear any pending resume timeout
    if (autoRotateTimeout) clearTimeout(autoRotateTimeout);
    
    // Set a timeout to restart the rotation after the delay
    autoRotateTimeout = setTimeout(() => {
      if (carouselConfig.autoRotate) { // Check again if autoRotate is still desired
         startAutoRotation(); // This will handle easing the speed up
      }
    }, carouselConfig.resumeDelay);
  }
  
  // Add pause/resume functionality if enabled
  if (carouselConfig.pauseOnHover) {
    const carouselElement = document.querySelector('.ls-curved-carousel');
    
    carouselElement.addEventListener('mouseenter', () => {
      // Clear any pending resume timeout when entering
      if (autoRotateTimeout) clearTimeout(autoRotateTimeout);
      stopAutoRotation(); // Ease speed down
    });
    
    carouselElement.addEventListener('mouseleave', () => {
      resumeAutoRotation(); // Start the resume process (with delay)
    });
    
    // Also handle touch events for mobile
    carouselElement.addEventListener('touchstart', () => {
       // Clear any pending resume timeout when touching
      if (autoRotateTimeout) clearTimeout(autoRotateTimeout);
      stopAutoRotation(); // Ease speed down
    }, { passive: true }); // Use passive listener for performance
    
    carouselElement.addEventListener('touchend', () => {
      resumeAutoRotation(); // Start the resume process (with delay)
    });
  }
}

function duplicateSlides(originalSlides, count) {
  const ring = document.querySelector('.ls-curved-carousel__ring');
  const originalCount = originalSlides.length;
  
  // Clone slides until we have enough
  for (let i = 0; i < count; i++) {
    const clone = originalSlides[i % originalCount].cloneNode(true);
    ring.appendChild(clone);
  }
}

Update Log

[April 22, 2025] – Updated the code so that pauseOnHover eases in instead of being so abrupt. Also includes a new configuration setting: pauseEaseDuration: 0.5 (Thanks for the suggestion Fabian)

Curved Panoramic 3D Carousel Slider (Ready-to-use Template + Draggable version)

Get the Template

Leave the first comment