More
Click

Opposite Scroll With a Bit of Spice

Category:  nerd
Date:  
Author:  Ryan McManimie

I was looking for something dynamic to aesthetically anchor on of the services pages of my site. A layout that could creatively marry a bit of portfolio content within it - since at the moment, bits are all I have for many of them - and something like @tomisloading's Opposite Scroll React component perfectly fit the bill.

The only problem is that it wasn't all that responsive and, considering the unconventional presentation, I also wanted to ensure users effortlessly connected the content on the left to the related photo on the right so let's dig into it a bit.

Disclaimer: Tom's Opposite Scroll is a paid component, so I'll only be sharing some custom bits here. However, I highly recommend checking out his site as it's not only a great library but also a brilliant model for how to compose your own clean React and Tailwind CSS components.

Mobile Mods

I thought about getting cute here by completely overlaying semi-opaque content atop the reverse scrolling images underneath but the implementation was a bit tricky and I wasn't sure I'd necessarily be satisfied with the result so I kept it simple: duplicate the image on the content side and dump the opposite scroll completely.

60 second fix.

const Content = ({ content }) => {
  return (
    <div className="w-full">
      {content.map(({ id, title, description, img }, idx) => (
        <div
          key={id}
          className={`p-8 h-screen flex flex-col justify-center ${
            idx % 2 ? "bg-white text-black" : "bg-black text-white"
          }`}
        >
          <h3 className="text-3xl font-medium">{title}</h3>
          <p className="font-light w-full max-w-md my-4">{description}</p>
          <NextImage 
            src={img} 
            width={350} 
            height={100}
            className="mt-2 block sm:hidden"
            />
        </div>
      ))}
    </div>
  );
};

Changes:
  1. Add the img prop
  2. Feed it to an image component
  3. Hide image above mobile width with sm:hidden
  4. Swap justify-between to justify-center for improved readability
  5. Tweak the margins, my-4 and mt-2
  6. Drop the mobile w-24 from the image column so it stretches full (not pictured).

and viola...

OppoScroll before and after mobile mods
OppoScroll before and after mobile mods

Intersection Observer

Now on to the fun stuff. Since it's a free-scrolling component with two columns traveling in opposite directions on scrub, as mentioned earlier, I wanted to take it a bit further to help users immediately connect the image on the right to its corresponding content on the left.

To accomplish that I went with an opacity dip and a (probably unnecessary) grayscale fade for images as they leave the viewport to drill user focus in the center when the sides meet.

Now to dust off that IO API....

const ImageWithFilter = ({ item }) => {
  const ref = useRef(null);
  const [inViewRatio, setInViewRatio] = useState(0);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        setInViewRatio(entry.intersectionRatio);
        console.log("IS Ratio:", entry.intersectionRatio); 
      },
      {
        threshold: Array.from({ length: 101 }, (_, i) => i / 100),
      }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    };
  }, []);

  const progress = useMotionValue(inViewRatio);
  
  useEffect(() => {
    progress.set(inViewRatio);
  }, [inViewRatio, progress]);

  const grayscaleValue = useTransform(progress, [0, 1], [100, 0]);
  const opacityValue = useTransform(progress, [0, 1], [0, 1]);

  return (
    <div ref={ref} className="h-screen">
      <motion.div 
        style={{
          filter: useTransform(grayscaleValue, (value) => `grayscale(${value}%)`),
          opacity: opacityValue,
          height: '100%',
        }}
      >
        <NextImage
          className="h-full w-full object-cover"
          src={item.image}
        />
      </motion.div>
    </div>
  );
};

From there, you just take the newly created ImageWithFilter component and swap that for the HTML img control used in the original project and Framer Motion handles the rest. You can check out a live demo on some of my services pages.