Motion Slop: How Fade-In-Up Became the text-gray-600 of Animation
Every section on an AI-built page does the same 16px hop, 500ms, ease-out, fired at 30% viewport. The library changes; the fingerprint doesn't. Here's why the machine always lands here, and how to ship motion that actually decided something.
Scroll any landing page generated by v0, Lovable, or Bolt in the last eighteen months. The hero is already there. Then you scroll, and the feature grid rises 16 pixels and fades in. Scroll more — the testimonials rise 16 pixels and fade in. The pricing cards rise 16 pixels and fade in. The FAQ accordion, the footer CTA, the logo cloud: all of them, the same 16 pixels, the same 500 milliseconds, the same ease-out, the same trigger at 30% viewport entry.
This is fade-in-up. It is the text-gray-600 of motion. And once you've seen it, you cannot stop seeing it — every section on the page doing the identical small hop, like a chorus line that only knows one step.
The exact code, because it's always the exact code
Here is the Framer Motion version. If you've used an AI builder this year, you have shipped this verbatim:
const fadeInUp = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
<motion.section
variants={fadeInUp}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
transition={{ duration: 0.5, ease: "easeOut" }}
>Sometimes y: 20, sometimes y: 16, occasionally y: 24. The duration is 0.5 or 0.6. The easing is easeOut, because that's the first easing in the docs and the one that "feels nice" to a model that has never felt anything. viewport={{ once: true }} is in there because someone on Stack Overflow complained the animation re-triggered on scroll-up, and that answer is in the training data.
The non-React version is AOS — Animate On Scroll — and it's even more naked about it:
<div data-aos="fade-up" data-aos-duration="600" data-aos-once="true">Or the hand-rolled Intersection Observer that ChatGPT writes when you ask for "scroll animations without a library":
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("animate-in");
}
});
}, { threshold: 0.1 });
document.querySelectorAll(".reveal").forEach((el) => observer.observe(el));.reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.reveal.animate-in {
opacity: 1;
transform: translateY(0);
}Three different stacks. One identical result: translateY(20px), 600ms, ease-out, fire once at a 10% threshold. The library changes; the fingerprint doesn't. That convergence — different tools, same artifact — is the whole thesis of why every AI-generated website looks the same, and motion is just the part people forgot to audit.
Why this specific animation became the default
It's not random that the machine landed here. Fade-in-up is the global minimum of "safe motion," and every force pushes toward it.
It can't break the layout. A 16px vertical nudge on opacity doesn't cause reflow, doesn't trigger horizontal scrollbars, doesn't overflow a container, doesn't fight the grid. An AI optimizing for "renders without errors on the first try" picks the transform that is impossible to get wrong. Animating width or height risks layout thrash. Animating scale past a point clips. translateY(20px) + opacity is the one combination with zero failure modes — and, conveniently, the only two properties the browser can animate off the main thread on the GPU compositor, so it never even janks. The machine stumbled into the cheapest correct answer.
It's the literal first example in every doc. Open the Framer Motion homepage. The whileInView section uses a fade-and-rise. The AOS demo leads with fade-up. GSAP's ScrollTrigger starter is a y tween with opacity. Models are trained on documentation and the blog posts that copy documentation, so the most-documented animation becomes the most-generated one. This is the same mechanism that made font-sans resolve to Inter everywhere, covered in the typography problem. The default in the docs becomes the default in the world.
It reads as "premium" to a non-designer. Any motion feels more polished than no motion to someone who can't articulate why. The model knows, statistically, that "modern landing page" co-occurs with "scroll reveal," so it adds the reveal. It does not know motion has a job. It knows motion is correlated with the adjective the prompt asked for.
Nobody pushes back. The person prompting "make me a SaaS landing page" has no opinion about easing curves. They see things move, they think "looks alive," they ship. The feedback loop that would punish uniform motion never runs.
Why uniform motion reads as machine-made
Here's the part the builders miss. The problem isn't fade-in-up itself. A single, well-placed fade-up on a hero headline is fine — good, even. The problem is that every element gets the same one.
Real motion design is a hierarchy of intent. The primary headline rises and fades. The subhead follows 80ms later — a stagger, because it's subordinate. The CTA button doesn't fade in at all; it's there from frame one, maybe with a tiny scale-settle, because you want it clickable immediately. A product screenshot slides in from the side because it's entering the conversation. A number counter ticks up because the motion *is* the information. Each choice answers a question: what is this element's role, and what should the motion say about it?
AI motion answers no questions. It applies one transform to a .map() over your sections. The testimonial card and the legal footer animate identically because to the generator they're both elements in an array. That's the tell. Humans differentiate; machines iterate. When the eye sees the footer make the same entrance as the hero, some pre-conscious part of the brain flags it: *nothing here was decided.* It's the motion equivalent of every card being rounded-2xl with shadow-sm — the uniformity is the signature. The 23 signs of AI-generated code covers the static version of this; motion is the same disease in the time dimension.
There's a second tell layered on top: the stagger that isn't. When AI does attempt variety, it staggers a grid with transition={{ delay: index * 0.1 }}. Now six feature cards cascade in sequence. Looks fancier. It's worse. A 100ms-per-item delay on six items means the user waits 600ms for the last card, and the cascade has no meaning — card 6 isn't more important than card 1, so why does it arrive later? Mechanical stagger is motion cosplaying as choreography. You can spot it from across the room because the rhythm is perfectly linear: 0, 100, 200, 300. Nothing in good design moves on a perfectly linear delay schedule.
And the easing. ease-out on everything. ease-out means fast-start, slow-finish — right for something *arriving* and settling. But AI uses it for exits too, for hovers, for everything, because it's the safe default. Real motion mixes: ease-out for entrances, ease-in for exits, a custom cubic-bezier(0.34, 1.56, 0.64, 1) with overshoot for something playful, linear only for continuous loops. A page where everything shares one easing curve has the tonal range of a single piano key.
How to spot it in 30 seconds
Open DevTools, go to the Animations panel (Chrome: More Tools → Animations), and scroll. If every recorded animation shares the same duration, easing, and transform distance, you're looking at a generated page. Or read the source — search the bundle for whileInView, data-aos, or a .reveal class with translateY. Then check: is it applied to *one* element with intent, or .map()'d across the whole document?
The faster heuristic, no tools needed: scroll at a normal reading pace and ask whether the motion told you anything. Did something draw your eye to the thing that mattered? Or did the whole page twitch upward in sequence as you went? If it's the latter, the motion is decoration applied by a generator — one more line item in the 30-second AI detection checklist, right next to the blue-purple gradient and the Geist font.
One more giveaway: the load-flash. Many AI scroll-reveal setups put opacity: 0 in CSS but trigger the reveal in JS. On a slow connection or with JS disabled, content above the fold is invisible — a blank page until the observer fires. It also tanks measured performance: every element that pops in after first paint counts toward Cumulative Layout Shift, and a page that hides its hero behind an observer can post a CLS north of 0.25 (Google's "poor" threshold is 0.1) and lose its Largest Contentful Paint candidate entirely, because the LCP element wasn't painted when the browser looked. A real implementation never hides content behind a script. The generated one will happily ship a page that's blank to a screen reader's first paint and a Lighthouse crawl — which is exactly the kind of thing Google's helpful-content machinery is learning to punish.
Motion that has intent
Here's the reframe. Before you add a single transition, answer one question per animated element: what is this motion's job? If you can't name the job, delete the animation. "It looks nice" is not a job.
When motion has a job, the code stops being uniform on its own, because different jobs need different motion:
// Job: direct the eye to the primary claim, then let the
// supporting line follow as subordinate. One stagger, intentional.
<motion.h1
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
>
Cut your AWS bill in half.
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.12 }} // follows the headline
>
We audit idle instances you forgot you turned on.
</motion.p>
// Job: none. The CTA is the point. It's there from frame one.
<a href="/start" className="btn-primary">Start the audit</a>Notice what's different. The headline uses a real easing curve — [0.22, 1, 0.36, 1], a quick decelerate, not the lazy easeOut. The subhead only fades, no y, because it's not making an entrance, it's catching up. The CTA doesn't animate at all. Three elements, three decisions. That's the opposite of a .map().
For scroll, the rule is: reveal something the user couldn't see anyway, and reveal it because seeing it matters now. A chart that draws its bars as it enters viewport — the motion *is* the data revealing itself, earned. A pricing table that does nothing on scroll because it's a table and you want to read it, not watch it — also correct. The generated instinct is "everything reveals." The designed instinct is "almost nothing reveals, and the thing that does has a reason."
And do the accessible version properly, in CSS, so content is never gated behind JS and reduced-motion users get the content with no jump:
.reveal { opacity: 0; transform: translateY(20px); transition: opacity .5s, transform .5s; }
.reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transform: none; transition: none; }
}That last block is the line the generator almost never writes. Roughly one in three macOS and iOS users has "Reduce Motion" on at some point; shipping without that media query means your "premium" reveal is, for them, just a layout that lurches.
Some practical anti-slop moves:
- Vary by role, not by index. Don't
delay: i * 0.1. Stagger only when elements form a genuine sequence — a numbered process, a timeline. For a grid of equal-weight cards, fade the whole group in together, or don't fade it at all. - Mix your easings. Entrances decelerate. Exits accelerate. Reserve overshoot (
cubic-bezier(0.34, 1.56, 0.64, 1)) for one or two playful moments so it stays special. A page with three distinct curves feels authored; a page with one feels printed. - Animate one unexpected property. Everyone does opacity and translate. A
clip-pathwipe, afilter: blur(8px)settling to sharp, a single accent shifting from#64748bto#0ea5e9— these read as deliberate precisely because the generator never reaches for them. - Kill the below-the-fold reveal on long-form content. An article or docs page doesn't need its paragraphs to rise as you scroll. That's motion for motion's sake, and it actively slows reading.
- Never hide content behind JS. Motion is an enhancement layer, not a gate. Visible at first paint, always.
The deeper point runs through this entire blog. The AI default isn't bad because it's ugly — fade-in-up is perfectly pleasant for half a second. It's bad because it's *undifferentiated*, and undifferentiated is the actual fingerprint of machine output. The gradient, the font, the rounded-2xl card, the text-gray-600 body, and the 16px scroll reveal are the same failure: a generator picking the global-minimum safe choice and applying it everywhere, because it has no taste to risk and no intent to express.
You break it the same way every time. Stop letting the tool decide. Pick up each element and ask what it's for. The motion that survives that question is the motion worth shipping — and it will never, ever look like a .map() of whileInView down the page.
SHIP CODE THAT LOOKS INTENTIONAL
Scan your frontend for AI patterns. Generate a unique design system. Stop shipping the same blue gradient as everyone else.