Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:33Get Lifetime →
loopkit
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.