If you have generated a landing page with Claude, v0, or Lovable in the last six months, you already know the animation the model reaches for by default. Every element fades in from below. All at the same time. On a 300ms ease curve. Sometimes it staggers by 50ms if the model is feeling fancy. The result is a UI that has motion but no intent, and human designers can spot it in one scroll.
Motion is one of the loudest tells in AI-generated frontends. Below are four patterns I use to replace the default fade-in-everything output. None of them require a heavy runtime. Most are CSS-only, one leans on the newly-shipped View Transitions API. The point is not to add motion. The point is to make motion mean something.
Pattern 1: Staggered reveals with the right easing
The problem: animation: fadeIn 0.3s ease on every element is what an AI model outputs because it is the safest, most-quoted snippet on the internet. It is not wrong. It is just a template.
The fix is two-part: stagger the reveals so the page reads top-to-bottom, and pick an easing curve that has some character. The default ease is a soft parabola. cubic-bezier(0.16, 1, 0.3, 1) (the "expo out" curve) starts fast and coasts to a stop. It reads as intentional. ease reads as random.
@keyframes reveal-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.reveal {
animation: reveal-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.reveal--delay-1 { animation-delay: 0.1s; }
.reveal--delay-2 { animation-delay: 0.2s; }
.reveal--delay-3 { animation-delay: 0.35s; }
.reveal--delay-4 { animation-delay: 0.5s; }
The delay progression matters. Linear delays (0.1s, 0.2s, 0.3s, 0.4s) look mechanical. A slight non-linearity (0.1, 0.2, 0.35, 0.5) reads as a hand tuning it, because it was a hand tuning it. The gap between the eyebrow and the headline is small; they belong together. The gap before the CTA is bigger, because the user has to shift attention.
<section class="hero">
<span class="reveal reveal--delay-1 eyebrow">Product Launch 2026</span>
<h1 class="reveal reveal--delay-2">Build different, ship faster.</h1>
<p class="reveal reveal--delay-3">The platform that grows with you.</p>
<a class="reveal reveal--delay-4 btn-primary" href="/demo">See demo</a>
</section>
The AI default treats these four elements as a group. Staggered reveals with tuned delays treat them as a sequence. The user reads them in the order I want them read.
Pattern 2: Scroll-triggered enters, not page-load enters
The problem: firing every animation on page load turns the whole page into an opening cinematic. Anything below the fold has already animated by the time the user scrolls to it.
The fix is IntersectionObserver. Elements start invisible and enter when they scroll into view. No library.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
observer.unobserve(entry.target);
}
});
},
{
threshold: 0.15,
rootMargin: '0px 0px -50px 0px',
}
);
document.querySelectorAll('[data-scroll]').forEach((el) => {
observer.observe(el);
});
The rootMargin: '0px 0px -50px 0px' is doing quiet work. It says "trigger the animation 50px before the element hits the viewport bottom." Without it, the animation fires exactly when the element becomes visible, which reads as "just in time." With the negative bottom margin, the animation fires slightly early, so the element is already animating when the user scrolls to it.
[data-scroll] {
opacity: 0;
transform: translateY(32px);
transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
[data-scroll].in-view {
opacity: 1;
transform: translateY(0);
}
[data-scroll="from-left"] { transform: translateX(-32px); }
[data-scroll="from-right"] { transform: translateX(32px); }
[data-scroll="scale-up"] { transform: scale(0.95); }
Direction variants matter. Everything entering from the same direction is the AI default. A card grid where alternating cards enter from left and right reads as intentional composition, not as "the model added a scroll animation." The tell is not the presence of motion. It is whether the motion has a pattern.
observer.unobserve() is the small optimization that matters at scale. Without it, the observer keeps watching elements that have already animated. On a long page with 100 scroll-triggered elements, you save real CPU. I know this because I shipped a page without unobserve once, and the DevTools performance graph on scroll looked like a mountain range I did not want to hike.
Pattern 3: Hover states with intent (not just :hover { opacity: 0.8 })
The problem: the AI default hover is opacity: 0.8 or background: darker-shade. It communicates "this is a link" and nothing else. On buttons that is fine. On cards, links, and secondary actions, it wastes an opportunity to convey what will happen when you click.
Three hover patterns that carry more information:
/* Card: lift and cast a real shadow */
.card {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.04),
0 12px 24px rgba(0, 0, 0, 0.08);
}
/* Link: underline that grows from the left */
.link-underline {
position: relative;
text-decoration: none;
}
.link-underline::after {
content: '';
position: absolute;
left: 0;
bottom: -2px;
width: 0;
height: 2px;
background: currentColor;
transition: width 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.link-underline:hover::after {
width: 100%;
}
/* Button: press feedback */
.btn-magnetic {
transition: transform 0.15s ease;
}
.btn-magnetic:hover { transform: scale(1.03); }
.btn-magnetic:active { transform: scale(0.97); }
The card's cubic-bezier(0.34, 1.56, 0.64, 1) is a spring curve. It overshoots slightly before settling. On a lift-on-hover it makes the card feel light. On something that is dropping down, it would feel wrong. The curve carries physical meaning.
The link's growing underline also does more than an underline that toggles on. It signals direction: you are hovering, the underline is filling in, click and it will act. The AI default :hover { text-decoration: underline } is on/off with no readable state in between.
The button's press-scale is the smallest of the three and the one most people skip. transform: scale(0.97) on :active mimics the physical give of a real button. It is 8 lines of CSS and it is the difference between "this is clickable" and "this responds to me."
Pattern 4: View Transitions API for page changes
The problem: SPA navigation replaces the entire main content in one frame. There is no transition, so the new content just appears. The AI default is to add a fade on the wrapper, which fades the entire page through a blank state. It looks worse than the abrupt swap.
The fix in 2026 is the browser-native View Transitions API. Same-document transitions are stable in Chrome 111+, Edge 111+, Firefox 133+, and Safari 18+. Cross-document (multi-page) transitions ship in Chrome 126+ and Safari 18.2+, with Firefox still behind a flag.
async function navigate(url) {
if (!document.startViewTransition) {
window.location.href = url;
return;
}
const transition = document.startViewTransition(async () => {
const html = await fetch(url).then(r => r.text());
document.querySelector('main').innerHTML =
new DOMParser().parseFromString(html, 'text/html')
.querySelector('main').innerHTML;
});
await transition.finished;
history.pushState({}, '', url);
}
The default is a cross-fade. Override it with a directional slide to make the navigation feel like going somewhere:
::view-transition-old(root) {
animation: slide-out-left 0.3s cubic-bezier(0.4, 0, 1, 1) both;
}
::view-transition-new(root) {
animation: slide-in-right 0.3s cubic-bezier(0, 0, 0.2, 1) both;
}
@keyframes slide-out-left {
to { transform: translateX(-5%); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(5%); opacity: 0; }
}
The old view slides out to the left, the new view slides in from the right. Cognitively this reads as "moving forward." Reverse both for back navigation. The -5% and +5% are intentionally small; big translations look like carousels.
Note that startViewTransition accepts an async callback. The browser snapshots the DOM before the callback runs, snapshots again after, and animates between them. You do not manage the transition state. The browser does. This is much less code than the Framer Motion AnimatePresence + layoutId equivalent, which is why I have been replacing it in every new project.
The one line that keeps this accessible
Ship any of these and the last thing to add is the prefers-reduced-motion guard. Not because it is polite. Because animation on vestibular-disorder screens causes motion sickness that the user experiences whether or not you shipped the accessibility check.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
That block goes at the bottom of your CSS. It disables every animation and transition on the page for users who set the system preference. The !important is one of the few legitimate uses of that flag, because you are overriding your own component styles for an accessibility reason.
If you are using JavaScript to add motion classes conditionally:
const prefersReduced = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (!prefersReduced) {
element.classList.add('reveal');
}
Both patterns cover the same case. Use the CSS one for CSS animations. Use the JS check when you are adding motion classes dynamically.
Why these four in particular
I picked these four because they cover the four places AI-generated UI motion falls apart the hardest:
- Page load. The AI default is all-at-once. Staggered reveals give the eye a path.
- Scroll. The AI default is page-load animations firing far below the fold. IntersectionObserver moves the animation to where the user actually is.
- Hover. The AI default is opacity change. Real hover states carry information about what will happen.
- Navigation. The AI default is a fade or nothing. View Transitions API is one browser API away from cinematic page changes.
Each replaces one lazy default with one intentional choice. That is the whole trick. Human designers are not adding more motion than the AI. They are adding motion that means something in each of those four spots.
The reason the list is exactly four and not fourteen is that I have shipped all of them wrong at least once. The list is short because the mistakes were expensive.
If you want the version of this that covers the other five places AI-generated UI leaks its origin (color palette, spacing rhythm, typography scale, imagery, and layout composition), the full guide is Claude Code Mastery. The book's frontend chapter is about how to hand-tune AI-generated UI code so it stops reading as template output. Chapter 11 is the motion playbook the article above compresses. The other chapters are the four remaining tells.
Top comments (0)