Lazy Section Reveal
A below-the-fold section fades and lifts the first time it comes into view, and never again after that.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useRef } from "react";
import type { ReactNode, RefObject } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import type { Variants } from "motion/react";
/**
* Vibary · Lazy Section Reveal
*
* A below-the-fold section fades and lifts the first time enough of it
* is on screen — and then never again. The "never again" is the part
* that matters: a section that re-animates every time it scrolls back
* into view turns a page into a slideshow, and re-hides paragraphs the
* reader has already read.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the section
* reads correctly on a light page and on a dark one.
* Works with zero props; pass `children` for your own content and
* `root` when the section scrolls inside a container rather than the
* window. Requires the automatic JSX runtime (default since React 17).
*/
export type LazySectionRevealProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your content. Falls back to embedded sample content. */
children?: ReactNode;
/** Section heading. Pass an empty string to drop it. */
heading?: string;
/** Scroll container, when the section is not scrolled by the window. */
root?: RefObject<HTMLElement | null>;
/** How much of the section must be visible before it reveals, 0–1. */
amount?: number;
/** Set false only if you genuinely want it to replay on every pass. */
once?: boolean;
/** Section width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Entry travel for the section, in px. */
lift: number;
/** Seconds between one child arriving and the next. */
stagger: number;
fadeSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: this is a section of type, so it travels a short
// distance on an over-damped spring and never scales. Text that grows
// into place while you are reading it is the fastest way to make a page
// feel cheap. Variants change travel and pace only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A few pixels and a fade. For a long page with many sections, where
// the reveal should register as polish rather than as an event.
subtle: {
lift: 8,
stagger: 0.04,
fadeSeconds: 0.32,
spring: { type: "spring", stiffness: 460, damping: 44 },
},
// The all-purpose setting: a clear arrival, settled inside half a
// second.
default: {
lift: 16,
stagger: 0.07,
fadeSeconds: 0.42,
spring: { type: "spring", stiffness: 320, damping: 34 },
},
// Longer travel and a wider gap between children, for a marketing
// section that is meant to be noticed.
playful: {
lift: 26,
stagger: 0.1,
fadeSeconds: 0.5,
spring: { type: "spring", stiffness: 240, damping: 29 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields cards and borders that are correctly toned in
* either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_CARDS = [
{ title: "Revenue by segment", meta: "Updated this morning" },
{ title: "Support backlog", meta: "Updated 2 hours ago" },
{ title: "Renewal risk list", meta: "Updated yesterday" },
];
export default function LazySectionReveal({
variant = "default",
children,
heading = "Related reports",
root,
amount = 0.35,
once = true,
width = 336,
}: LazySectionRevealProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const ref = useRef<HTMLDivElement | null>(null);
// `once` keeps the observer from re-firing on the way back up. It also
// means the reveal costs one intersection callback for the life of the
// page instead of one per scroll direction change.
const inView = useInView(ref, { root, amount, once });
const lift = reduceMotion ? 0 : cfg.lift;
const group: Variants = {
hidden: {},
shown: {
transition: {
staggerChildren: reduceMotion ? cfg.stagger * 0.5 : cfg.stagger,
delayChildren: 0.02,
},
},
};
const item: Variants = {
hidden: { opacity: 0, y: lift },
shown: {
opacity: 1,
y: 0,
transition: {
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
y: cfg.spring,
},
},
};
return (
<motion.section
ref={ref}
// Reduced motion keeps the arrival — the section still fades up to
// full strength in reading order — and drops all of the travel.
variants={group}
initial="hidden"
animate={inView ? "shown" : "hidden"}
style={{ width, display: "flex", flexDirection: "column", gap: 12 }}
>
{heading ? (
<motion.h3
variants={item}
style={{
margin: 0,
fontSize: 13,
fontWeight: 600,
letterSpacing: 0.3,
}}
>
{heading}
</motion.h3>
) : null}
{children ?? (
<>
{SAMPLE_CARDS.map((card) => (
<motion.div
key={card.title}
variants={item}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "12px 14px",
borderRadius: 12,
border: `1px solid ${tone(11)}`,
background: tone(5),
}}
>
<span
style={{
width: 30,
height: 30,
borderRadius: 9,
flexShrink: 0,
background: tone(9),
display: "grid",
placeItems: "center",
}}
>
<svg width={14} height={14} viewBox="0 0 16 16" fill="none">
<path
d="M3 12.5V7M8 12.5V3.5M13 12.5V9"
stroke="currentColor"
strokeOpacity="0.6"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 13,
fontWeight: 500,
lineHeight: 1.3,
}}
>
{card.title}
</span>
<span
style={{
display: "block",
fontSize: 11.5,
opacity: 0.52,
marginTop: 2,
}}
>
{card.meta}
</span>
</span>
</motion.div>
))}
</>
)}
</motion.section>
);
}About this pattern
The reveal that long pages are built out of, with the discipline that usually gets left off. An observer watches the section and fires once about a third of it is on screen; its children then arrive a few tens of milliseconds apart, fading and lifting a short distance on an over-damped spring. The important half is the restraint: the observer is armed only once, so scrolling back up leaves the section exactly as the reader left it. Replaying on every pass turns a page into a slideshow and re-hides paragraphs that have already been read, which is worse than no reveal at all. Everything here is type, so it travels a little and never scales.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
Feature blocks lift and fade in once as the page is scrolled past them.
Related patterns
- Infinite Scroll FooterA footer spinner fills a slot that was already reserved, and the next page fades in above it.
- Map Tiles LoadTiles fade in out of order over the placeholder grid, the way a real map delivers them.
- Skeleton to ContentBreathing placeholders hand off to the real content in one cross-fade and a small lift.