• Development

Simple Scroll-Based Fade Animations with React & Intersection Observer

Waleed Ali Khan
5 min read | September 25, 2025
  • Facebook icon
  • Twitter (𝕏) icon
  • Linkedin icon
  • Instagram icon

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!

Did you find this helpful?
Share it with others!

Transforming your
ideas into reality

Let's build something extraordinary together to captivate your audience.