Animation
ScrollReveal
Wrapper that fades children up, in from a side, or scales them on entry.
npx bezel-add add scroll-revealAbout
A scroll reveal animation wrapper for React that fades its children in as they enter the viewport, sliding up, in from the left or right, or scaling up. It uses IntersectionObserver and CSS transitions with no animation library. Content stays visible when scripting is off and appears instantly under prefers-reduced-motion, so nothing can get stuck hidden.
Key props: variant ("up", "left", "right" or "scale"), delay (0 to 500 ms in steps of 100, for staggering), className (classes on the wrapper)
packages/ui/src/animation/ScrollReveal.tsx · 100 lines
"use client";
import { useEffect, useRef, ReactNode } from "react";
type Variant = "up" | "left" | "right" | "scale";
type Props = {
children: ReactNode;
variant?: Variant;
delay?: 0 | 100 | 200 | 300 | 400 | 500;
className?: string;
};
/**
* Timing comes from tokens.css, so a host that retimes the system retimes the
* reveal. The hidden state is only ever reached when a script can undo it:
* `scripting: none` and reduced motion both land the content visible, which is
* the state anything that cannot animate must end in.
*
* The rules ship in the element rather than the head so a server render is
* already correct and nothing flashes in before the observer attaches.
*/
const revealStyle = `
.sr-up,.sr-left,.sr-right,.sr-scale {
opacity:0;
transition:opacity var(--bz-duration-slower,800ms) var(--bz-ease-out,cubic-bezier(.23,1,.32,1)),
transform var(--bz-duration-slower,800ms) var(--bz-ease-out,cubic-bezier(.23,1,.32,1));
will-change:opacity,transform;
}
.sr-up { transform:translateY(24px); }
.sr-left { transform:translateX(-28px); }
.sr-right { transform:translateX(28px); }
.sr-scale { transform:scale(.94); }
.sr-up.in,.sr-left.in,.sr-right.in,.sr-scale.in { opacity:1; transform:none; }
/* Once it has landed the hint costs a layer for nothing. */
.sr-rest { will-change:auto; }
.sr-d1 { transition-delay:.1s; }
.sr-d2 { transition-delay:.2s; }
.sr-d3 { transition-delay:.3s; }
.sr-d4 { transition-delay:.4s; }
.sr-d5 { transition-delay:.5s; }
@media (prefers-reduced-motion:reduce) {
.sr-up,.sr-left,.sr-right,.sr-scale { opacity:1; transform:none; transition:none; }
}
@media (scripting:none) {
.sr-up,.sr-left,.sr-right,.sr-scale { opacity:1; transform:none; }
}
`;
const variantClass: Record<Variant, string> = {
up: "sr-up",
left: "sr-left",
right: "sr-right",
scale: "sr-scale",
};
export function ScrollReveal({ children, variant = "up", delay = 0, className = "" }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
// will-change is a promise to the compositor; it is withdrawn once the
// reveal has landed, or straight away when nothing will animate.
const rest = () => el.classList.add("sr-rest");
el.addEventListener("transitionend", rest);
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
el.classList.add("in", "sr-rest");
return () => el.removeEventListener("transitionend", rest);
}
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
el.classList.add("in");
io.disconnect();
},
{ threshold: 0.1, rootMargin: "0px 0px -60px 0px" },
);
io.observe(el);
return () => {
io.disconnect();
el.removeEventListener("transitionend", rest);
};
}, []);
const delayClass = delay ? `sr-d${delay / 100}` : "";
const cls = [variantClass[variant], delayClass, className].filter(Boolean).join(" ");
return (
<>
<style>{revealStyle}</style>
<div ref={ref} className={cls}>
{children}
</div>
</>
);
}