DEV Community

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

Posted on

Infinite Scrollable Image Slider with Framer Motion in Next.js

✨ 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.❤️

Top comments (0)