Motion v12: per-element whileInView beats variant propagation
Scroll reveals kept landing in the wrong state when parents orchestrated children through variant propagation. One Reveal primitive with per-element whileInView fixed it for the whole site.
This portfolio is one long single page. You scroll past Hero, About, Skills, Experience, Work and Contact, and every section is supposed to rise into place as it enters the viewport. That is a lot of entrance animations on one route, all driven by Motion v12, the successor to Framer Motion. Getting them to fire reliably took one wrong turn before it took the right one.
The pattern that looks correct on paper
Motion has a feature that seems built for exactly this. A parent holds the timing, declares a container variant with staggerChildren, and every child just names the same variant states and inherits both the trigger and the cascade. No per-element wiring, no manual delays. It reads beautifully:
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.12 } },
};
const rise = {
hidden: { opacity: 0, y: 24 },
show: { opacity: 1, y: 0 },
};
<motion.div variants={container} initial="hidden" whileInView="show">
<motion.h2 variants={rise}>Featured projects.</motion.h2>
<motion.p variants={rise}>A few things I have shipped.</motion.p>
</motion.div>The parent decides when everyone starts, the children ride along, and the whole block staggers itself. For a self-contained group this is the idiomatic approach, and I want to be clear that it is not broken.
Where propagation held, and where it did not
The honest split is about the trigger. When the parent orchestrates its children on mount with initial and animate, propagation is rock solid. The Hero and the Contact section both still run on container variants with staggerChildren today. The badge, the headline, the paragraph and the buttons cascade off a single parent, and I have never once seen them land wrong, because the parent animates immediately and the children have a stable timeline to inherit.
The trouble started when the trigger was scroll instead of mount. On a page this tall, with sections orchestrated by a parent whileInView, children occasionally settled in the wrong state. Some would stick on the hidden state and never fade in. Others fired a beat too early or too late, out of step with the element that owned them. It was intermittent, which is the worst kind of wrong: it depended on scroll speed, on how fast the section crossed the observer threshold, on whether the parent had resolved its own viewport entry before the children needed their state. There was orchestration state to keep in sync, and across a long scroll it desynced.
One primitive, observed per element
The fix was to stop asking a parent to coordinate scroll entrances at all. I gave the whole site a single primitive, Reveal, and every element that enters on scroll observes its own viewport crossing:
export const EASE = [0.16, 1, 0.3, 1] as const;
export function Reveal({ children, delay = 0, y = 20, blur = false, duration = 0.6 }) {
const reduce = useReducedMotion();
const hidden = reduce
? { opacity: 0 }
: { opacity: 0, y, ...(blur ? { filter: "blur(10px)" } : {}) };
const shown = reduce
? { opacity: 1 }
: { opacity: 1, y: 0, ...(blur ? { filter: "blur(0px)" } : {}) };
return (
<motion.div
initial={hidden}
whileInView={shown}
viewport={{ once: true, margin: "-12% 0px" }}
transition={{ duration, ease: EASE, delay }}
>
{children}
</motion.div>
);
}Each Reveal carries its own initial and whileInView, its own viewport with once and a margin of minus twelve percent so it triggers slightly before the element is fully on screen, and the shared EASE curve, an expo-out at [0.16, 1, 0.3, 1], so every entrance on the site feels like it came from the same hand. The y travel and the blur-in are optional props, and there is a delay prop for staggering.
Why is per-element observation more predictable? Because each element owns its own trigger. Motion backs whileInView with an IntersectionObserver, so a Reveal decides entirely on its own when to animate, from its own crossing, with nothing to inherit and no shared clock to fall out of sync with. There is no orchestration state that can desync, because there is no orchestration. Ten Reveals on screen are ten independent observers, each correct in isolation.
Staggering without a parent
Giving up parent orchestration means giving up staggerChildren, so the cascade moves onto each element through the delay prop, keyed on its index. Card grids do this directly. SpotlightCard, the shell behind the Skills bento and the About cards, takes an index prop and offsets its own entrance by index times 0.05:
transition={{ duration: 0.55, ease: EASE, delay: index * 0.05 }}Project rows use a slightly wider index times 0.08 for a more deliberate rhythm. The stagger looks identical to what staggerChildren produced, but the timing now lives on the element that is actually animating, not on a parent trying to conduct from above. Each card is still fully in charge of itself.
Accessibility comes for free
Because the entrance logic sits in one place, reduced motion is handled once. Reveal and SpotlightCard both call useReducedMotion, and when it returns true they drop the transform and blur and cross-fade opacity only. Motion cannot gate CSS-driven animation though, so a global prefers-reduced-motion guard in globals.css neutralizes the CSS pieces it does not own, the marquee, the pulse, the ping, and smooth scroll. Motion variants and raw CSS both go quiet for anyone who asks their operating system to settle things down.
A trap that looks like the same bug
One more thing worth naming, because it wears the same disguise. Every entrance can look permanently stuck at opacity zero and you will swear the reveal logic is broken, when the real cause is that the tab was in the background. A hidden tab reports document.hidden as true, the browser freezes requestAnimationFrame to save work, and Motion drives its timeline off requestAnimationFrame, so nothing ever advances past frame zero. It cost me an afternoon on this same site, and the answer was the environment, not the code.
Takeaways
- Variant propagation is fine, and I still use it for mount-time cascades like the hero, where the parent animates immediately.
- For scroll entrances across a long page, let each element observe its own viewport crossing. No shared orchestration state means nothing to desync.
- Fold it into one primitive so the easing, the trigger margin and the reduced-motion handling are decided once for the whole site.
- Stagger with index-based delays on the element itself rather than a parent conductor.
- When entrances look frozen, check whether the tab is backgrounded before you blame the animation.
Propagation was not the villain. It was the wrong tool for scroll-triggered work on a tall page, and swapping it for per-element observation removed a whole category of intermittent, unreproducible bugs.