Docs
Draggable Carousel
Draggable Carousel
The DraggableCarousel component is a GSAP-powered animated carousel which has inertia.
Mountain Retreat
A peaceful mountain getaway surrounded by nature
Ocean Vista
Breathtaking views of the endless ocean horizon
Urban Adventure
Explore the vibrant city life and culture
Forest Escape
Get lost in the tranquility of ancient forests
Desert Oasis
Experience the mystique of endless sand dunes
Mountain Retreat
A peaceful mountain getaway surrounded by nature
Ocean Vista
Breathtaking views of the endless ocean horizon
Urban Adventure
Explore the vibrant city life and culture
Forest Escape
Get lost in the tranquility of ancient forests
Desert Oasis
Experience the mystique of endless sand dunes
Install the following dependencies:
pnpm add gsap @gsap/react
Make a file for cn and mergerRef functions and match the import afterwards
import clsx, { ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes))
Make a file and copy paste this code in a file with name
draggable-carousel.tsx
"use client"
import { useRef, useEffect } from "react"
import gsap from "gsap"
import { Draggable } from "gsap/Draggable"
import { useGSAP } from "@gsap/react"
export interface CarouselItem {
title: string
description: string
imageUrl: string
}
gsap.registerPlugin(Draggable)
interface DraggableCarouselProps {
items: CarouselItem[]
/**
* keep this number between 0-1
*/
friction?: number
cardWidth?: string
}
export function DraggableCarousel({
items,
friction = 0.92,
cardWidth,
}: DraggableCarouselProps) {
const carouselRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const draggableInstanceRef = useRef<Draggable | null>(null)
// Tracking for custom inertia
const lastPositionsRef = useRef<Array<{ time: number; x: number }>>([])
const isThrowingRef = useRef(false)
const animationFrameRef = useRef<number | null>(null)
useGSAP(() => {
if (!carouselRef.current || !wrapperRef.current) return
const carousel = carouselRef.current
const wrapper = wrapperRef.current
// Calculate the total width of all cards
const totalWidth = wrapper.scrollWidth
const carouselWidth = carousel.offsetWidth
const maxX = -(totalWidth - carouselWidth)
// Clear position tracking array
lastPositionsRef.current = []
// Create draggable instance
const draggable = Draggable.create(wrapper, {
type: "x",
bounds: {
minX: maxX,
maxX: 0,
},
// Disable GSAP's built-in inertia
inertia: false,
dragResistance: 0,
edgeResistance: 0.9,
onDragStart: function () {
// Clear any ongoing inertia animation
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
isThrowingRef.current = false
lastPositionsRef.current = []
},
onDrag: function () {
// Track position and time for velocity calculation
const currentX = this.x
lastPositionsRef.current.push({
time: Date.now(),
x: currentX,
})
// Keep only the last 10 positions for accurate velocity calculation
if (lastPositionsRef.current.length > 10) {
lastPositionsRef.current.shift()
}
},
onDragEnd: function () {
// Calculate velocity from tracked positions
const positions = lastPositionsRef.current
if (positions.length < 2) return
const newest = positions[positions.length - 1]
const oldest = positions[Math.max(0, positions.length - 6)] // Use ~5 positions for smoother velocity
const timeDiff = newest.time - oldest.time
if (timeDiff === 0) return
// Calculate pixels per millisecond, then convert to pixels per frame
const velocity = ((newest.x - oldest.x) / timeDiff) * 16.67 // ~60fps
// Only apply inertia if there's significant velocity
if (Math.abs(velocity) > 0.5) {
applyInertia(velocity, this.x, maxX)
}
},
})[0]
draggableInstanceRef.current = draggable
// Custom inertia implementation
const applyInertia = (
initialVelocity: number,
startX: number,
minBound: number
) => {
let velocity = initialVelocity
let currentX = startX
// const friction =friction; // Adjust for more or less "slide" - lower = faster slowdown
isThrowingRef.current = true
const animate = () => {
if (Math.abs(velocity) < 0.5 || !isThrowingRef.current) {
// Stop animation when velocity becomes very small
isThrowingRef.current = false
animationFrameRef.current = null
// Snap back if out of bounds
if (currentX > 0) {
gsap.to(wrapper, { x: 0, duration: 0.3, ease: "power2.out" })
} else if (currentX < minBound) {
gsap.to(wrapper, { x: minBound, duration: 0.3, ease: "power2.out" })
}
return
}
// Apply friction to slow down
velocity *= friction
// Update position
currentX += velocity
// Handle bouncing off boundaries
if (currentX > 0) {
// Bouncing off the start
velocity *= -0.5
currentX = 0
} else if (currentX < minBound) {
// Bouncing off the end
velocity *= -0.5
currentX = minBound
}
// Apply the new position
if (wrapper) {
gsap.set(wrapper, { x: currentX })
// Allow Draggable to update its internal state
if (draggableInstanceRef.current) {
draggableInstanceRef.current.update()
}
}
// Continue animation
animationFrameRef.current = requestAnimationFrame(animate)
}
// Start animation
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
animationFrameRef.current = requestAnimationFrame(animate)
}
// Cleanup function
return () => {
// Kill the draggable instance
if (draggableInstanceRef.current) {
draggableInstanceRef.current.kill()
draggableInstanceRef.current = null
}
// Cancel any ongoing animation
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}
})
// Allow interrupting the throw animation when user interacts
useEffect(() => {
const handleInterrupt = () => {
if (isThrowingRef.current) {
isThrowingRef.current = false
}
}
document.addEventListener("mousedown", handleInterrupt)
document.addEventListener("touchstart", handleInterrupt)
return () => {
document.removeEventListener("mousedown", handleInterrupt)
document.removeEventListener("touchstart", handleInterrupt)
}
}, [])
return (
<div
ref={carouselRef}
className="w-full overflow-hidden cursor-grab active:cursor-grabbing"
>
<div ref={wrapperRef} className="flex gap-6 p-4">
{items.map((item, index) => (
<div
key={index}
style={{ width: cardWidth ?? "300px" }}
className="flex-shrink-0 w-[300px] bg-white dark:bg-black rounded-xl border shadow-lg overflow-hidden transform transition-transform hover:scale-[1.02]"
>
<div className="p-4">
<h3 className="text-base font-semibold mb-2 text-gray-800 dark:text-white">
{item.title}
</h3>
<p className="text-foregound text-sm">{item.description}</p>
</div>
<div className="rounded-xl w-[90%] h-48 overflow-hidden my-3 mx-auto">
<img
src={item.imageUrl}
alt={item.title}
className="w-full h-48 object-cover"
/>
</div>
</div>
))}
</div>
</div>
)
}
Props
Name | Type | Description |
---|---|---|
items * | Arrav<{title,description,imageUrl}> | Array of card in carousel |
friction | number | The value should be between 0-1, 1 for max smoothness |
cardWidth | string | Width of each card in string format example:- 300px |
Note: Props marked with
*
are required.