logo
ia

Transitions de pages en Next.js : le code, et celle que j'ai supprimée

7 min de lecture
Transitions de pages en Next.js : le code, et celle que j'ai supprimée

Cet article fait partie de la série "Refonte de site web avec l'IA". Épisode précédent : Créer une landing page avec l'IA.


Pourquoi des animations

Un site web, ce n'est pas juste de l'information, c'est une expérience. Et les transitions entre les pages sont le moment où la plupart des sites cassent cette expérience. Un flash blanc, un rechargement brutal, et le lecteur perd le fil.

Je voulais que naviguer sur asuos.ch soit fluide, presque tactile. Comme feuilleter un magazine plutôt que cliquer sur des liens.

Ce que je vais raconter, c'est autant ce qui a marché que ce que j'ai jeté. Parce que la partie intéressante de cet épisode, c'est la seconde.


L'animation que j'ai construite puis supprimée

L'idée de départ : quand on quitte une page, elle ne disparaît pas, elle tombe. Comme une feuille de papier qui glisse de la table. Rotation 3D, translation verticale, et une bordure déchirée générée en SVG pour simuler le bord irrégulier d'une feuille arrachée.

Claude Code l'a implémentée. Un composant de plus de 150 lignes, environ 170 lignes de CSS, keyframes, transforms 3D, courbes d'accélération cubiques. Ça fonctionnait, et c'était spectaculaire.

Elle a vécu quelques jours. Le commit qui l'a remplacée s'appelle fix: smooth fade page transitions, header/footer stay static, et il retire 211 lignes pour en ajouter 49.

Trois raisons, dans l'ordre où elles me sont apparues :

L'en-tête et le pied de page tombaient aussi. L'animation s'appliquait à toute la page. Or un menu qui bascule en 3D à chaque clic, ce n'est pas de la fluidité, c'est de l'instabilité. La correction a consisté à ne faire porter la transition que sur le contenu principal, ce qui rendait du même coup l'effet de chute beaucoup moins lisible.

Elle durait 400 ms. À la première visite, c'est charmant. Au huitième clic dans une session de lecture, c'est une taxe. Une animation de navigation est un coût payé à chaque interaction, pas une fois.

Elle se voyait dans les Core Web Vitals. Une rotation 3D sur un conteneur pleine page, ça repeint beaucoup. Un commit ultérieur, perf: optimize Core Web Vitals for mobile, a d'ailleurs continué le nettoyage en remplaçant un reflow forcé par un double requestAnimationFrame.

Ce qui reste aujourd'hui tient en deux transitions d'opacité :

// components/PageTransition.tsx, sortie
el.style.transition = 'opacity 250ms ease-out'
el.style.opacity = '0'

el.addEventListener(
  'transitionend',
  () => {
    window.scrollTo(0, 0)
    router.push(target)
  },
  { once: true }
)

// Filet de sécurité si transitionend ne se déclenche jamais
setTimeout(() => {
  window.scrollTo(0, 0)
  router.push(target)
}, 300)

Moins impressionnant à raconter. Nettement meilleur à utiliser.

La leçon, et c'est celle de toute la série : une IA implémente n'importe quelle idée d'animation en quelques minutes. Ça rend le coût de production presque nul, et donc ça supprime le filtre naturel qui te faisait renoncer aux mauvaises idées avant. Le travail s'est déplacé. Il n'est plus dans l'implémentation, il est dans la décision de garder ou de jeter.


Le détail qui rend le fondu crédible

Faire disparaître une page est facile. La faire réapparaître sans clignotement l'est moins. Si tu remets l'opacité à zéro puis à un dans le même cycle, le navigateur regroupe les deux changements et tu ne vois aucune transition.

La parade tient en un double requestAnimationFrame :

el.style.transition = 'none'
el.style.opacity = '0'

// Double rAF pour laisser le navigateur appliquer opacity:0
// avant d'animer, sans provoquer de reflow forcé
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    el.style.transition = 'opacity 350ms ease-in'
    el.style.opacity = '1'
  })
})

La solution qu'on trouve partout ailleurs consiste à lire el.offsetHeight pour forcer le navigateur à recalculer la mise en page. Ça marche, et ça coûte un reflow synchrone à chaque navigation. Le double requestAnimationFrame obtient le même résultat sans ce coût.

Autre choix à connaître : l'interception se fait au niveau du document, en phase de capture, et pas en remplaçant chaque lien du site.

document.addEventListener('click', handleClick, true)

