Infinite Scrollable Image Slider with Framer Motion in Next.js
Anik Deb Nath

Anik Deb Nath @anikdebnath

About: Working full time as a Next / React Js developer @XPONENT InfoSystem pvt Ltd. Chittagong, Bangladesh.

Location:
Bangladesh
Joined:
May 20, 2024

Infinite Scrollable Image Slider with Framer Motion in Next.js

Publish Date: Jul 4 '25
0 0

✨ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

3. 👉Configure Tailwind

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

Enter fullscreen mode Exit fullscreen mode

4. 👉globals.css (./app/globals.css)

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

7. 👉Run the Project

npm run dev
Visit: http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

grid view

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:

  1. Add drag support (drag="x") for mobile swiping
  2. Animate image hover state for interactivity
  3. Fetch image data dynamically from API
  4. 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.❤️

Comments 0 total

    Add comment