Le fade-in-up : ce tic d'animation qui trahit les sites générés par IA
Chaque section qui monte de 16 pixels et s'estompe, toujours la même durée, le même ease-out : c'est le text-gray-600 du mouvement. Voici pourquoi l'IA y tombe à chaque fois, et comment animer avec une vraie intention.
Faites défiler n'importe quelle landing page sortie de v0, Lovable ou Bolt depuis dix-huit mois. Le hero est déjà là. Puis vous scrollez, et la grille de fonctionnalités monte de 16 pixels en s'estompant à l'apparition. Vous continuez : les témoignages montent de 16 pixels et apparaissent. Les cartes de tarifs montent de 16 pixels et apparaissent. L'accordéon de la FAQ, le CTA du footer, le mur de logos : tous pareil, les mêmes 16 pixels, les mêmes 500 millisecondes, le même ease-out, le même déclenchement à 30 % d'entrée dans le viewport.
Ça, c'est le fade-in-up. C'est le text-gray-600 du mouvement. Et une fois que vous l'avez repéré, impossible de ne plus le voir : chaque section de la page fait le même petit saut, comme une troupe de danse qui ne connaît qu'un seul pas.
Toujours le même code, parce que c'est toujours le même code
Voici la version Framer Motion. Si vous avez touché à un builder IA cette année, vous avez livré ça mot pour mot :
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" }}
>Parfois y: 20, parfois y: 16, à l'occasion y: 24. La durée est de 0.5 ou 0.6. L'easing est easeOut, parce que c'est le premier easing de la doc et celui qui "rend bien" pour un modèle qui n'a jamais rien ressenti. viewport={{ once: true }} est là parce que quelqu'un sur Stack Overflow s'est plaint que l'animation se redéclenchait au scroll vers le haut, et que cette réponse traîne dans les données d'entraînement.
La version sans React, c'est AOS (Animate On Scroll), et elle est encore plus à nu :
<div data-aos="fade-up" data-aos-duration="600" data-aos-once="true">Ou l'Intersection Observer fait main que ChatGPT vous pond quand vous demandez "des animations au scroll sans librairie" :
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);
}Trois stacks différentes. Un seul et même résultat : translateY(20px), 600ms, ease-out, déclenchement unique à un seuil de 10 %. La librairie change, l'empreinte non. Cette convergence, des outils différents pour un artefact identique, c'est toute la thèse de pourquoi tous les sites générés par IA se ressemblent, et le mouvement n'est que la partie que tout le monde a oublié d'auditer.
Pourquoi c'est précisément cette animation qui est devenue le défaut
Ce n'est pas par hasard que la machine atterrit là. Le fade-in-up est le minimum global du "mouvement sans risque", et toutes les forces poussent vers lui.
Il ne peut pas casser la mise en page. Un petit décalage vertical de 16px sur l'opacité ne provoque pas de reflow, ne déclenche pas de scrollbars horizontales, ne déborde pas d'un conteneur, ne se bat pas avec la grille. Une IA qui optimise pour "ça s'affiche sans erreur du premier coup" choisit la transformation qu'il est impossible de rater. Animer width ou height, c'est risquer le layout thrashing. Animer scale au-delà d'un certain point, ça rogne. translateY(20px) + opacity, c'est la seule combinaison à zéro mode de défaillance, et accessoirement les deux seules propriétés que le navigateur sait animer hors du thread principal, sur le compositeur GPU, donc ça ne saccade jamais. La machine est tombée sur la réponse correcte la moins chère.
C'est littéralement le premier exemple de chaque doc. Ouvrez la page d'accueil de Framer Motion. La section whileInView utilise un fondu-montée. La démo d'AOS ouvre sur fade-up. Le starter de ScrollTrigger de GSAP, c'est un tween sur y avec opacity. Les modèles sont entraînés sur la documentation et sur les articles de blog qui recopient la documentation, donc l'animation la plus documentée devient la plus générée. C'est le même mécanisme qui a fait que font-sans se résout en Inter partout, comme on l'explique dans le problème de la typographie. Le défaut de la doc devient le défaut du monde.
Pour un non-designer, ça fait "premium". Pour quelqu'un qui ne sait pas verbaliser pourquoi, n'importe quel mouvement paraît plus soigné que pas de mouvement du tout. Le modèle sait, statistiquement, que "landing page moderne" cooccurre avec "scroll reveal", alors il ajoute le reveal. Il ne sait pas que le mouvement a un rôle. Il sait que le mouvement est corrélé à l'adjectif demandé dans le prompt.
Personne ne conteste. La personne qui tape "fais-moi une landing page SaaS" n'a aucune opinion sur les courbes d'easing. Elle voit des choses bouger, elle pense "ça a l'air vivant", elle livre. La boucle de feedback qui punirait le mouvement uniforme ne tourne jamais.
Pourquoi le mouvement uniforme sent la machine
Voici ce qui échappe aux builders. Le problème n'est pas le fade-in-up en soi. Un seul fondu-montée bien placé sur un titre de hero, c'est très bien, c'est même bon. Le problème, c'est que chaque élément reçoit le même.
Le vrai motion design est une hiérarchie d'intentions. Le titre principal monte et s'estompe. Le sous-titre suit 80ms plus tard, un décalage, parce qu'il est subordonné. Le bouton CTA, lui, ne s'estompe pas du tout : il est là dès la première frame, peut-être avec un léger scale qui se pose, parce que vous voulez qu'il soit cliquable tout de suite. Une capture d'écran du produit glisse depuis le côté parce qu'elle entre dans la conversation. Un compteur de chiffres défile vers le haut parce que le mouvement *est* l'information. Chaque choix répond à une question : quel est le rôle de cet élément, et que doit dire le mouvement à son sujet ?
Le mouvement IA ne répond à aucune question. Il applique une seule transformation à un .map() sur vos sections. La carte de témoignage et les mentions légales du footer s'animent à l'identique parce que, pour le générateur, ce sont deux dans un tableau. C'est ça, le signe. Les humains différencient, les machines itèrent. Quand l'œil voit le footer faire la même entrée que le hero, une part préconsciente du cerveau lève un drapeau : *rien ici n'a été décidé.* C'est l'équivalent en mouvement de toutes ces cartes en rounded-2xl avec shadow-sm : l'uniformité est la signature. Les 23 signes de code généré par IA couvrent la version statique de tout ça, le mouvement est la même maladie dans la dimension temporelle.
Il y a un second signe par-dessus : le stagger qui n'en est pas un. Quand l'IA tente quand même un peu de variété, elle décale une grille avec transition={{ delay: index * 0.1 }}. Et voilà six cartes de fonctionnalités qui défilent en cascade. Ça fait plus chic. C'est pire. Un délai de 100ms par élément sur six éléments, ça veut dire que l'utilisateur attend 600ms pour la dernière carte, et cette cascade n'a aucun sens : la carte 6 n'est pas plus importante que la carte 1, alors pourquoi arrive-t-elle plus tard ? Le stagger mécanique, c'est du mouvement déguisé en chorégraphie. Vous le repérez à l'autre bout de la pièce parce que le rythme est parfaitement linéaire : 0, 100, 200, 300. Rien, dans le bon design, ne se déplace sur un planning de délais parfaitement linéaire.
Et puis l'easing. ease-out sur tout. ease-out, ça veut dire départ rapide et fin lente : parfait pour quelque chose qui *arrive* et se pose. Mais l'IA l'utilise aussi pour les sorties, pour les hovers, pour tout, parce que c'est le défaut sans risque. Le vrai mouvement panache : ease-out pour les entrées, ease-in pour les sorties, un cubic-bezier(0.34, 1.56, 0.64, 1) sur mesure avec overshoot pour ce qui est joueur, du linéaire uniquement pour les boucles continues. Une page où tout partage une seule courbe d'easing a l'étendue tonale d'une seule touche de piano.
Comment le repérer en 30 secondes
Ouvrez les DevTools, allez dans le panneau Animations (Chrome : More Tools puis Animations) et scrollez. Si chaque animation enregistrée partage la même durée, le même easing et la même distance de transformation, vous avez une page générée sous les yeux. Ou lisez la source : cherchez dans le bundle whileInView, data-aos, ou une classe .reveal avec translateY. Puis vérifiez : c'est appliqué à *un* élément avec intention, ou bien .map() sur tout le document ?
L'heuristique la plus rapide, sans aucun outil : scrollez à une vitesse de lecture normale et demandez-vous si le mouvement vous a appris quoi que ce soit. Est-ce que quelque chose a attiré votre regard sur ce qui comptait ? Ou est-ce que toute la page a tressauté vers le haut en séquence pendant que vous descendiez ? Si c'est la seconde option, le mouvement est de la décoration posée par un générateur, une ligne de plus dans la checklist de détection d'IA en 30 secondes, juste à côté du dégradé bleu-violet et de la police Geist.
Encore un indice : le flash au chargement. Beaucoup de montages de scroll-reveal IA mettent opacity: 0 en CSS mais déclenchent le reveal en JS. Sur une connexion lente, ou avec le JS désactivé, le contenu au-dessus de la ligne de flottaison est invisible : une page blanche jusqu'à ce que l'observer se déclenche. Ça plombe aussi les perfs mesurées : chaque élément qui surgit après le premier rendu compte dans le Cumulative Layout Shift, et une page qui cache son hero derrière un observer peut afficher un CLS au-delà de 0,25 (le seuil "mauvais" de Google est à 0,1) et perdre complètement son candidat Largest Contentful Paint, parce que l'élément LCP n'était pas peint au moment où le navigateur a regardé. Une vraie implémentation ne cache jamais le contenu derrière un script. La version générée, elle, livrera tranquillement une page qui reste blanche au premier rendu pour un lecteur d'écran et pour un crawl Lighthouse, exactement le genre de chose que la machinerie helpful-content de Google apprend à sanctionner.
Un mouvement qui a une intention
Voici le recadrage. Avant d'ajouter la moindre transition, répondez à une question par élément animé : quel est le rôle de ce mouvement ? Si vous ne savez pas nommer le rôle, supprimez l'animation. "Ça fait joli" n'est pas un rôle.
Quand le mouvement a un rôle, le code cesse de lui-même d'être uniforme, parce que des rôles différents appellent des mouvements différents :
// Role : diriger l'oeil vers l'affirmation principale, puis laisser la
// ligne d'appui suivre comme subordonnee. Un stagger, intentionnel.
<motion.h1
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
>
Divisez votre facture AWS par deux.
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.12 }} // suit le titre
>
On audite les instances inactives que vous avez oublie d'eteindre.
</motion.p>
// Role : aucun. Le CTA est le but. Il est la des la premiere frame.
<a href="/start" className="btn-primary">Lancer l'audit</a>Remarquez ce qui change. Le titre utilise une vraie courbe d'easing, [0.22, 1, 0.36, 1], une décélération rapide, pas le easeOut fainéant. Le sous-titre ne fait que s'estomper, sans y, parce qu'il ne fait pas une entrée : il rattrape le titre. Le CTA, lui, ne s'anime pas du tout. Trois éléments, trois décisions. C'est l'exact contraire d'un .map().
Pour le scroll, la règle est : ne révélez que ce que l'utilisateur ne pouvait pas voir de toute façon, et révélez-le parce que le voir compte maintenant. Un graphique qui dessine ses barres en entrant dans le viewport : le mouvement *est* la donnée qui se révèle, c'est mérité. Un tableau de tarifs qui ne fait rien au scroll, parce que c'est un tableau et que vous voulez le lire, pas le regarder : correct aussi. L'instinct généré, c'est "tout se révèle". L'instinct conçu, c'est "presque rien ne se révèle, et ce qui se révèle a une raison".
Et faites la version accessible correctement, en CSS, pour que le contenu ne soit jamais conditionné au JS et que les utilisateurs en reduced-motion aient le contenu sans le moindre saut :
.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; }
}Ce dernier bloc, c'est la ligne que le générateur n'écrit presque jamais. À peu près un utilisateur de macOS et d'iOS sur trois active "Réduire les animations" à un moment ou un autre. Livrer sans cette media query, ça veut dire que votre reveal "premium" n'est, pour eux, qu'une mise en page qui tressaute.
Quelques gestes anti-slop, en pratique :
- Variez par rôle, pas par index. Pas de
delay: i * 0.1. Ne décalez que quand les éléments forment une vraie séquence : un processus numéroté, une timeline. Pour une grille de cartes de poids égal, faites apparaître tout le groupe ensemble, ou ne le faites pas apparaître du tout. - Mélangez vos easings. Les entrées décélèrent. Les sorties accélèrent. Réservez l'overshoot (
cubic-bezier(0.34, 1.56, 0.64, 1)) à un ou deux moments joueurs pour qu'il reste spécial. Une page avec trois courbes distinctes paraît écrite ; une page avec une seule paraît imprimée. - Animez une propriété inattendue. Tout le monde fait de l'opacity et du translate. Un balayage en
clip-path, unfilter: blur(8px)qui se pose net, un seul accent qui passe de#64748bà#0ea5e9: ça se lit comme délibéré justement parce que le générateur n'y pense jamais. - Tuez le reveal sous la ligne de flottaison sur les contenus longs. Un article ou une page de doc n'a pas besoin que ses paragraphes montent quand vous scrollez. C'est du mouvement pour le mouvement, et ça ralentit activement la lecture.
- Ne cachez jamais le contenu derrière le JS. Le mouvement est une couche d'amélioration, pas un verrou. Visible au premier rendu, toujours.
Le fond du sujet traverse tout ce blog. Le défaut IA n'est pas mauvais parce qu'il est laid : le fade-in-up est parfaitement agréable pendant une demi-seconde. Il est mauvais parce qu'il est *indifférencié*, et l'indifférencié est l'empreinte réelle de la production machine. Le dégradé, la police, la carte rounded-2xl, le corps de texte en text-gray-600 et le reveal au scroll de 16px sont le même échec : un générateur qui choisit l'option sans risque du minimum global et l'applique partout, parce qu'il n'a pas de goût à risquer ni d'intention à exprimer.
On le casse de la même façon à chaque fois. Arrêtez de laisser l'outil décider. Prenez chaque élément un par un et demandez-vous à quoi il sert. Le mouvement qui survit à cette question est le mouvement qui mérite d'être livré, et il ne ressemblera jamais, au grand jamais, à un .map() de whileInView qui descend la 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.