Reveals children once as they scroll in, with a capped stagger that never replays.
01Preview
Changelog
Scroll to read- v4.12
Branch previews for every pull request
Each push to a branch now builds its own preview with a stable URL, posted on the pull request and torn down when it merges.
38smedian build1,204previews0config files - v4.11
Audit log export
Owners can export up to 90 days of audit events as CSV or send them to a webhook as they happen.
- v4.10
Faster cold starts in eu-west-2
Functions in London now boot from a warm snapshot. Cold starts dropped from 410 ms to 120 ms at the 95th percentile.
- v4.9
Roles for billing
A new Billing role can manage invoices and payment methods without access to projects or secrets.
- v4.8
Keyboard shortcuts everywhere
Press ? on any screen to see what it supports. G then P jumps to projects; G then S to settings.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion03Usage
import { ScrollReveal } from "@/components/ui/scroll-reveal";
<ScrollReveal className="grid grid-cols-3 gap-4">
<FeatureCard title="Previews" />
<FeatureCard title="Audit log" />
<FeatureCard title="Roles" />
</ScrollReveal>
// Long lists: each child reveals as it enters.
<ScrollReveal as="ol" each>
{entries.map((e) => <Entry key={e.id} entry={e} />)}
</ScrollReveal>04Source
"use client";
import { useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
export type ScrollRevealProps = React.HTMLAttributes<HTMLElement> & {
/** The element to render. Children are revealed, not the element itself. */
as?: "div" | "section" | "ul" | "ol" | "article";
/** Reveal each child as it enters, instead of the whole group at once. Use it for long lists and grids. */
each?: boolean;
/** Seconds between children arriving together. */
stagger?: number;
/** After this many children, the rest arrive with the last one's delay, so a long list never trails. */
staggerCap?: number;
/** Seconds for each child's entrance. */
duration?: number;
/** Seconds before the first child. */
delay?: number;
/** Rise in px. */
distance?: number;
/** Starting blur in px. 0 turns it off for large or image-heavy children. */
blur?: number;
/** The scroll container, when it isn't the page. */
root?: React.RefObject<Element | null>;
/** Fraction of each child that must be visible to reveal it (with each). A group reveals as its top edge enters. */
threshold?: number;
rootMargin?: string;
ref?: React.Ref<HTMLElement>;
};
/**
* Reveals its children once, as they scroll into view: a fade, an 8px rise and
* a blur that clears. It never plays again. With reduced motion, or without
* JavaScript, the children are simply there.
*/
export function ScrollReveal({
as: Tag = "div",
each = false,
stagger = 0.05,
staggerCap = 6,
duration = 0.6,
delay = 0,
distance = 8,
blur = 4,
root,
threshold = 0.2,
rootMargin = "0px 0px -8% 0px",
className,
children,
ref,
...rest
}: ScrollRevealProps) {
const reduce = useReducedMotion();
const self = useRef<HTMLElement>(null);
const opts = useRef({ each, stagger, staggerCap, duration, delay, distance, blur });
useEffect(() => {
opts.current = { each, stagger, staggerCap, duration, delay, distance, blur };
});
useEffect(() => {
const el = self.current;
if (!el) return;
const pending = () => Array.from(el.children).filter((c): c is HTMLElement => c instanceof HTMLElement && !c.hasAttribute("data-revealed"));
// Nothing to animate: mark everything shown so the hiding rule lets go.
if (reduce) {
pending().forEach((c) => c.setAttribute("data-revealed", ""));
return;
}
const play = (targets: HTMLElement[]) => {
const o = opts.current;
targets.forEach((child, i) => {
child.setAttribute("data-revealed", "");
// Web Animations with fill "backwards": hidden through its delay, and
// nothing left behind afterwards to fight the child's own styles.
child.animate(
[
{ opacity: 0, transform: `translateY(${o.distance}px)`, filter: o.blur ? `blur(${o.blur}px)` : "none" },
{ opacity: 1, transform: "none", filter: o.blur ? "blur(0px)" : "none" },
],
{ duration: o.duration * 1000, delay: (o.delay + Math.min(i, o.staggerCap) * o.stagger) * 1000, easing: `cubic-bezier(${ease.out.join(",")})`, fill: "backwards" },
);
});
};
const io = new IntersectionObserver(
(entries) => {
const arriving: HTMLElement[] = [];
for (const entry of entries) {
const target = entry.target as HTMLElement;
const above = entry.boundingClientRect.bottom < (entry.rootBounds?.top ?? 0);
if (!entry.isIntersecting && !above) continue;
io.unobserve(target);
const kids = target === el ? pending() : [target];
// Jumped past it (an anchor link, End, a restored scroll): show it without a show.
if (above) kids.forEach((k) => k.setAttribute("data-revealed", ""));
else arriving.push(...kids);
}
// Children arriving in the same frame stagger in document order.
arriving.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
if (arriving.length) play(arriving);
},
{ root: root?.current ?? null, threshold: each ? threshold : 0, rootMargin },
);
const watch = () => {
if (opts.current.each) pending().forEach((c) => io.observe(c));
else io.observe(el);
};
watch();
// Children added later (the next page of a feed) reveal as they enter, one by one.
const mo = new MutationObserver(() => pending().forEach((c) => io.observe(c)));
mo.observe(el, { childList: true });
return () => {
io.disconnect();
mo.disconnect();
};
// Observers are built once; the options above are read live.
}, [reduce, root, threshold, rootMargin]);
return (
<>
<Tag
ref={(node: HTMLElement | null) => {
self.current = node;
if (typeof ref === "function") return ref(node);
if (ref) (ref as React.RefObject<HTMLElement | null>).current = node;
}}
data-scroll-reveal=""
className={cn(
// Hidden until revealed, but only when motion is welcome; reduced motion never hides anything.
"motion-safe:[&>*:not([data-revealed])]:opacity-0 print:[&>*]:opacity-100!",
className,
)}
{...rest}
>
{children}
</Tag>
{/* Without JavaScript nothing would ever reveal, so nothing hides. */}
<noscript>
<style>{"[data-scroll-reveal]>*{opacity:1!important}"}</style>
</noscript>
</>
);
}05Props
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "div" | "section" | "ul" | "ol" | "article" | "div" | The wrapper element. Its direct children are what reveal. |
| each | boolean | false | Reveal each child as it enters instead of the whole group when its top edge does. |
| stagger | number | 0.05 | Seconds between children arriving in the same frame. |
| staggerCap | number | 6 | Children past this index share the last delay, so long groups never trail. |
| duration | number | 0.6 | Seconds for each entrance. |
| delay | number | 0 | Seconds before the first child. |
| distance | number | 8 | Rise in px. |
| blur | number | 4 | Starting blur in px. Set 0 for large or image-heavy children. |
| root | RefObject<Element | null> | — | The scroll container, when it isn’t the page. |
| threshold | number | 0.2 | With each: how much of a child must show before it reveals. |
| rootMargin | string | "0px 0px -8% 0px" | Reveal slightly before the bottom edge, not at it. |
06Notes
Behavior
- Once means once: every child is marked as it reveals and is never hidden again, whatever the scroll does next.
- Children you jumped past (an anchor link, End, a restored scroll position) are shown on the spot without animating, so scrolling back up never plays a late entrance.
- Children added later, like the next page of a feed, reveal one by one as they enter.
- The entrance runs as a Web Animation filled backwards: hidden through its delay, and nothing left inline afterwards to fight the child’s own transforms, filters or hover transitions.
Motion
- Opacity 0→1, 8px rise and a 4px blur that clears over 600ms on the expo ease-out; children arriving together stagger 50ms apart, capped after the sixth.
- Reduced motion never hides anything: the hiding rule only applies under prefers-reduced-motion: no-preference, so content is there from the first paint.
- Without JavaScript a noscript style keeps every child visible, and print always shows everything.
Accessibility
- Children stay in the accessibility tree and the tab order throughout; opacity is the only thing hidden, so find-in-page and focus still reach them and reveal them.
- No roles are added: the wrapper is whatever element you choose with as.