✨ Overview
In this blog, we’ll walk through how I built an infinite scrolling image slider using Framer Motion, Next.js, and Tailwind CSS. The slider is powered by scroll velocity and it supports a "Show More" grid view with pagination!
🧱 Tools & Technologies Used
- Next.js (App Router)
- React
- Framer Motion
- Tailwind CSS
- Lucide Icons
1. 👉Create a New Next.js Project
npx create-next-app@latest scroll-slider-demo
cd scroll-slider-demo
2. 👉Install Required Packages
# Framer Motion for animation
npm install framer-motion
# Tailwind CSS and its dependencies
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
# Lucide React for icons
npm install lucide-react
3. 👉Configure Tailwind
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
4. 👉globals.css (./app/globals.css)
@tailwind base;
@tailwind components;
@tailwind utilities;
5. 👉Add the Scroll Slider Component
"use client";
import React, { useState, useRef } from "react";
import Image from "next/image";
import {
motion,
useScroll,
useSpring,
useTransform,
useMotionValue,
useVelocity,
useAnimationFrame,
wrap,
} from "framer-motion";
import {
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
} from "lucide-react";
const images = [
{
id: 1,
title: "Moonbeam",
description: "Serene moonlit landscape with ethereal beauty",
thumbnail:
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=40&w=640",
},
{
id: 2,
title: "Cursor",
description: "Digital innovation meets creative design",
thumbnail:
"https://images.unsplash.com/photo-1469474968028-56623f02e42e?q=40&w=640",
},
{
id: 3,
title: "Rogue",
description: "Adventure awaits in uncharted territories",
thumbnail:
"https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?q=40&w=640",
},
{
id: 4,
title: "Editorially",
description: "Crafting stories that inspire and engage",
thumbnail:
"https://images.unsplash.com/photo-1510784722466-f2aa9c52fff6?q=80&w=640",
},
{
id: 5,
title: "Editrix AI",
description: "Artificial intelligence meets human creativity",
thumbnail:
"https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?q=80&w=640",
},
{
id: 6,
title: "Cosmic Dreams",
description: "Journey through the vast expanse of space",
thumbnail:
"https://images.unsplash.com/photo-1446776877081-d282a0f896e2?q=80&w=640",
},
{
id: 7,
title: "Urban Pulse",
description: "The heartbeat of modern city life",
thumbnail:
"https://images.unsplash.com/photo-1449824913935-59a10b8d2000?q=80&w=640",
},
{
id: 8,
title: "Natural Wonder",
description: "Discovering the beauty in untamed nature",
thumbnail:
"https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=640",
},
{
id: 9,
title: "Tech Horizon",
description: "Where technology meets tomorrow",
thumbnail:
"https://images.unsplash.com/photo-1451187580459-43490279c0fa?q=80&w=640",
},
{
id: 10,
title: "Ocean Depths",
description: "Exploring the mysteries beneath the waves",
thumbnail:
"https://images.unsplash.com/photo-1439066615861-d1af74d74000?q=80&w=640",
},
];
// ScrollVelocity Component
const ScrollVelocity = React.forwardRef(
(
{
children,
velocity = 5,
movable = true,
clamp = false,
className,
initialOffset = 0, // New prop for initial offset
...props
},
ref
) => {
const baseX = useMotionValue(initialOffset); // Use initialOffset here
const { scrollY } = useScroll();
const scrollVelocity = useVelocity(scrollY);
const smoothVelocity = useSpring(scrollVelocity, {
damping: 100,
stiffness: 20,
});
const velocityFactor = useTransform(smoothVelocity, [0, 10000], [0, 0.6], {
clamp,
});
const x = useTransform(baseX, (v) => `${wrap(-20, -50, v)}%`); // Adjusted wrap values
const directionFactor = useRef(1);
const scrollThreshold = useRef(5);
useAnimationFrame((t, delta) => {
if (movable) {
move(delta);
} else {
if (Math.abs(scrollVelocity.get()) >= scrollThreshold.current) {
move(delta);
}
}
});
function move(delta) {
let moveBy = directionFactor.current * velocity * (delta / 1000);
if (velocityFactor.get() < 0) {
directionFactor.current = -1;
} else if (velocityFactor.get() > 0) {
directionFactor.current = 1;
}
moveBy += directionFactor.current * moveBy * velocityFactor.get();
baseX.set(baseX.get() + moveBy);
}
return (
<div
ref={ref}
className={`relative m-0 flex flex-nowrap overflow-hidden whitespace-nowrap leading-[0.8] ${className}`}
{...props}
>
<motion.div
className="flex flex-row flex-nowrap whitespace-nowrap"
style={{ x }}
>
{children}
</motion.div>
</div>
);
}
);
ScrollVelocity.displayName = "ScrollVelocity";
// Image Card Component
const ImageCard = ({ image, isGrid = false }) => {
return (
<div
className={`relative cursor-pointer mr-6 ${
isGrid ? "w-full h-80" : "h-[15rem] w-[25rem] flex-shrink-0" // Fixed width of 400px (25rem = 400px, adjust as needed)
}`}
>
<Image
src={image.thumbnail}
alt={image.title}
width={400} // Fixed width
height={240} // Fixed height (adjust aspect ratio as needed)
className="w-full h-full object-cover object-center rounded-lg"
priority={isGrid}
/>
{/* Prime Badge - Top Left Corner */}
<div className="absolute top-2 left-2 z-10">
<div className="tracking-wide bg-gradient-to-r from-yellow-400 to-yellow-600 text-black px-2 py-1 rounded-md text-xs font-bold shadow-lg">
Popular
</div>
</div>
{/* Gradient Overlay - Black to White from bottom to top */}
<div className="rounded-lg absolute inset-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent" />
{/* Content - Always visible with improved spacing */}
<div
className={`absolute bottom-0 left-0 right-0 text-white ${
isGrid ? "p-4" : "p-3 md:p-4"
}`}
>
<h3
className={`font-semibold ${
isGrid
? "text-lg mb-3 leading-7 tracking-wide"
: "text-base mb-2 leading-6 tracking-wide"
}`}
>
{image.title}
</h3>
<p
className={`text-gray-300 font-normal ${
isGrid ? "text-sm tracking-wide" : "text-sm tracking-wide"
} drop-shadow-md truncate`}
>
{image.description}
</p>
</div>
</div>
);
};
// Grid Layout Component
const GridLayout = ({ images, onClose }) => {
const [currentPage, setCurrentPage] = useState(0);
const imagesPerPage = 6;
const totalPages = Math.ceil(images.length / imagesPerPage);
const getCurrentImages = () => {
const start = currentPage * imagesPerPage;
const end = start + imagesPerPage;
return images.slice(start, end);
};
const goToNext = () => {
setCurrentPage((prev) => (prev + 1) % totalPages);
};
const goToPrevious = () => {
setCurrentPage((prev) => (prev - 1 + totalPages) % totalPages);
};
return (
<motion.div
className="w-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5 }}
>
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-800">All Projects</h2>
<button
onClick={onClose}
className="flex items-center gap-2 px-5 py-2 rounded-full text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all shadow-md"
>
<ChevronUp className="w-4 h-4" />
Show Less
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{getCurrentImages().map((image) => (
<ImageCard key={image.id} image={image} isGrid={true} />
))}
</div>
{/* Navigation Controls - Below the grid */}
<div className="flex justify-center items-center flex-wrap gap-4 mt-8">
{/* Pagination buttons */}
<div className="flex items-center gap-4 flex-wrap">
<button
onClick={goToPrevious}
disabled={totalPages <= 1}
className="flex items-center gap-2 px-5 py-2 rounded-full text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
Previous
</button>
<span className="text-sm font-medium text-gray-700">
Page {currentPage + 1} of {totalPages}
</span>
<button
onClick={goToNext}
disabled={totalPages <= 1}
className="flex items-center gap-2 px-5 py-2 rounded-full text-white bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
);
};
// Main Component
const EnhancedScrollVelocityDemo = () => {
const [showGrid, setShowGrid] = useState(false);
const velocity = [0.2, -0.2];
if (showGrid) {
return (
<div className="w-full p-6">
<GridLayout images={images} onClose={() => setShowGrid(false)} />
</div>
);
}
return (
<div className="w-full">
<div className="flex flex-col space-y-10 py-10">
{velocity.map((v, index) => (
<ScrollVelocity
key={index}
velocity={v}
initialOffset={index % 2 === 0 ? -10 : 10} // Alternate initial offsets
>
{images.map((image) => (
<ImageCard key={image.id} image={image} />
))}
{/* Duplicate images to create infinite scroll effect */}
{images.map((image) => (
<ImageCard key={`dup-${image.id}`} image={image} />
))}
</ScrollVelocity>
))}
</div>
{/* Show More Button */}
<div className="flex justify-center">
<motion.button
onClick={() => setShowGrid(true)}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-full hover:from-blue-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<span className="font-semibold">Show More</span>
<ChevronDown className="w-5 h-5" />
</motion.button>
</div>
</div>
);
};
export default EnhancedScrollVelocityDemo;
6. 👉Use It in a Page or Other where you need
import EnhancedScrollVelocityDemo from "./components/EnhancedScrollVelocityDemo";
export default function Home() {
return (
<main className="min-h-screen bg-white">
<EnhancedScrollVelocityDemo />
</main>
);
}
7. 👉Run the Project
npm run dev
Visit: http://localhost:3000
You should see your animated scrollable slider with a “Show More” grid view. 🚀
Tips & Enhancements
Here are a few ways you can further enhance the experience:
- Add drag support (drag="x") for mobile swiping
- Animate image hover state for interactivity
- Fetch image data dynamically from API
- Add a modal/lightbox on card click
Final Thoughts
This project was a fun experiment combining Framer Motion’s powerful animation utilities with clean, scalable UI design. It showcases how scroll velocity can create visually stunning effects with minimal code.
If you're building a portfolio, product showcase, or creative gallery — this layout can provide a delightful user experience that stands out.❤️
Top comments (0)