Le gestionnaire ignore les clics avec touche modificatrice, les target="_blank", les téléchargements, les ancres, les mailto:, les liens externes et les liens vers la page courante. Cette approche a un avantage inattendu que j'ai mesuré cinq mois plus tard : elle fonctionne avec n'importe quelle balise <a>, y compris celles que j'ai ajoutées bien après. Quand j'ai remplacé mon sélecteur de langue par de vrais liens, la navigation client a continué de marcher sans que j'aie une ligne à changer ici.


La bascule de thème avec la View Transitions API

Quand on passe du thème clair au thème sombre, un cercle se propage depuis le bouton. Et la direction dépend du sens. Vers le clair, l'onde part du bouton et rayonne vers l'extérieur, comme le soleil qui illumine. Vers le sombre, elle part des bords et converge vers le bouton, comme la nuit qui tombe.

Ce n'est pas fait avec un calque plein écran, mais avec l'API native startViewTransition. Le composant calcule l'origine et le rayon nécessaire pour couvrir l'écran, puis les passe au CSS via des variables :

// components/ThemeSwitch.tsx
const x = e.clientX
const y = e.clientY
const maxRadius = Math.hypot(
  Math.max(x, window.innerWidth - x),
  Math.max(y, window.innerHeight - y)
)

document.documentElement.dataset.transition = 'theme'
document.documentElement.style.setProperty('--theme-switch-x', `${x}px`)
document.documentElement.style.setProperty('--theme-switch-y', `${y}px`)
document.documentElement.style.setProperty('--theme-switch-radius', `${maxRadius}px`)

const transition = document.startViewTransition(() => setTheme(nextTheme))

Le CSS n'a plus qu'à animer un clip-path sur la pseudo-classe de vue :

@keyframes theme-reveal-out {
  from {
    clip-path: circle(var(--theme-switch-radius) at var(--theme-switch-x) var(--theme-switch-y));
  }
  to {
    clip-path: circle(0px at var(--theme-switch-x) var(--theme-switch-y));
  }
}

Math.hypot sur la distance au coin le plus éloigné, c'est ce qui garantit que le cercle couvre bien l'écran entier quel que soit l'endroit où se trouve le bouton. Un rayon fixe laisse un coin non couvert dès que la fenêtre change de proportions.


Le scroll infini

Sur les listings, les articles se chargent progressivement. Un IntersectionObserver déclenche le lot suivant avant que le lecteur n'atteigne le bas :

const observer = new IntersectionObserver(
  (entries) => {
    if (entries[0].isIntersecting) loadMore()
  },
  { rootMargin: '200px' }
)

Le rootMargin: '200px' est le paramètre qui compte. La sentinelle se déclenche 200 pixels avant d'entrer dans le champ de vision, donc le contenu suivant est déjà là quand on arrive. Sans cette marge, on voit le vide, puis le chargement. Avec, on ne voit rien du tout, ce qui est exactement le but.


L'accessibilité n'est pas une option de fin de chantier

Le support de prefers-reduced-motion est présent aux trois endroits, et pas comme une réduction de durée. Les animations sont désactivées, pas atténuées. Pour certaines personnes, le mouvement à l'écran ne relève pas du goût mais du confort physique, voire de la nausée.

if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  el.style.opacity = '1'
  return
}

Et côté CSS, pour la bascule de thème :

@media (prefers-reduced-motion: reduce) {
  [data-transition='theme']::view-transition-old(root),
  [data-transition='theme']::view-transition-new(root) {
    animation: none !important;
  }
}

La bascule de thème vérifie aussi la disponibilité de l'API avant de s'en servir, et bascule sans animation quand elle est absente. Une animation qui ne se dégrade pas proprement n'est pas une animation soignée, c'est un bug qui attend son navigateur.


Ce que je retiens

L'idée de la page qui tombe comme une feuille de papier venait d'une envie créative, pas de l'IA. L'implémentation, le CSS, les transforms 3D, le SVG de bordure déchirée, c'était Claude Code, en quelques minutes. La décision de tout jeter, c'était moi, quatre jours plus tard.

C'est là que se situe la vraie répartition des rôles. L'IA a rendu l'exécution quasi gratuite. Ce qui reste rare, c'est le jugement pour distinguer ce qui impressionne dans une démo de ce qui tient au huitième clic.

Ces animations qui donnent du caractère à un site, c'est le genre de finition qu'on intègre en une journée lors d'un Sprint Digital. De l'idée concrète au déploiement, sans semaines d'allers-retours.


Épisode suivant de la série : l'audit SEO complet du site, et les surprises qu'il a révélées.

Toni Dias

Toni Dias

Ingénieur logiciel et partenaire technique · AsuOs

Prêt à transformer votre business digital ?

Toni Dias vous accompagne dans votre stratégie digitale avec des solutions sur mesure.