Scroll Stack Cards

It is a refined, minimal UI component built for Next.js and Framer Motion.It uses lenis for smooth scroll behaviour.

Fight Club

The first rule of Fight Club is: You do not talk about Fight Club. The second rule of Fight Club is: You DO NOT talk about Fight Club.

The Boys

When you’re famous, people let you get away with murder. Homelander doesn’t need permission — he needs applause.

The Dark Knight

Why so serious? You either die a hero, or you live long enough to see yourself become the villain.

Inception

You mustn’t be afraid to dream a little bigger, darling. An idea is like a virus — resilient, highly contagious.

Interstellar

We used to look up at the sky and wonder at our place in the stars. Now we just look down and worry about our place in the dirt.

Dependencies

npm installframer-motionlenis

Code

"use client";
import React, { useEffect, useRef } from "react";
import { motion, MotionValue, useScroll, useTransform } from "framer-motion";
import Lenis from "lenis";
import { cn } from "@/lib/utils";

export const projects = [
  {
    title: "Fight Club",
    description:
      "The first rule of Fight Club is: You do not talk about Fight Club. The second rule of Fight Club is: You DO NOT talk about Fight Club.",
    src: "/fght-club.jpg",
    link: "fght-club.jpg",
    color: "#BBACAF",
  },
  {
    title: "The Boys",
    description:
      "When you’re famous, people let you get away with murder. Homelander doesn’t need permission — he needs applause.",
    src: "https://res.cloudinary.com/dmqwpwo6c/image/upload/v1776147234/the-boys_rsaqok.jpg",
    link: "the.jpg",
    color: "#977F6D",
  },
  {
    title: "The Dark Knight",
    description:
      "Why so serious? You either die a hero, or you live long enough to see yourself become the villain.",
    src: "https://res.cloudinary.com/dmqwpwo6c/image/upload/v1776147231/dark-knight_e1hv4i.jpg",
    link: "dark-knight",
    color: "#C2491D",
  },
  {
    title: "Inception",
    description:
      "You mustn’t be afraid to dream a little bigger, darling. An idea is like a virus — resilient, highly contagious.",
    src: "https://res.cloudinary.com/dmqwpwo6c/image/upload/v1776147231/inception_hutvvu.jpg",
    link: "https://res.cloudinary.com/dmqwpwo6c/image/upload/v1776147233/15_uojijm.jpg",
    color: "#862429",
  },
  {
    title: "Interstellar",
    description:
      "We used to look up at the sky and wonder at our place in the stars. Now we just look down and worry about our place in the dirt.",
    src: "/interstellar.jpg",
    link: "https://res.cloudinary.com/dmqwpwo6c/image/upload/v1776147231/16_jeq4en.jpg",
    color: "#88A2BD",
  },
];

function ScrollCards() {
  const containerRef = useRef(null);
  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ["start start", "end end"],
  });
  // remove comment if want lenis
  // useEffect(() => {
  //   const lenis = new Lenis();

  //   function raf(time: any) {
  //     lenis.raf(time);
  //     requestAnimationFrame(raf);
  //   }

  //   requestAnimationFrame(raf);
  // }, []);

  return (
    <motion.div
      ref={containerRef}
      className="flex justify-center flex-col items-center my-[10vh]"
    >
      {projects.map((items: any, idx) => {
        const targetScale = 1 - (projects.length - idx) * 0.05;
        return (
          <Cards
            title={items.title}
            description={items.description}
            src={items.src}
            link={items.link}
            color={items.color}
            key={idx}
            index={idx}
            progress={scrollYProgress}
            range={[idx * 0.25, 1]}
            targetScale={targetScale}
          />
        );
      })}
    </motion.div>
  );
}

export default ScrollCards;

type cardProps = {
  title?: string;
  description?: string;
  src?: string;
  link?: string;
  color?: string;
  className?: string;
  index: number;
  progress: MotionValue<number>;
  targetScale: number;
  range: number[];
};

function Cards({
  className,
  title,
  description,
  src,
  link,
  color,
  index,
  range,
  targetScale,
  progress,
}: cardProps) {
  const containerRef = useRef(null);

  const { scrollYProgress } = useScroll({
    target: containerRef,
    offset: ["start end", "start start"],
  });

  const scrollVal = useTransform(scrollYProgress, [0, 1], [2, 1]);
  const scale = useTransform(progress, range, [1, targetScale]);

  return (
    <div
      ref={containerRef}
      className="font-bebas max-w-[60vw] h-screen flex items-center justify-center sticky top-[10vh]"
    >
      <motion.div
        style={{
          scale: scale,
          backgroundColor: color,
          top: `calc(-10% + ${index * 25}px)`,
        }}
        className={cn(
          className,
          "relative w-[50vw] h-[60vh] rounded-2xl flex justify-between items-center gap-2 p-20",
        )}
      >
        <div className="w-full h-full flex flex-col gap-5 justify-center items-center">
          <h2 className="text-5xl font-bold text-center">{title}</h2>
          <p className="text-xl">{description}</p>
        </div>
        <div className="max-h-[300px] w-full flex justify-center rounded-2xl items-center overflow-hidden">
          <motion.div
            style={{ scale: scrollVal }}
            className="absolute top-0 -right-5 h-[300px] w-[200px] rounded-2xl overflow-hidden"
          >
            {/* <MaskCard overlayBg={link}/> */}
            <img
              src={src}
              alt=""
              className="object-cover w-full h-full rounded-2xl overflow-hidden"
            />
          </motion.div>
        </div>
      </motion.div>
    </div>
  );
}