3D Image Carousel
A cylindrical 3D image carousel that spins continuously, responds to drag with inertia, accelerates on scroll, and plays an intro animation when it enters the viewport.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Draggable.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/InertiaPlugin.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Observer.min.js"></script>Code
<div class="img-carousel__wrap">
<div data-3d-carousel-wrap="" class="img-carousel__list">
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d87de67d8f1795c60d_Contemplative%20Portrait%20with%20Bucket%20Hat.avif" class="img-carousel__img"></div>
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d83a1a3815a26f7bd6_Mysterious%20Urban%20Portrait.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d819cbb2dbff9b2a64_Moonlit%20Rocky%20Landscape.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d8cc5908532a8fd8c6_Mysterious%20Balaclava%20Portrait.avif" class="img-carousel__img"></div>
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d8da35115879d4da77_Futuristic%20Mask%20Portrait.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d819cbb2dbff9b2a80_Serene%20Wheat%20Field%20Landscape.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d84ab87177abc40d52_Solitude%20in%20White.avif" class="img-carousel__img"></div>
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d8bcd829c22ae071e4_Mysterious%20Portrait.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d8f2996c45c7f5ec3e_Modern%20House%20on%20Hillside.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d83b775bc909a4914f_Cylindrical%20Tube%20with%20Oranges.avif" class="img-carousel__img"></div>
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d89daf4fb3b1b6dbcf_Contemplative%20Urban%20Portrait.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d8cb1cc611680f5233_White%20Bucket%20Hat%20on%20Rocky%20Surface.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d82ea2b65ee2abfc33_Urban%20Anonymity.avif" class="img-carousel__img"></div>
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d880f185d08576afa9_Futuristic%20Masked%20Individual.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d847d26653710d42aa_Regal%20Portrait%20with%20Crown.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d8d7f5ef9c36c91701_Window%20View%20of%20Vibrant%20Sky.avif" class="img-carousel__img"></div>
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d87b92b30ee718d99d_White%20Baseball%20Cap%20with%20Dried%20Plants.avif" class="img-carousel__img"></div>
</div>
<div data-3d-carousel-panel="" class="img-carousel__panel">
<div data-3d-carousel-content="" class="img-carousel__item"><img src="https://cdn.prod.website-files.com/689201b6fcd75d4e39c1bf42/689204d85f8f425e2f133bba_Urban%20Chic%20Portrait.avif" class="img-carousel__img"></div>
</div>
</div>
</div>.img-carousel__wrap {
justify-content: center;
align-items: center;
width: 100%;
min-height: 100vh;
display: flex;
}
.img-carousel__list {
z-index: 1;
perspective: 90vw;
perspective-origin: 50%;
transform-style: preserve-3d;
justify-content: center;
align-items: center;
width: 80vw;
height: 50vw;
margin-left: auto;
margin-right: auto;
font-size: 1vw;
display: flex;
position: relative;
}
.img-carousel__panel {
z-index: 0;
flex-direction: column;
flex: none;
justify-content: space-between;
align-items: stretch;
width: 13em;
height: 39em;
display: flex;
position: absolute;
}
.img-carousel__panel:nth-of-type(even) {
justify-content: center;
}
.img-carousel__item {
aspect-ratio: 1;
width: 100%;
position: relative;
overflow: hidden;
}
.img-carousel__img {
object-fit: cover;
width: 100%;
max-width: none;
height: 100%;
position: absolute;
inset: 0%;
}gsap.registerPlugin(Draggable, InertiaPlugin, Observer, ScrollTrigger);
function init3dImageCarousel() {
let radius;
let draggableInstance;
let observerInstance;
let spin;
let intro;
let lastWidth = window.innerWidth;
const wrap = document.querySelector('[data-3d-carousel-wrap]');
if (!wrap) return;
// Define the radius of your cylinder here
const calcRadius = () => {
radius = window.innerWidth * 0.5;
};
// Destroy function to reset everything on resize
const destroy = () => {
draggableInstance && draggableInstance.kill();
observerInstance && observerInstance.kill();
spin && spin.kill();
intro && intro.kill();
ScrollTrigger.getAll().forEach(st => st.kill());
const panels = wrap.querySelectorAll('[data-3d-carousel-panel]');
gsap.set(panels, { clearProps: 'transform' });
};
// Create function that sets the spin, drag, and rotation
const create = () => {
calcRadius();
const panels = wrap.querySelectorAll('[data-3d-carousel-panel]');
const content = wrap.querySelectorAll('[data-3d-carousel-content]');
const proxy = document.createElement('div');
const wrapProgress = gsap.utils.wrap(0, 1);
const dragDistance = window.innerWidth * 3; // Control the snapiness on drag
let startProg;
// Position panels in 3D space
panels.forEach(p =>
p.style.transformOrigin = `50% 50% ${-radius}px`
);
// Infinite rotation of all panels
spin = gsap.fromTo(
panels,
{ rotationY: i => (i * 360) / panels.length },
{ rotationY: '-=360', duration: 30, ease: 'none', repeat: -1 }
);
// cheeky workaround to create some 'buffer' when scrolling back up
spin.progress(1000);
draggableInstance = Draggable.create(proxy, {
trigger: wrap,
type: 'x',
inertia: true,
allowNativeTouchScrolling: true,
onPress() {
// Subtle feedback on touch/mousedown of the wrap
gsap.to(content, {
clipPath: 'inset(5%)',
duration: 0.3,
ease: 'power4.out',
overwrite: 'auto'
});
// Stop automatic spinning to prepare for drag
gsap.killTweensOf(spin);
spin.timeScale(0);
startProg = spin.progress();
},
onDrag() {
const p = startProg + (this.startX - this.x) / dragDistance;
spin.progress(wrapProgress(p));
},
onThrowUpdate() {
const p = startProg + (this.startX - this.x) / dragDistance;
spin.progress(wrapProgress(p));
},
onRelease() {
if (!this.tween || !this.tween.isActive()) {
gsap.to(spin, { timeScale: 1, duration: 0.1 });
}
gsap.to(content, {
clipPath: 'inset(0%)',
duration: 0.5,
ease: 'power4.out',
overwrite: 'auto'
});
},
onThrowComplete() {
gsap.to(spin, { timeScale: 1, duration: 0.1 });
}
})[0];
// Scroll-into-view animation
intro = gsap.timeline({
scrollTrigger: {
trigger: wrap,
start: 'top 80%',
end: 'bottom top',
scrub: false,
toggleActions: 'play resume play play'
},
defaults: { ease: 'expo.inOut' }
});
intro
.fromTo(spin, { timeScale: 15 }, { timeScale: 1, duration: 2 })
.fromTo(wrap, { scale: 0.5, rotation: 12 }, { scale: 1, rotation: 5, duration: 1.2 }, '<')
.fromTo(content, { autoAlpha: 0 }, { autoAlpha: 1, stagger: { amount: 0.8, from: 'random' } }, '<');
// While-scrolling feedback
observerInstance = Observer.create({
target: window,
type: 'wheel,scroll,touch',
onChangeY: self => {
// Control how much scroll speed affects the rotation on scroll
let v = gsap.utils.clamp(-60, 60, self.velocityY * 0.005);
spin.timeScale(v);
const resting = v < 0 ? -1 : 1;
gsap.fromTo(
{ value: v },
{ value: v },
{
value: resting,
duration: 1.2,
onUpdate() {
spin.timeScale(this.targets()[0].value);
}
}
);
}
});
};
// First create on function call
create();
// Debounce function to use on resize events
const debounce = (fn, ms) => {
let t;
return () => {
clearTimeout(t);
t = setTimeout(fn, ms);
};
};
// Whenever window resizes, first destroy, then re-init it all
window.addEventListener('resize', debounce(() => {
const newWidth = window.innerWidth;
if (newWidth !== lastWidth) {
lastWidth = newWidth;
destroy();
create();
ScrollTrigger.refresh();
}
}, 200));
}
// Initialize 3D Image Carousel
document.addEventListener("DOMContentLoaded", () => {
init3dImageCarousel();
});Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| data-3d-carousel-wrap | string | "" | Add to the main 3D carousel list element. The script targets this to query panels, content, and to attach the Draggable trigger. |
| data-3d-carousel-panel | string | "" | Add to each panel (slide) in the carousel. Panels are distributed evenly around the cylinder and each receives an individual rotationY transform and a dynamic transformOrigin based on the calculated radius. |
| data-3d-carousel-content | string | "" | Add to the inner content container inside each panel. Used for the press clip-path feedback animation and the staggered fade-in on scroll-into-view. Optional — the carousel works without it. |
Notes
- •All five GSAP plugins (Draggable, InertiaPlugin, Observer, ScrollTrigger) must be registered before calling init3dImageCarousel — the script calls gsap.registerPlugin at the top level.
- •The carousel radius is calculated in JS as window.innerWidth * 0.5. Change this multiplier in calcRadius() to adjust the depth of the cylinder.
- •The drag sensitivity is controlled by the dragDistance variable (window.innerWidth * 3). A smaller value makes the carousel spin faster on drag; a larger value makes it feel heavier.
- •The spin.progress(1000) call after creating the spin tween is a workaround to create a buffer of history, allowing the carousel to spin backwards via scroll without hitting the start of the repeat.
- •On every resize the entire carousel is destroyed and recreated — this ensures all viewport-relative measurements (radius, dragDistance) stay accurate without stale values.
- •Panels with an even nth-of-type index use justify-content: center so single-image panels are vertically centred, while odd panels justify-content: space-between to spread two images top and bottom.
- •The scroll-speed effect is controlled by multiplying velocityY by 0.005. Increase this number to make the carousel react more strongly to scroll speed; decrease it for a subtler effect.
Guide
Required structure
Wrap all panels in [data-3d-carousel-wrap]. Add [data-3d-carousel-panel] to each panel, and optionally [data-3d-carousel-content] to inner containers for the press and entrance animations. Place images or any content inside the content elements.
Carousel perspective
The CSS perspective property on .img-carousel__list controls the depth illusion. The default is 90vw — increase it to flatten the cylinder, decrease it for a more dramatic 3D effect.
Carousel size & radius
The cylinder radius is set in JS inside calcRadius(). The default is half the viewport width. Adjust the multiplier to make the carousel wider or tighter, and update the matching CSS on .img-carousel__list (width, height, font-size) to keep the visual proportions consistent.
const calcRadius = () => {
radius = window.innerWidth * 0.5;
};Drag sensitivity
dragDistance controls how far you need to drag to complete a full rotation. A smaller value (e.g. window.innerWidth * 1) is very reactive; a larger value (e.g. window.innerWidth * 6) feels slow and heavy.
const dragDistance = window.innerWidth * 3;Scroll speed effect
The Observer instance reads scroll velocity and applies it as a timeScale to the spin animation. Adjust the 0.005 multiplier to control sensitivity. Remove the observerInstance block entirely if you do not want the scroll-speed effect.
let v = gsap.utils.clamp(-60, 60, self.velocityY * 0.005);Intro animation
A GSAP Timeline triggered by ScrollTrigger speeds up the spin, scales and rotates the wrapper in, then fades in content items with a random stagger. Customise the fromTo values or replace the timeline entirely to create your own entrance effect.
Panel content
Panels can hold any content — images, videos, text cards. Alternate between panels with one and two items to create the staggered visual rhythm seen in the demo. Even-indexed panels centre their single item; odd-indexed panels spread two items top and bottom.