Simple Scroll-Based Fade Animations with React & Intersection Observer

Aayush BajajCo-Founder, OpenCore Group · 5 min read

Sometimes, all you need is a subtle fade or slide animation when elements enter the viewport. You don’t always need a heavy library like framer-motion for that.

In this blog, I'll walk through a lightweight Reveal component which I built with React and the Intersection Observer API. It mimics the basic motion.div behavior but only for simple fade-in animations (up, down, left, right).

Component code (Reveal.tsx)

"use client";
import { ElementType, HTMLAttributes } from "react";
import cn from "classnames";
import { useInView } from "@/hooks/useInView";
type RevealProps = {
  as?: ElementType;
  className?: string;
  delay?: number;
  position?: "top" | "bottom" | "left" | "right";
} & HTMLAttributes<HTMLElement>;
export const Reveal = ({
  as,
  className,
  delay = 0,
  position = "bottom",
  children,
  style,
  ...rest
}: RevealProps) => {
  const { ref, inView } = useInView<HTMLElement>();
  const Tag: ElementType = as ?? "div";
  const getPositionClass = () => {
  switch (position) {
    case "top":
      return "reveal-slide-top";
    case "left":
      return "reveal-slide-left";
    case "right":
      return "reveal-slide-right";
    case "bottom":
    default:
      return "reveal-slide-bottom";
  }
};
  return (
    <Tag
      ref={ref as any}
      {...rest}
      className={cn(
        getPositionClass(),
        "opacity-0 will-change-transform",
        inView && "reveal-in",
        className
      )}
      style={{ ...style, transitionDelay: `${delay}ms` }}
    >
      {children}
    </Tag>
  );
};

Custom hook code (useInView.ts)

"use client";
import { useEffect, useRef, useState } from "react";
export const useInView = <T extends Element>(
  options?: IntersectionObserverInit
) => {
  const ref = useRef<T | null>(null);
  const [inView, setInView] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const obs = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
        setInView(true);
        obs.unobserve(entry.target); // one-shot for performance
      }
      },
      { rootMargin: "0px 0px -10% 0px", threshold: 0.1, ...options }
    );
    obs.observe(el);
    return () => obs.disconnect();
  }, [options]);
  return { ref, inView };
};

CSS code

.reveal {
  transition: all 0.4s ease-in-out;
  transform:translateY(16px);
  opacity:0;
}
.reveal-in {
  transform:translateY(0);
  opacity:1;
}
.reveal-slide-left {
  transition: all 0.4s ease-out;
  transform:translateX(-30px);
  opacity:0;
}
.reveal-slide-left.reveal-in {
  transform:translateX(0);
  opacity:1;
}
.reveal-slide-right {
  transition: all 0.4s ease-out;
  transform:translateX(30px);
  opacity:0;
}
.reveal-slide-right.reveal-in {
  transform:translateX(0);
  opacity:1;
}
.reveal-slide-top {
  transition: all 0.4s ease-out;
  transform:translateY(-30px);
  opacity:0;
}
.reveal-slide-top.reveal-in {
  transform:translateY(0);
  opacity:1;
}
.reveal-slide-bottom {
  transition: all 0.4s ease-out;
  transform:translateY(30px);
  opacity:0;
}
.reveal-slide-bottom.reveal-in {
  transform:translateY(0);
  opacity:1;
}

And just like that, you have a re-usable component which you can use to add fade animation to almost any element you want!

Simple usage

<Reveal>
  <p>Animate me!</p>
</Reveal>

By default it it will fade in from the bottom because of the component's position is set to bottom.

Advanced Usage (directional, delay)

<Reveal position="top">
  <p>Animate me from top</p>
</Reveal>
<Reveal position="left">
  <p>Animate me from left</p>
</Reveal>
<Reveal position="right">
  <p>Animate me from right</p>
</Reveal>
<Reveal position="left" delay={200}>
  <p>Animate me from left with delay</p>
</Reveal>
<Reveal position="right" delay={400}>
  <p>Animate me from right with delay</p>
</Reveal>

Look at how I used the delay prop to add a delay in the animation, the delay value is judge in milliseconds so 1000 = 1 second.

The custom useInView takes the ref of the component, observes its position in the viewport, then return the inView value, if the element is in view, the value returns true which and vice-versa. The inView state variable is responsible for adding animation class reveal-in

Conclusion: A highly-robust, light-weight and easy-to-use animation component that elements the need of any third-party library to add simple animations. Feel free to add more to this component, Ciao!

Have question? We've got answers!

It depends on scope, but most website projects run four to eight weeks, and custom software projects typically run two to four months for a first version. We work in stages, so you're seeing real progress early rather than waiting for a big reveal at the end. Before we start, we'll give you a clear timeline with milestones — and because most of our clients work with us on a retainer, the launch date is usually a starting point rather than a finish line.

Didn't find the answer?

Book a call and let's have a chat.

Tell us what you're building.

We'll help you map the right scope, team, timeline, and next steps

Start your new project