Back to library
carousels
Depth Carousel
GSAP-powered 3D depth carousel with drag, autoplay, blur falloff, tilt, controls, and indicators.
Inspired by React Bits ยท View source
Preview
Copy for AI
Copy for Lovable is the fastest way to move this component into another Lovable project โ it copies the full prompt with dependencies, files, and a usage example. Copy all files gives you the raw source if you want to paste it manually.
Installation
bun add gsapStep 1: install dependencies. Step 2: copy the source files below into your project.
Source files
components/ui/depth-carousel.tsx
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
PointerEvent as ReactPointerEvent,
KeyboardEvent as ReactKeyboardEvent
} from 'react';
import gsap from 'gsap';
export type DepthCarouselItem = string | { image: string; alt?: string };
type TiltDirection = 'left' | 'right';
export interface DepthCarouselProps {
items?: DepthCarouselItem[];
cardWidth?: number;
cardHeight?: number;
radius?: number;
tint?: string;
depth?: number;
spread?: number;
tilt?: number;
tiltDirection?: TiltDirection;
perspective?: number;
visibleCards?: number;
falloff?: number;
blur?: number;
duration?: number;
ease?: string;
autoplay?: boolean;
autoplayDelay?: number;
loop?: boolean;
showControls?: boolean;
showIndicators?: boolean;
onChange?: (index: number, item: { image: string; alt?: string }) => void;
className?: string;
}
interface CarouselConfig {
count: number;
depth: number;
spread: number;
tilt: number;
tiltDirection: TiltDirection;
visibleCards: number;
falloff: number;
blur: number;
duration: number;
ease: string;
loop: boolean;
cardWidth: number;
autoplayDelay: number;
}
interface DragState {
x: number;
startPos: number;
lastX: number;
lastT: number;
v: number;
moved: boolean;
id: number;
}
const DEFAULT_ITEMS: DepthCarouselItem[] = [
{ image: 'https://picsum.photos/seed/depth1/800/1000', alt: 'Slide 1' },
{ image: 'https://picsum.photos/seed/depth2/800/1000', alt: 'Slide 2' },
{ image: 'https://picsum.photos/seed/depth3/800/1000', alt: 'Slide 3' },
{ image: 'https://picsum.photos/seed/depth4/800/1000', alt: 'Slide 4' },
{ image: 'https://picsum.photos/seed/depth5/800/1000', alt: 'Slide 5' },
{ image: 'https://picsum.photos/seed/depth6/800/1000', alt: 'Slide 6' }
];
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
const normalizeItem = (it: DepthCarouselItem) => (typeof it === 'string' ? { image: it, alt: '' } : it);
const DepthCarousel = ({
items = DEFAULT_ITEMS,
cardWidth = 300,
cardHeight = 380,
radius = 18,
tint = '#05060a',
depth = 220,
spread = 90,
tilt = 22,
tiltDirection = 'right',
perspective = 1400,
visibleCards = 4,
falloff = 0.2,
blur = 6,
duration = 700,
ease = 'power3.out',
autoplay = false,
autoplayDelay = 3200,
loop = true,
showControls = true,
showIndicators = true,
onChange,
className = ''
}: DepthCarouselProps) => {
const data = useMemo(() => (Array.isArray(items) ? items : []).map(normalizeItem), [items]);
const count = data.length;
const rootRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const overlayRefs = useRef<(HTMLSpanElement | null)[]>([]);
const posRef = useRef(0);
const focusRef = useRef(0);
const tweenRef = useRef<gsap.core.Tween | null>(null);
const scaleRef = useRef(1);
const cfgRef = useRef<CarouselConfig>({} as CarouselConfig);
const onChangeRef = useRef(onChange);
const dragRef = useRef<DragState | null>(null);
const wheelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const reducedRef = useRef(false);
const [active, setActive] = useState(0);
onChangeRef.current = onChange;
cfgRef.current = {
count,
depth,
spread,
tilt,
tiltDirection,
visibleCards,
falloff,
blur,
duration,
ease,
loop,
cardWidth,
autoplayDelay
};
const layout = useCallback((pos: number) => {
const cfg = cfgRef.current;
const n = cfg.count;
if (!n) return;
const dir = cfg.tiltDirection === 'left' ? -1 : 1;
const sc = scaleRef.current;
for (let i = 0; i < n; i++) {
const el = cardRefs.current[i];
if (!el) continue;
let d = i - pos;
if (cfg.loop && n > 1) {
d = ((d % n) + n) % n;
if (d > n / 2) d -= n;
}
const back = Math.max(0, d);
const az = Math.abs(d);
const shown = az <= cfg.visibleCards + 0.5;
const tz = -cfg.depth * d;
const tx = dir * cfg.spread * d;
const ry = dir * cfg.tilt * clamp(d, 0, 1);
let opacity = d < 0 ? Math.max(0, 1 + d) : 1;
if (!shown) opacity = 0;
const brightness = Math.max(0.15, 1 - back * cfg.falloff);
const blurPx = cfg.blur > 0 ? Math.min(cfg.blur, (back / Math.max(1, cfg.visibleCards)) * cfg.blur) : 0;
const zi = Math.round(2000 - d * 20);
el.style.transform = `translate(-50%, -50%) scale(${sc}) translateX(${tx.toFixed(2)}px) translateZ(${tz.toFixed(2)}px) rotateY(${ry.toFixed(3)}deg)`;
el.style.opacity = opacity.toFixed(3);
el.style.filter = `brightness(${brightness.toFixed(3)}) blur(${blurPx.toFixed(2)}px)`;
el.style.zIndex = String(zi);
el.style.pointerEvents = shown && opacity > 0.05 ? 'auto' : 'none';
const ov = overlayRefs.current[i];
if (ov) ov.style.opacity = clamp(back * cfg.falloff * 1.25, 0, 0.86).toFixed(3);
}
}, []);
const notify = useCallback(
(idx: number) => {
setActive(idx);
onChangeRef.current?.(idx, data[idx]);
},
[data]
);
const tweenTo = useCallback(
(target: number, animate: boolean) => {
tweenRef.current?.kill();
const cfg = cfgRef.current;
const proxy = { p: posRef.current };
const dur = animate && !reducedRef.current ? cfg.duration / 1000 : 0;
tweenRef.current = gsap.to(proxy, {
p: target,
duration: dur,
ease: cfg.ease,
onUpdate: () => {
posRef.current = proxy.p;
layout(proxy.p);
},
onComplete: () => {
const n = cfg.count;
if (n > 0) posRef.current = ((posRef.current % n) + n) % n;
layout(posRef.current);
}
});
},
[layout]
);
const setFocus = useCallback(
(rawIndex: number, animate = true) => {
const cfg = cfgRef.current;
const n = cfg.count;
if (!n) return;
const idx = cfg.loop ? ((rawIndex % n) + n) % n : clamp(rawIndex, 0, n - 1);
let delta = idx - posRef.current;
if (cfg.loop && n > 1) {
delta = ((delta % n) + n) % n;
if (delta > n / 2) delta -= n;
}
tweenTo(posRef.current + delta, animate);
if (idx !== focusRef.current) {
focusRef.current = idx;
notify(idx);
}
},
[tweenTo, notify]
);
const navigateBy = useCallback((step: number) => setFocus(focusRef.current + step, true), [setFocus]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const ro = new ResizeObserver(entries => {
const w = entries[0].contentRect.width;
const cfg = cfgRef.current;
const needed = cfg.cardWidth + Math.abs(cfg.spread) * 2 + 120;
scaleRef.current = clamp(w / needed, 0.4, 1);
layout(posRef.current);
});
ro.observe(root);
return () => ro.disconnect();
}, [layout]);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
const cfg = cfgRef.current;
if (cfg.count < 2) return;
e.preventDefault();
tweenRef.current?.kill();
const raw = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
const delta = e.deltaMode === 1 ? raw * 24 : raw;
const step = clamp(delta / (cfg.cardWidth * 0.9), -0.6, 0.6);
posRef.current += step;
layout(posRef.current);
if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);
wheelTimerRef.current = setTimeout(() => setFocus(Math.round(posRef.current), true), 130);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('wheel', onWheel);
if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);
};
}, [layout, setFocus]);
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
const cfg = cfgRef.current;
if (cfg.count < 2) return;
tweenRef.current?.kill();
dragRef.current = {
x: e.clientX,
startPos: posRef.current,
lastX: e.clientX,
lastT: performance.now(),
v: 0,
moved: false,
id: e.pointerId
};
}, []);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
const cfg = cfgRef.current;
const stepPx = Math.max(cfg.cardWidth * 0.55 * scaleRef.current, 40);
const dx = e.clientX - drag.x;
if (!drag.moved && Math.abs(dx) > 4) {
drag.moved = true;
rootRef.current?.setPointerCapture(drag.id);
}
if (!drag.moved) return;
const now = performance.now();
const dt = Math.max(now - drag.lastT, 1);
drag.v = (e.clientX - drag.lastX) / dt;
drag.lastX = e.clientX;
drag.lastT = now;
posRef.current = drag.startPos - dx / stepPx;
layout(posRef.current);
},
[layout]
);
const onPointerEnd = useCallback(() => {
const drag = dragRef.current;
if (!drag) return;
dragRef.current = null;
if (!drag.moved) return;
const cfg = cfgRef.current;
const stepPx = Math.max(cfg.cardWidth * 0.55 * scaleRef.current, 40);
const projected = posRef.current - (drag.v * 180) / stepPx;
setFocus(Math.round(projected), true);
}, [setFocus]);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
navigateBy(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
navigateBy(1);
}
},
[navigateBy]
);
const onCardClick = useCallback(
(index: number) => {
if (dragRef.current?.moved) return;
setFocus(index, true);
},
[setFocus]
);
useEffect(() => {
reducedRef.current = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!autoplay || reducedRef.current || count < 2) return;
const root = rootRef.current;
let hovered = false;
let focused = false;
const stop = () => {
if (autoTimerRef.current) clearInterval(autoTimerRef.current);
autoTimerRef.current = null;
};
const start = () => {
stop();
autoTimerRef.current = setInterval(
() => {
if (!hovered && !focused) navigateBy(1);
},
Math.max(cfgRef.current.autoplayDelay, 600)
);
};
const onEnter = () => {
hovered = true;
};
const onLeave = () => {
hovered = false;
};
const onFocusIn = () => {
focused = true;
};
const onFocusOut = () => {
focused = false;
};
root?.addEventListener('mouseenter', onEnter);
root?.addEventListener('mouseleave', onLeave);
root?.addEventListener('focusin', onFocusIn);
root?.addEventListener('focusout', onFocusOut);
start();
return () => {
stop();
root?.removeEventListener('mouseenter', onEnter);
root?.removeEventListener('mouseleave', onLeave);
root?.removeEventListener('focusin', onFocusIn);
root?.removeEventListener('focusout', onFocusOut);
};
}, [autoplay, autoplayDelay, count, navigateBy]);
useEffect(() => {
layout(posRef.current);
}, [layout, depth, spread, tilt, tiltDirection, visibleCards, falloff, blur, cardWidth, cardHeight, radius, count]);
useEffect(
() => () => {
tweenRef.current?.kill();
if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);
if (autoTimerRef.current) clearInterval(autoTimerRef.current);
},
[]
);
return (
<div
ref={rootRef}
className={`relative flex h-full min-h-[320px] w-full cursor-grab touch-pan-y select-none items-center justify-center outline-none [perspective-origin:50%_50%] active:cursor-grabbing focus-visible:rounded-xl focus-visible:outline-2 focus-visible:outline-white/50 focus-visible:[outline-offset:4px] ${className}`.trim()}
style={{ perspective: `${perspective}px` }}
role="group"
aria-roledescription="carousel"
aria-label="Depth carousel"
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onKeyDown={onKeyDown}
>
<div className="absolute inset-0 [transform-style:preserve-3d]" ref={stageRef}>
{data.map((item, i) => (
<div
key={i}
className="absolute left-1/2 top-1/2 cursor-pointer overflow-hidden bg-[#0b0d12] shadow-[0_30px_60px_-20px_rgba(0,0,0,0.65),0_8px_20px_-10px_rgba(0,0,0,0.5)] [transform:translate(-50%,-50%)] [transform-origin:center] [will-change:transform,opacity,filter]"
ref={el => {
cardRefs.current[i] = el;
}}
style={{ width: cardWidth, height: cardHeight, borderRadius: radius }}
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}`}
aria-hidden={active !== i}
onClick={() => onCardClick(i)}
>
<img
className="block h-full w-full select-none object-cover [pointer-events:none] [-webkit-user-drag:none]"
src={item.image}
alt={item.alt || ''}
draggable={false}
/>
<span
className="pointer-events-none absolute inset-0 opacity-0 mix-blend-multiply"
ref={el => {
overlayRefs.current[i] = el;
}}
style={{ background: tint }}
/>
</div>
))}
</div>
{showControls && count > 1 && (
<>
<button
type="button"
className="absolute left-4 top-1/2 z-[3000] grid h-[42px] w-[42px] -translate-y-1/2 place-items-center rounded-full border border-white/20 bg-[rgba(18,20,26,0.55)] text-white backdrop-blur-md transition-[background,border-color,transform] duration-200 hover:border-white/40 hover:bg-[rgba(28,31,40,0.85)] active:scale-95"
aria-label="Previous slide"
onClick={() => navigateBy(-1)}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M15 5l-7 7 7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
className="absolute right-4 top-1/2 z-[3000] grid h-[42px] w-[42px] -translate-y-1/2 place-items-center rounded-full border border-white/20 bg-[rgba(18,20,26,0.55)] text-white backdrop-blur-md transition-[background,border-color,transform] duration-200 hover:border-white/40 hover:bg-[rgba(28,31,40,0.85)] active:scale-95"
aria-label="Next slide"
onClick={() => navigateBy(1)}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
d="M9 5l7 7-7 7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</>
)}
{showIndicators && count > 1 && (
<div
className="absolute bottom-4 left-1/2 z-[3000] flex -translate-x-1/2 gap-2 rounded-full bg-[rgba(14,16,22,0.4)] px-3 py-2 backdrop-blur-sm"
role="tablist"
aria-label="Slides"
>
{data.map((_, i) => (
<button
key={i}
type="button"
role="tab"
aria-selected={active === i}
aria-label={`Go to slide ${i + 1}`}
className={`h-[7px] cursor-pointer rounded-full transition-[width,background] duration-[250ms] ${
active === i ? 'w-5 bg-white' : 'w-[7px] bg-white/30'
}`}
onClick={() => setFocus(i, true)}
/>
))}
</div>
)}
</div>
);
};
export default DepthCarousel;
Dependencies
gsap