Interactive
ScrollReveal
Children slide and fade in as they scroll into view. Those arriving together go one after another, so a long list never waits on its own stagger. Reduced motion keeps only the fade.
Download
- React
- Tailwind CSS v4
- motion
components/ui/scroll-reveal.tsx
"use client";
import { Children, useEffect, useRef, type ReactNode, type RefObject } from "react";
import { useReducedMotion } from "motion/react";
type ScrollRevealProps = {
/** Each direct child reveals on its own. */
children: ReactNode;
/** Side each child slides in from. */
from?: "bottom" | "top" | "left" | "right";
/** Pixels each child travels. */
distance?: number;
/** Seconds one child takes. */
duration?: number;
/** Seconds between children that come into view together. */
stagger?: number;
/** Seconds before the first child starts. */
delay?: number;
/** How much of a child must show before it starts, 0 to 1. */
amount?: number;
/** False hides children again when they leave, so they replay. */
once?: boolean;
/** Scrolling box that holds this. Leave out to use the page. */
root?: RefObject<HTMLElement | null>;
/** On the wrapper, so a grid or flex layout goes here. */
className?: string;
};
const SHIFT = { bottom: [0, 1], top: [0, -1], left: [-1, 0], right: [1, 0] };
/* ScrollReveal: children slide and fade in as they scroll into view. Those arriving together go one after another. */
export function ScrollReveal({
children,
from = "bottom",
distance = 28,
duration = 0.6,
stagger = 0.08,
delay = 0,
amount = 0.25,
once = true,
root,
className = "",
}: ScrollRevealProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const count = Children.count(children);
const [dx, dy] = SHIFT[from];
const hidden = `translate3d(${dx * distance}px, ${dy * distance}px, 0)`;
// The outer slot is watched and the inner layer moves, so the shift cannot change what is seen.
useEffect(() => {
if (!box.current) return;
const inner = (slot: Element) => slot.firstElementChild as HTMLElement;
const slots = [...box.current.children];
if (reduce) slots.forEach((slot) => (inner(slot).style.transform = "none"));
const observer = new IntersectionObserver(
(entries) => {
let order = 0;
for (const { target, isIntersecting } of entries) {
const el = inner(target);
if (isIntersecting) {
el.style.transitionDelay = `${delay + order++ * stagger}s`;
el.style.opacity = "1";
el.style.transform = "none";
if (once) observer.unobserve(target);
} else if (!once) {
el.style.transitionDelay = "0s";
el.style.opacity = "0";
if (!reduce) el.style.transform = hidden;
}
}
},
{ root: root?.current, threshold: amount },
);
slots.forEach((slot) => observer.observe(slot));
return () => observer.disconnect();
}, [reduce, hidden, stagger, delay, amount, once, root, count]);
return (
<div ref={box} className={className}>
{Children.map(children, (child) => (
<div>
<div
className="h-full"
style={{
opacity: 0,
transform: hidden,
transition: `opacity ${duration}s ease-out, transform ${duration}s cubic-bezier(0.22, 1, 0.36, 1)`,
}}
>
{child}
</div>
</div>
))}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/scroll-reveal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/scroll-reveal-demo.tsx
"use client"; import { useRef } from "react"; import { ScrollReveal } from "@/components/ui/scroll-reveal"; const STEPS = ["Pick a piece", "Copy the code", "Change the props", "Ship it", "Watch it land", "Pick another", "Keep going", "That's all"]; export default function ScrollRevealExample() { // The preview scrolls inside its own box. On a real page, leave out `root`. const box = useRef<HTMLDivElement>(null); return ( <div ref={box} className="h-full overflow-y-auto bg-[#0b0a14] p-5"> <p className="mx-auto mb-4 max-w-sm text-sm text-white/55">Scroll inside this box.</p> <ScrollReveal root={box} once={false} className="mx-auto grid max-w-sm gap-3"> {STEPS.map((step, i) => ( <div key={step} className="rounded-xl border border-white/10 bg-white/5 p-4"> <p className="text-xs text-[#a78bfa]">0{i + 1}</p> <p className="mt-1 text-sm font-medium text-white">{step}</p> </div> ))} </ScrollReveal> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Each direct child reveals on its own. |
| from | "bottom" | "top" | "left" | "right" | "bottom" | Side each child slides in from. |
| distance | number | 28 | Pixels each child travels. |
| duration | number | 0.6 | Seconds one child takes. |
| stagger | number | 0.08 | Seconds between children that come into view together. |
| delay | number | 0 | Seconds before the first child starts. |
| amount | number | 0.25 | How much of a child must show before it starts, 0 to 1. |
| once | boolean | true | False hides children again when they leave, so they replay. |
| root | RefObject | — | Scrolling box that holds this. Leave out to use the page. |
| className | string | "" | On the wrapper, so a grid or flex layout goes here. |