freeNew86 lines · framer-motion
Scroll Reveal Grid
Every card notices the scroll on its own. A full-width grid of cards where each card watches its own entry into the viewport and animates in on a small positional delay — no shared timeline, no pinning, each card just notices it arrived.
- Use it for
- A features or capabilities grid lower on a landing page that should feel considered without needing a pinned, scroll-jacked section.
- Themes
- Light + dark
- License
- Commercial use
Live
Arrives in its own time
Every card notices the scroll on its own
Routing
Every request finds the right handler without a static table to maintain.
Retries
Transient failures get a second try before anyone has to notice.
Backpressure
Load sheds gracefully instead of taking the whole system down.
Tracing
A single id follows the request through every service it touches.
Rollback
A bad deploy reverts itself before the error budget notices.
Isolation
One tenant's spike never shows up on anyone else's dashboard.
tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width scroll section: a grid of cards, each watching its own entry
* into the viewport and animating in on its own small delay — no shared
* timeline, no pinning, just each card noticing it arrived.
*/
export interface RevealGridItem {
title: string;
body: string;
accent: "signal" | "info" | "lime" | "ok";
}
export interface ScrollRevealGridProps {
eyebrow?: string;
title?: string;
items?: RevealGridItem[];
columns?: 2 | 3 | "2" | "3";
className?: string;
}
const DEFAULT_ITEMS: RevealGridItem[] = [
{ title: "Routing", body: "Every request finds the right handler without a static table to maintain.", accent: "signal" },
{ title: "Retries", body: "Transient failures get a second try before anyone has to notice.", accent: "info" },
{ title: "Backpressure", body: "Load sheds gracefully instead of taking the whole system down.", accent: "lime" },
{ title: "Tracing", body: "A single id follows the request through every service it touches.", accent: "ok" },
{ title: "Rollback", body: "A bad deploy reverts itself before the error budget notices.", accent: "signal" },
{ title: "Isolation", body: "One tenant's spike never shows up on anyone else's dashboard.", accent: "info" },
];
const accentBg: Record<string, string> = { signal: "bg-signal", info: "bg-info", lime: "bg-lime", ok: "bg-ok" };
export function ScrollRevealGrid({
eyebrow = "Arrives in its own time",
title = "Every card notices the scroll on its own",
items = DEFAULT_ITEMS,
columns = 3,
className,
}: ScrollRevealGridProps) {
const reduce = useReducedMotionSafe();
const cols: 2 | 3 = Number(columns) === 2 ? 2 : 3;
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-20 sm:px-6 lg:px-10", className)}>
<div className="mx-auto max-w-5xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-3 font-display text-[clamp(1.9rem,4.6vw,3.25rem)] font-extrabold leading-[1.05] tracking-[-0.03em]">{title}</h2>
</div>
<div className={cn("mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-4 sm:grid-cols-2", cols === 3 && "lg:grid-cols-3")}>
{items.map((item, i) => {
const col = cols === 3 ? i % 3 : i % 2;
const row = Math.floor(i / cols);
return (
<motion.div
key={item.title}
initial={reduce ? false : { opacity: 0, y: 28, scale: 0.96 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, amount: 0.4 }}
transition={{ duration: 0.55, delay: reduce ? 0 : (col + row) * 0.08, ease: [0.22, 1, 0.36, 1] }}
className="rounded-3xl border border-border bg-card p-6"
>
<span className={cn("inline-block size-2.5 rounded-full", accentBg[item.accent])} aria-hidden />
<h3 className="mt-4 font-display text-lg font-bold tracking-tight">{item.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{item.body}</p>
</motion.div>
);
})}
</div>
</section>
);
}
export default ScrollRevealGrid;
How it behaves
- →Each card uses its own whileInView trigger (an independent IntersectionObserver per Framer Motion's viewport feature), not a single shared progress value.
- →Delay per card is based on its row and column, so the grid animates in a loose diagonal wave rather than all at once.
- →Each card reveals once, the first time it scrolls into view, and stays put afterwards — scrolling back up never hides it again.
- →Needs no tall or pinned wrapper — it sits in normal page flow at its natural height.
- →prefers-reduced-motion renders every card in its final position immediately, with no stagger.
Accessibility
- ✓Cards are plain, always-present DOM content; the animation only affects opacity/transform, never visibility to assistive tech.
- ✓Accent dots are decorative; each card's meaning comes from its title and body text.
- ✓Honours prefers-reduced-motion by skipping the stagger entrance.
- ✓Honours prefers-reduced-motion.