Motion Reveal

A small system for revealing content as it scrolls into view — one element at a time, staggered as a group, or independently across a page's sections.


Three components cover most cases:

  • MotionReveal — animates a single element in as it scrolls into view.
  • MotionRevealGroup — wraps a set of MotionReveals and triggers them together as one staggered sequence.
  • MotionRevealIndividual — wraps a set of children and reveals each one independently, with no shared trigger or stagger.

A few conceptual notes before diving in:

  • Defaults are global, per-animation, and overridable. Each animation has its own default offset/duration/ease in motionRevealDefaults, shared with the raw Framer variants — pass offset/duration on any instance to override just that value.
  • Exactly one thing owns the viewport trigger. Inside a MotionRevealGroup, individual MotionReveals automatically defer their trigger to the group (via context, no prop needed) — which is why trigger/repeat go inert on a grouped MotionReveal.
  • One shared prop vocabulary, not three APIs. MotionRevealGroup and MotionRevealIndividual forward straight to MotionReveal's props instead of reinventing their own — learn MotionReveal once and you know all three.
  • trigger is a viewport position, not a visibility ratio. A number is the percent down the viewport (0 = top, 100 = bottom) where the element's top edge counts as "entered" — not the fraction of the element's own area that must be visible. That distinction matters: an area-ratio threshold (Motion's own viewport.amount, the escape hatch's shape) becomes unreachable once an element is taller than roughly 1 / amount viewport heights, since that fraction of it can never be on-screen at once — a real bug we hit with a long docs-heavy section before switching to the position-based default.
  • trigger needs scroll room to work. A low trigger value only helps if there's enough page content below the target to actually scroll it up that far — an element near the bottom of the page (especially the last one) may never reach a low trigger threshold and simply never reveal.

MotionReveal

Animates a single element in — fading, sliding, or scaling — once it scrolls into view. Built on Framer Motion's variants API.

Pick an animation
Trigger at
A placeholder image

Card

Card description

import { MotionReveal } from '@/animations';
import { Card } from '@/ui/components/card';
 
<MotionReveal animation="fade-up">
  <Card title="Card 1" description="Card description" media={image} />
</MotionReveal>;

Default Animation Values

Default offset/duration/ease per animation, defined once in motionRevealDefaults and shared with the raw Framer variants (fadeUp, zoomIn, etc):

animationoffsetdurationease
fade-up24px500msstandard (ease-out)
fade-down24px500msstandard (ease-out)
fade-left24px400msstandard (ease-out)
fade-right24px400msstandard (ease-out)
fade-in300msstandard (ease-out)
zoom-inscale 0.95300msbounce (overshoot spring)

ease isn't overridable per instance — swap animation or fall back to composing your own Framer variants for a different curve.

Props

PropDefaultOverridable?Notes
animation'fade-up'See the table above for each effect's defaults.
delayundefinedMs before this instance's animation starts.
durationanimation defaultyesMs. Overrides just the duration, keeping the animation's default offset/scale.
offsetanimation defaultyesPx travelled for fade-* animations. No effect on zoom-in or fade-in.
trigger80Percent down the viewport where the element's top edge triggers the reveal. Pass { amount, margin } (Motion's own viewport options) directly as an escape hatch. Ignored inside a MotionRevealGroup — set it on the group instead.
repeatfalseReplay on every viewport re-entry instead of once. Ignored inside a MotionRevealGroup.
transitionundefinedMerged on top of the computed delay/duration/ease, e.g. to swap in a spring.

MotionRevealGroup

  • Staggered Reveal — wrap a set of MotionReveal cards in MotionRevealGroup so they trigger together as one staggered sequence when the group scrolls into view, instead of each card triggering independently.
Pick an animation
Trigger at
Loading cards

Cards load with skeletons on first visit — toggle Simulate loading to show or hide the skeleton UI.

import { MotionReveal, MotionRevealGroup } from '@/animations';
import { Card } from '@/ui/components/card';
 
function CardGrid() {
  return (
    <MotionRevealGroup className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
      {cards.map((image, index) => (
        <MotionReveal key={`${index}-${animation}`} animation={animation}>
          <Card
            title={`Card ${index + 1}`}
            description="Card description"
            media={image}
          />
        </MotionReveal>
      ))}
    </MotionRevealGroup>
  );
}

Props

PropDefaultNotes
stagger100Ms between each child's animation start.
delayChildren0Ms before the first child starts.
trigger80Same semantics as MotionReveal's trigger — percent down the viewport where the group's top edge triggers the sequence.
repeatfalseReplay the whole sequence on every viewport re-entry.

MotionRevealIndividual

  • Independent Reveal — wrap a set of children in MotionRevealIndividual so each one gets its own independent trigger, with no shared trigger or stagger, unlike MotionRevealGroup.

Reach for this when wrapping a page's top-level sections, where each one should reveal on its own schedule as it individually scrolls into view.

Pick an animation
Trigger at

Hero section

Intro copy and a primary call to action.

Features section

A grid of feature highlights.

Testimonials section

Social proof from customers.

Pricing section

Plans and a final call to action.

import { MotionRevealIndividual } from '@/animations';
 
function Page() {
  return (
    <MotionRevealIndividual animation="fade-up">
      <HeroSection />
      <FeaturesSection />
      <PricingSection />
    </MotionRevealIndividual>
  );
}

Accepts the same props as MotionReveal (animation, delay, duration, offset, trigger, repeat, transition), applied uniformly to every child.


Loading UI

Wrap the grid in Suspense and use CardSkeleton as the fallback while data loads. The demo uses the same markup for Simulate loading and the Suspense fallback.

import { Suspense } from 'react';
import { placeholderImageList } from '@/assets';
import { CardSkeleton } from '@/ui/components/card-skeleton';
 
<Suspense
  fallback={
    <div
      className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
      role="status"
      aria-busy="true"
      aria-live="polite"
    >
      <span className="sr-only">Loading cards</span>
      {placeholderImageList.map((_, index) => (
        <CardSkeleton key={index} />
      ))}
    </div>
  }
>
  <CardGrid />
</Suspense>;

Next.js docs: Loading UI and Streaming