
Card
Card description
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:
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.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.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.MotionRevealAnimates a single element in — fading, sliding, or scaling — once it scrolls
into view. Built on Framer Motion's variants API.

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 offset/duration/ease per animation, defined once in
motionRevealDefaults and shared with the raw Framer variants (fadeUp,
zoomIn, etc):
animation | offset | duration | ease |
|---|---|---|---|
fade-up | 24px | 500ms | standard (ease-out) |
fade-down | 24px | 500ms | standard (ease-out) |
fade-left | 24px | 400ms | standard (ease-out) |
fade-right | 24px | 400ms | standard (ease-out) |
fade-in | — | 300ms | standard (ease-out) |
zoom-in | scale 0.95 | 300ms | bounce (overshoot spring) |
ease isn't overridable per instance — swap animation or fall back to
composing your own Framer variants for a different curve.
| Prop | Default | Overridable? | Notes |
|---|---|---|---|
animation | 'fade-up' | — | See the table above for each effect's defaults. |
delay | undefined | — | Ms before this instance's animation starts. |
duration | animation default | yes | Ms. Overrides just the duration, keeping the animation's default offset/scale. |
offset | animation default | yes | Px travelled for fade-* animations. No effect on zoom-in or fade-in. |
trigger | 80 | — | Percent 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. |
repeat | false | — | Replay on every viewport re-entry instead of once. Ignored inside a MotionRevealGroup. |
transition | undefined | — | Merged on top of the computed delay/duration/ease, e.g. to swap in a spring. |
MotionRevealGroupMotionReveal cards in
MotionRevealGroup so they trigger together as one staggered sequence
when the group scrolls into view, instead of each card triggering
independently.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>
);
}| Prop | Default | Notes |
|---|---|---|
stagger | 100 | Ms between each child's animation start. |
delayChildren | 0 | Ms before the first child starts. |
trigger | 80 | Same semantics as MotionReveal's trigger — percent down the viewport where the group's top edge triggers the sequence. |
repeat | false | Replay the whole sequence on every viewport re-entry. |
MotionRevealIndividualMotionRevealIndividual 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.
Intro copy and a primary call to action.
A grid of feature highlights.
Social proof from customers.
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.
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