Skip to content

Reveals children once as they scroll in, with a capped stagger that never replays.

Scrollmotion

01Preview

Changelog

Scroll to read
  1. 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 build
    1,204previews
    0config files
  2. 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.

  3. 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.

  4. v4.9

    Roles for billing

    A new Billing role can manage invoices and payment methods without access to projects or secrets.

  5. 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 motion

03Usage

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

PropTypeDefaultDescription
as"div" | "section" | "ul" | "ol" | "article""div"The wrapper element. Its direct children are what reveal.
eachbooleanfalseReveal each child as it enters instead of the whole group when its top edge does.
staggernumber0.05Seconds between children arriving in the same frame.
staggerCapnumber6Children past this index share the last delay, so long groups never trail.
durationnumber0.6Seconds for each entrance.
delaynumber0Seconds before the first child.
distancenumber8Rise in px.
blurnumber4Starting blur in px. Set 0 for large or image-heavy children.
rootRefObject<Element | null>The scroll container, when it isn’t the page.
thresholdnumber0.2With each: how much of a child must show before it reveals.
rootMarginstring"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.