Cours Next.js · Module 3: Styles et optimisation visuelle

Animations et transitions dans l'interface utilisateur avec Next.js 16

19 min de lecture
Dans cette leçon10

À Metropolis Quantum, rien n'est immobile. Les véhicules se déplacent en douceur entre les quartiers, les affichages holographiques changent par des transitions élégantes, et les éléments interactifs de la ville répondent aux habitants par des animations subtiles mais significatives. Ce dynamisme ne procure pas seulement un attrait visuel : il améliore avant tout l'utilisabilité et l'intuitivité des systèmes de la ville.

De même, dans les applications web, des animations et des transitions bien conçues peuvent avoir un impact significatif sur l'expérience utilisateur, rendant l'interface plus naturelle, informative et agréable à utiliser. Dans ce chapitre, nous découvrirons différentes techniques d'implémentation des animations et transitions dans les applications Next.js 16.

Types d'animations dans les interfaces web

Avant de passer aux implémentations concrètes, il vaut la peine de connaître les différentes catégories d'animations que l'on trouve dans les interfaces web :

1. Animations de micro-interactions

Des animations subtiles répondant aux actions de l'utilisateur, par ex. :

  • Les effets de survol sur les boutons
  • Les animations de clic
  • Les indicateurs de chargement
  • Les changements d'état des formulaires

2. Animations de transition d'état

Des transitions fluides entre les différents états d'un composant :

  • Les menus déroulants
  • L'agrandissement/réduction des éléments
  • Les changements de visibilité des éléments
  • La bascule de mode (clair/sombre)

3. Animations de navigation

Des animations accompagnant les changements de page ou de vue :

  • Les transitions de page
  • Le changement d'onglet
  • Le défilement de la page
  • Les effets de parallaxe

4. Animations narratives

Des animations plus complexes présentant une histoire ou un processus :

  • Les séquences d'onboarding
  • Les illustrations de processus
  • Les infographies animées
  • Le storytelling visuel

Implémenter des animations dans React et Next.js 16

Dans l'écosystème React et Next.js, nous disposons de nombreuses méthodes pour implémenter des animations :

1. CSS et CSS transitions

La manière la plus simple et la plus performante d'animer des changements simples :

1/* app/globals.css */
2.button {
3  background-color: #3a86ff;
4  color: white;
5  padding: 8px 16px;
6  border-radius: 4px;
7  transition: transform 0.2s ease, background-color 0.2s ease;
8}
9
10.button:hover {
11  transform: translateY(-2px);
12  background-color: #2563eb;
13}

2. Animations CSS

Pour des animations plus complexes avec des cycles définis :

1/* app/globals.css */
2@keyframes fadeIn {
3  from {
4    opacity: 0;
5    transform: translateY(20px);
6  }
7  to {
8    opacity: 1;
9    transform: translateY(0);
10  }
11}
12
13.card {
14  animation: fadeIn 0.5s ease forwards;
15}
16
17/* Sequential animations for a list of elements */
18.card:nth-child(1) { animation-delay: 0.1s; }
19.card:nth-child(2) { animation-delay: 0.2s; }
20.card:nth-child(3) { animation-delay: 0.3s; }

3. React Hooks et useState

Pour des animations dépendant des états React :

1// components/ExpandablePanel.tsx
2'use client';
3
4import { useState } from 'react';
5import styles from './ExpandablePanel.module.css';
6
7export function ExpandablePanel({ title, children }) {
8  const [isExpanded, setIsExpanded] = useState(false);
9
10  return (
11    <div className={styles.panel}>
12      <button
13        className={styles.header}
14        onClick={() => setIsExpanded(!isExpanded)}
15      >
16        <h3>{title}</h3>
17        <span className={`${styles.icon} ${isExpanded ? styles.iconExpanded : ''}`}>
18          ▼
19        </span>
20      </button>
21      <div className={`
22        ${styles.content}
23        ${isExpanded ? styles.contentExpanded : styles.contentCollapsed}
24      `}>
25        {children}
26      </div>
27    </div>
28  );
29}
1/* components/ExpandablePanel.module.css */
2.panel {
3  border: 1px solid #ddd;
4  border-radius: 8px;
5  overflow: hidden;
6  margin-bottom: 16px;
7}
8
9.header {
10  display: flex;
11  justify-content: space-between;
12  align-items: center;
13  padding: 16px;
14  background-color: #f9f9f9;
15  cursor: pointer;
16  width: 100%;
17  text-align: left;
18  border: none;
19}
20
21.icon {
22  transition: transform 0.3s ease;
23}
24
25.iconExpanded {
26  transform: rotate(180deg);
27}
28
29.content {
30  padding: 0 16px;
31  transition: all 0.3s ease;
32  overflow: hidden;
33}
34
35.contentExpanded {
36  max-height: 500px; /* Adjust to your needs */
37  padding: 16px;
38  opacity: 1;
39}
40
41.contentCollapsed {
42  max-height: 0;
43  padding-top: 0;
44  padding-bottom: 0;
45  opacity: 0;
46}

4. React Transition Group

Une bibliothèque qui simplifie la gestion des transitions d'éléments dans React :

1npm install react-transition-group
1// components/Notification.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { CSSTransition } from 'react-transition-group';
6import styles from './Notification.module.css';
7
8interface NotificationProps {
9  message: string;
10  type?: 'success' | 'error' | 'info';
11  duration?: number;
12}
13
14export function Notification({ message, type = 'info', duration = 3000 }: NotificationProps) {
15  const [show, setShow] = useState(true);
16
17  useEffect(() => {
18    const timer = setTimeout(() => {
19      setShow(false);
20    }, duration);
21
22    return () => clearTimeout(timer);
23  }, [duration]);
24
25  return (
26    <CSSTransition
27      in={show}
28      timeout={300}
29      classNames={{
30        enter: styles.notificationEnter,
31        enterActive: styles.notificationEnterActive,
32        exit: styles.notificationExit,
33        exitActive: styles.notificationExitActive,
34      }}
35      unmountOnExit
36    >
37      <div className={`${styles.notification} ${styles[type]}`}>
38        {message}
39      </div>
40    </CSSTransition>
41  );
42}
1/* components/Notification.module.css */
2.notification {
3  position: fixed;
4  bottom: 20px;
5  right: 20px;
6  padding: 16px;
7  border-radius: 8px;
8  color: white;
9  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
10  z-index: 1000;
11}
12
13.success {
14  background-color: #10b981;
15}
16
17.error {
18  background-color: #ef4444;
19}
20
21.info {
22  background-color: #3b82f6;
23}
24
25.notificationEnter {
26  opacity: 0;
27  transform: translateY(20px);
28}
29
30.notificationEnterActive {
31  opacity: 1;
32  transform: translateY(0);
33  transition: opacity 300ms, transform 300ms;
34}
35
36.notificationExit {
37  opacity: 1;
38}
39
40.notificationExitActive {
41  opacity: 0;
42  transform: translateY(20px);
43  transition: opacity 300ms, transform 300ms;
44}

5. Framer Motion

Framer Motion est une puissante bibliothèque d'animation pour React qui simplifie la création d'animations complexes :

1npm install framer-motion
1// components/Card.tsx
2'use client';
3
4import { motion } from 'framer-motion';
5import styles from './Card.module.css';
6
7interface CardProps {
8  title: string;
9  description: string;
10  imageUrl: string;
11  index: number;
12}
13
14export function Card({ title, description, imageUrl, index }: CardProps) {
15  return (
16    <motion.div
17      className={styles.card}
18      initial={{ opacity: 0, y: 20 }}
19      animate={{ opacity: 1, y: 0 }}
20      transition={{
21        duration: 0.5,
22        delay: index * 0.1,
23        ease: "easeOut"
24      }}
25      whileHover={{
26        scale: 1.05,
27        boxShadow: "0 10px 30px rgba(0, 0, 0, 0.15)"
28      }}
29    >
30      <div className={styles.imageContainer}>
31        <img src={imageUrl} alt={title} />
32      </div>
33      <div className={styles.content}>
34        <h3>{title}</h3>
35        <p>{description}</p>
36      </div>
37    </motion.div>
38  );
39}

6. CSS Modules avec Next.js

Next.js permet de combiner facilement les CSS Modules avec les composants :

1// components/Button.tsx
2'use client';
3
4import { useState } from 'react';
5import styles from './Button.module.css';
6
7interface ButtonProps {
8  children: React.ReactNode;
9  onClick?: () => void;
10}
11
12export function Button({ children, onClick }: ButtonProps) {
13  const [isPressed, setIsPressed] = useState(false);
14
15  const handleClick = () => {
16    setIsPressed(true);
17    setTimeout(() => setIsPressed(false), 200);
18    onClick && onClick();
19  };
20
21  return (
22    <button
23      className={`${styles.button} ${isPressed ? styles.pressed : ''}`}
24      onClick={handleClick}
25    >
26      {children}
27    </button>
28  );
29}
1/* components/Button.module.css */
2.button {
3  background-color: #3a86ff;
4  color: white;
5  border: none;
6  border-radius: 4px;
7  padding: 10px 20px;
8  font-size: 16px;
9  cursor: pointer;
10  transition: all 0.2s ease;
11  position: relative;
12  overflow: hidden;
13}
14
15.button:hover {
16  background-color: #2563eb;
17}
18
19.button:before {
20  content: '';
21  position: absolute;
22  top: 50%;
23  left: 50%;
24  width: 0;
25  height: 0;
26  background-color: rgba(255, 255, 255, 0.3);
27  border-radius: 50%;
28  transform: translate(-50%, -50%);
29  transition: width 0.4s ease-out, height 0.4s ease-out;
30}
31
32.button:hover:before {
33  width: 300px;
34  height: 300px;
35}
36
37.pressed {
38  transform: scale(0.98);
39}

Animations de transition de page dans Next.js 16

Next.js 16 utilise React 19.2 : en plus des techniques classiques comme les animations CSS ou Framer Motion, tu peux utiliser le composant <ViewTransition> de React. Dans l'App Router, il fonctionne sans aucune configuration, car chaque navigation est une transition, et les navigateurs qui ne prennent pas en charge la View Transitions API affichent simplement la nouvelle page sans animation.

1. Transitions de base en CSS

1// app/layout.tsx
2export default function RootLayout({
3  children,
4}: {
5  children: React.ReactNode;
6}) {
7  return (
8    <html lang="pl">
9      <body>
10        <main className="page-transition">
11          {children}
12        </main>
13      </body>
14    </html>
15  );
16}
1/* app/globals.css */
2.page-transition {
3  animation: fadeIn 0.5s ease-in-out;
4}
5
6@keyframes fadeIn {
7  from {
8    opacity: 0;
9    transform: translateY(20px);
10  }
11  to {
12    opacity: 1;
13    transform: translateY(0);
14  }
15}

2. Transitions avancées avec Framer Motion

1// components/PageTransition.tsx
2'use client';
3
4import { motion } from 'framer-motion';
5
6const variants = {
7  hidden: { opacity: 0, x: -200, y: 0 },
8  enter: { opacity: 1, x: 0, y: 0 },
9  exit: { opacity: 0, x: 0, y: -100 },
10};
11
12export function PageTransition({ children }: { children: React.ReactNode }) {
13  return (
14    <motion.div
15      variants={variants}
16      initial="hidden"
17      animate="enter"
18      exit="exit"
19      transition={{ type: 'linear', duration: 0.5 }}
20    >
21      {children}
22    </motion.div>
23  );
24}
1// app/page.tsx
2import { PageTransition } from '@/components/PageTransition';
3
4export default function HomePage() {
5  return (
6    <PageTransition>
7      <h1>Bienvenue à Metropolis Quantum</h1>
8      {/* Rest of content */}
9    </PageTransition>
10  );
11}

3. Transitions avec next/navigation

1// components/Navigation.tsx
2'use client';
3
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6import Link from 'next/link';
7import styles from './Navigation.module.css';
8
9export function Navigation() {
10  const router = useRouter();
11  const [isNavigating, setIsNavigating] = useState(false);
12
13  const handleNavigate = (href: string) => {
14    setIsNavigating(true);
15    setTimeout(() => {
16      router.push(href);
17    }, 500); // Give time for exit animation
18  };
19
20  return (
21    <nav className={`${styles.nav} ${isNavigating ? styles.navigating : ''}`}>
22      <ul>
23        <li>
24          <a onClick={() => handleNavigate('/')}>Home</a>
25        </li>
26        <li>
27          <a onClick={() => handleNavigate('/about')}>O nas</a>
28        </li>
29        <li>
30          <a onClick={() => handleNavigate('/services')}>Services</a>
31        </li>
32        <li>
33          <a onClick={() => handleNavigate('/contact')}>Kontakt</a>
34        </li>
35      </ul>
36    </nav>
37  );
38}
1/* components/Navigation.module.css */
2.nav {
3  transition: opacity 0.5s ease;
4}
5
6.navigating {
7  opacity: 0.5;
8  pointer-events: none;
9}

Implémenter des animations d'interface complexes avec Framer Motion

Framer Motion est particulièrement utile pour créer des animations d'interface complexes et interactives. Regardons quelques exemples :

1. Menu mobile animé

1// components/MobileMenu.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion, AnimatePresence } from 'framer-motion';
6import Link from 'next/link';
7import styles from './MobileMenu.module.css';
8
9export function MobileMenu() {
10  const [isOpen, setIsOpen] = useState(false);
11
12  const menuVariants = {
13    closed: {
14      opacity: 0,
15      x: '100%',
16      transition: {
17        type: 'spring',
18        stiffness: 400,
19        damping: 40,
20      },
21    },
22    open: {
23      opacity: 1,
24      x: 0,
25      transition: {
26        type: 'spring',
27        stiffness: 400,
28        damping: 40,
29        staggerChildren: 0.1,
30        delayChildren: 0.2,
31      },
32    },
33  };
34
35  const menuItemVariants = {
36    closed: { opacity: 0, x: 50 },
37    open: { opacity: 1, x: 0 },
38  };
39
40  const buttonVariants = {
41    closed: { rotate: 0 },
42    open: { rotate: 90 },
43  };
44
45  return (
46    <div className={styles.mobileMenuContainer}>
47      <motion.button
48        className={styles.menuButton}
49        onClick={() => setIsOpen(!isOpen)}
50        variants={buttonVariants}
51        animate={isOpen ? 'open' : 'closed'}
52        transition={{ duration: 0.3 }}
53      >
54        <div className={styles.hamburger}>
55          <span className={`${styles.line} ${isOpen ? styles.line1Open : ''}`}></span>
56          <span className={`${styles.line} ${isOpen ? styles.line2Open : ''}`}></span>
57          <span className={`${styles.line} ${isOpen ? styles.line3Open : ''}`}></span>
58        </div>
59      </motion.button>
60
61      <AnimatePresence>
62        {isOpen && (
63          <motion.div
64            className={styles.menuOverlay}
65            variants={menuVariants}
66            initial="closed"
67            animate="open"
68            exit="closed"
69          >
70            <nav className={styles.mobileNav}>
71              <ul>
72                {['Home', 'About', 'Services', 'Contact'].map((item) => (
73                  <motion.li key={item} variants={menuItemVariants}>
74                    <Link href={item === 'Home' ? '/' : `/${item.toLowerCase()}`}>
75                      {item}
76                    </Link>
77                  </motion.li>
78                ))}
79              </ul>
80            </nav>
81          </motion.div>
82        )}
83      </AnimatePresence>
84    </div>
85  );
86}
1/* components/MobileMenu.module.css */
2.mobileMenuContainer {
3  position: fixed;
4  top: 20px;
5  right: 20px;
6  z-index: 1000;
7}
8
9.menuButton {
10  background-color: #3a86ff;
11  border: none;
12  width: 50px;
13  height: 50px;
14  border-radius: 50%;
15  display: flex;
16  align-items: center;
17  justify-content: center;
18  cursor: pointer;
19  z-index: 1001;
20  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
21}
22
23.hamburger {
24  width: 24px;
25  height: 18px;
26  position: relative;
27}
28
29.line {
30  position: absolute;
31  width: 100%;
32  height: 2px;
33  background-color: white;
34  transition: all 0.3s ease;
35}
36
37.line:nth-child(1) {
38  top: 0;
39}
40
41.line:nth-child(2) {
42  top: 8px;
43}
44
45.line:nth-child(3) {
46  top: 16px;
47}
48
49.line1Open {
50  top: 8px;
51  transform: rotate(45deg);
52}
53
54.line2Open {
55  opacity: 0;
56}
57
58.line3Open {
59  top: 8px;
60  transform: rotate(-45deg);
61}
62
63.menuOverlay {
64  position: fixed;
65  top: 0;
66  right: 0;
67  width: 300px;
68  height: 100vh;
69  background-color: white;
70  box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1);
71  z-index: 1000;
72}
73
74.mobileNav {
75  padding: 80px 40px;
76}
77
78.mobileNav ul {
79  list-style: none;
80  padding: 0;
81  margin: 0;
82}
83
84.mobileNav li {
85  margin-bottom: 20px;
86}
87
88.mobileNav a {
89  font-size: 24px;
90  color: #333;
91  text-decoration: none;
92  transition: color 0.3s ease;
93}
94
95.mobileNav a:hover {
96  color: #3a86ff;
97}

2. Cartes animées avec effet de retournement

1// components/FlipCard.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion } from 'framer-motion';
6import styles from './FlipCard.module.css';
7
8interface FlipCardProps {
9  frontContent: React.ReactNode;
10  backContent: React.ReactNode;
11}
12
13export function FlipCard({ frontContent, backContent }: FlipCardProps) {
14  const [isFlipped, setIsFlipped] = useState(false);
15
16  const frontVariants = {
17    flipped: { rotateY: 180, opacity: 0 },
18    unflipped: { rotateY: 0, opacity: 1 }
19  };
20
21  const backVariants = {
22    flipped: { rotateY: 0, opacity: 1 },
23    unflipped: { rotateY: -180, opacity: 0 }
24  };
25
26  return (
27    <div
28      className={styles.flipCardContainer}
29      onClick={() => setIsFlipped(!isFlipped)}
30    >
31      <motion.div
32        className={styles.flipCardFront}
33        variants={frontVariants}
34        animate={isFlipped ? 'flipped' : 'unflipped'}
35        transition={{ duration: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
36      >
37        {frontContent}
38      </motion.div>
39
40      <motion.div
41        className={styles.flipCardBack}
42        variants={backVariants}
43        animate={isFlipped ? 'flipped' : 'unflipped'}
44        transition={{ duration: 0.6, type: 'spring', stiffness: 300, damping: 20 }}
45      >
46        {backContent}
47      </motion.div>
48    </div>
49  );
50}
1/* components/FlipCard.module.css */
2.flipCardContainer {
3  width: 300px;
4  height: 400px;
5  perspective: 1000px;
6  cursor: pointer;
7  position: relative;
8}
9
10.flipCardFront,
11.flipCardBack {
12  position: absolute;
13  width: 100%;
14  height: 100%;
15  backface-visibility: hidden;
16  border-radius: 16px;
17  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
18  display: flex;
19  flex-direction: column;
20  justify-content: center;
21  align-items: center;
22  padding: 20px;
23}
24
25.flipCardFront {
26  background-color: #f9f9f9;
27  color: #333;
28}
29
30.flipCardBack {
31  background-color: #3a86ff;
32  color: white;
33}

3. Barre de progression de défilement

1// components/ScrollProgressBar.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { motion, useScroll } from 'framer-motion';
6import styles from './ScrollProgressBar.module.css';
7
8export function ScrollProgressBar() {
9  const { scrollYProgress } = useScroll();
10
11  return (
12    <motion.div
13      className={styles.progressBar}
14      style={{ scaleX: scrollYProgress }}
15    />
16  );
17}
1/* components/ScrollProgressBar.module.css */
2.progressBar {
3  position: fixed;
4  top: 0;
5  left: 0;
6  right: 0;
7  height: 5px;
8  background-color: #3a86ff;
9  transform-origin: 0%;
10  z-index: 1000;
11}

Optimisation de la performance des animations

Les animations peuvent solliciter le navigateur, il vaut donc la peine d'appliquer des méthodes d'optimisation :

1. Utilise des propriétés CSS qui ne provoquent pas de reflow

Meilleures propriétés à animer :

  • transform
  • opacity
  • filter

Évite d'animer :

  • width, height
  • top, left, right, bottom
  • margin, padding

2. Utilise will-change avec prudence

1.animated-element {
2  will-change: transform, opacity;
3  /* Rest of styles */
4}

Remarque : abuser de will-change peut dégrader la performance.

3. Charger en lazy les bibliothèques d'animation lourdes

1// components/AnimatedSection.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import dynamic from 'next/dynamic';
6
7// Lazy loading Framer Motion
8const motion = dynamic(() => import('framer-motion').then((mod) => mod.motion), {
9  ssr: false,
10  loading: () => <div>Loading...</div>,
11});
12
13export function AnimatedSection({ children }) {
14  const [isClient, setIsClient] = useState(false);
15
16  useEffect(() => {
17    setIsClient(true);
18  }, []);
19
20  if (!isClient) {
21    return <div>{children}</div>;
22  }
23
24  return (
25    <motion.div
26      initial={{ opacity: 0 }}
27      animate={{ opacity: 1 }}
28      transition={{ duration: 0.5 }}
29    >
30      {children}
31    </motion.div>
32  );
33}

4. Utiliser le GPU pour les animations

1.accelerated {
2  transform: translateZ(0); /* Enables GPU acceleration */
3}

Accessibilité des animations

Souviens-toi que certains utilisateurs peuvent préférer des animations réduites, par ex. en raison de troubles vestibulaires.

1. Respecter les préférences système

1@media (prefers-reduced-motion: reduce) {
2  /* Disable or simplify animations */
3  * {
4    animation-duration: 0.01ms !important;
5    animation-iteration-count: 1 !important;
6    transition-duration: 0.01ms !important;
7    scroll-behavior: auto !important;
8  }
9}

2. Implémenter une bascule d'animation

1// components/AnimationToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import styles from './AnimationToggle.module.css';
6
7export function AnimationToggle() {
8  const [areAnimationsEnabled, setAreAnimationsEnabled] = useState(true);
9
10  useEffect(() => {
11    // Check system preferences
12    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
13    setAreAnimationsEnabled(!prefersReducedMotion);
14
15    // Check saved user preferences
16    const savedPreference = localStorage.getItem('animations-enabled');
17    if (savedPreference !== null) {
18      setAreAnimationsEnabled(savedPreference === 'true');
19    }
20  }, []);
21
22  const toggleAnimations = () => {
23    const newValue = !areAnimationsEnabled;
24    setAreAnimationsEnabled(newValue);
25    document.documentElement.classList.toggle('reduce-animations', !newValue);
26    localStorage.setItem('animations-enabled', String(newValue));
27  };
28
29  return (
30    <button
31      className={styles.toggleButton}
32      onClick={toggleAnimations}
33      aria-pressed={!areAnimationsEnabled}
34    >
35      {areAnimationsEnabled ? 'Disable animations' : 'Enable animations'}
36    </button>
37  );
38}
1/* app/globals.css */
2.reduce-animations * {
3  animation-duration: 0.01ms !important;
4  animation-iteration-count: 1 !important;
5  transition-duration: 0.01ms !important;
6  scroll-behavior: auto !important;
7}

Animations liées au défilement

1. Animation de base au défilement avec Intersection Observer

1// components/FadeInSection.tsx
2'use client';
3
4import { useRef, useEffect, useState } from 'react';
5import styles from './FadeInSection.module.css';
6
7export function FadeInSection({ children }) {
8  const [isVisible, setIsVisible] = useState(false);
9  const ref = useRef<HTMLDivElement>(null);
10
11  useEffect(() => {
12    const observer = new IntersectionObserver(
13      ([entry]) => {
14        setIsVisible(entry.isIntersecting);
15      },
16      {
17        root: null,
18        rootMargin: '0px',
19        threshold: 0.1,
20      }
21    );
22
23    if (ref.current) {
24      observer.observe(ref.current);
25    }
26
27    return () => {
28      if (ref.current) {
29        observer.unobserve(ref.current);
30      }
31    };
32  }, []);
33
34  return (
35    <div
36      ref={ref}
37      className={`${styles.fadeInSection} ${isVisible ? styles.isVisible : ''}`}
38    >
39      {children}
40    </div>
41  );
42}
1/* components/FadeInSection.module.css */
2.fadeInSection {
3  opacity: 0;
4  transform: translateY(30px);
5  transition: opacity 1s ease, transform 1s ease;
6}
7
8.isVisible {
9  opacity: 1;
10  transform: translateY(0);
11}

2. Animations de défilement avancées avec Framer Motion

1// components/ScrollAnimation.tsx
2'use client';
3
4import { useRef } from 'react';
5import { motion, useScroll, useTransform } from 'framer-motion';
6import styles from './ScrollAnimation.module.css';
7
8export function ScrollAnimation() {
9  const ref = useRef<HTMLDivElement>(null);
10
11  const { scrollYProgress } = useScroll({
12    target: ref,
13    offset: ["start end", "end start"]
14  });
15
16  const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
17  const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.8, 1, 0.8]);
18  const rotate = useTransform(scrollYProgress, [0, 0.5, 1], [-15, 0, 15]);
19
20  return (
21    <div ref={ref} className={styles.container}>
22      <motion.div
23        className={styles.animatedBox}
24        style={{ opacity, scale, rotate }}
25      >
26        <h2>Animation pendant le défilement</h2>
27        <p>This section animates based on scroll position.</p>
28      </motion.div>
29    </div>
30  );
31}
1/* components/ScrollAnimation.module.css */
2.container {
3  height: 100vh;
4  display: flex;
5  justify-content: center;
6  align-items: center;
7}
8
9.animatedBox {
10  padding: 40px;
11  background-color: #3a86ff;
12  color: white;
13  border-radius: 16px;
14  max-width: 500px;
15  text-align: center;
16}

Cas d'usage dans différents contextes

1. Tableaux de données animés

1// components/DataTable.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion, AnimatePresence } from 'framer-motion';
6import styles from './DataTable.module.css';
7
8interface DataItem {
9  id: string;
10  name: string;
11  value: number;
12  category: string;
13}
14
15interface DataTableProps {
16  data: DataItem[];
17}
18
19export function DataTable({ data }: DataTableProps) {
20  const [sortField, setSortField] = useState<keyof DataItem>('name');
21  const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
22
23  const handleSort = (field: keyof DataItem) => {
24    if (field === sortField) {
25      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
26    } else {
27      setSortField(field);
28      setSortDirection('asc');
29    }
30  };
31
32  const sortedData = [...data].sort((a, b) => {
33    if (a[sortField] < b[sortField]) return sortDirection === 'asc' ? -1 : 1;
34    if (a[sortField] > b[sortField]) return sortDirection === 'asc' ? 1 : -1;
35    return 0;
36  });
37
38  return (
39    <div className={styles.tableContainer}>
40      <table className={styles.table}>
41        <thead>
42          <tr>
43            <th onClick={() => handleSort('name')}>
44              Nom {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
45            </th>
46            <th onClick={() => handleSort('value')}>
47              Value {sortField === 'value' && (sortDirection === 'asc' ? '↑' : '↓')}
48            </th>
49            <th onClick={() => handleSort('category')}>
50              Kategoria {sortField === 'category' && (sortDirection === 'asc' ? '↑' : '↓')}
51            </th>
52          </tr>
53        </thead>
54        <motion.tbody layout>
55          <AnimatePresence>
56            {sortedData.map((item) => (
57              <motion.tr
58                key={item.id}
59                initial={{ opacity: 0, y: 20 }}
60                animate={{ opacity: 1, y: 0 }}
61                exit={{ opacity: 0, y: -20 }}
62                transition={{ duration: 0.3 }}
63                layout
64              >
65                <td>{item.name}</td>
66                <td>{item.value}</td>
67                <td>{item.category}</td>
68              </motion.tr>
69            ))}
70          </AnimatePresence>
71        </motion.tbody>
72      </table>
73    </div>
74  );
75}

2. Interface de chat animée

1// components/ChatInterface.tsx
2'use client';
3
4import { useState } from 'react';
5import { motion, AnimatePresence } from 'framer-motion';
6import styles from './ChatInterface.module.css';
7
8interface Message {
9  id: number;
10  text: string;
11  sender: 'user' | 'assistant';
12  timestamp: Date;
13}
14
15export function ChatInterface() {
16  const [messages, setMessages] = useState<Message[]>([]);
17  const [inputText, setInputText] = useState('');
18
19  const handleSend = () => {
20    if (!inputText.trim()) return;
21
22    // Add user message
23    const userMessage: Message = {
24      id: Date.now(),
25      text: inputText,
26      sender: 'user',
27      timestamp: new Date(),
28    };
29
30    setMessages([...messages, userMessage]);
31    setInputText('');
32
33    // Simulate assistant response
34    setTimeout(() => {
35      const assistantMessage: Message = {
36        id: Date.now() + 1,
37        text: `I received your message: "${inputText}"`,
38        sender: 'assistant',
39        timestamp: new Date(),
40      };
41
42      setMessages((prev) => [...prev, assistantMessage]);
43    }, 1000);
44  };
45
46  return (
47    <div className={styles.chatContainer}>
48      <div className={styles.chatMessages}>
49        <AnimatePresence>
50          {messages.map((message) => (
51            <motion.div
52              key={message.id}
53              className={`${styles.message} ${styles[message.sender]}`}
54              initial={{ opacity: 0, y: 20, scale: 0.9 }}
55              animate={{ opacity: 1, y: 0, scale: 1 }}
56              transition={{ duration: 0.3 }}
57            >
58              <div className={styles.messageContent}>
59                {message.text}
60              </div>
61              <div className={styles.timestamp}>
62                {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
63              </div>
64            </motion.div>
65          ))}
66        </AnimatePresence>
67      </div>
68
69      <div className={styles.chatInput}>
70        <input
71          type="text"
72          value={inputText}
73          onChange={(e) => setInputText(e.target.value)}
74          onKeyPress={(e) => e.key === 'Enter' && handleSend()}
75          placeholder="Write a message..."
76        />
77        <button onClick={handleSend}>Send</button>
78      </div>
79    </div>
80  );
81}

3. Sélection de thème animée

1// components/ThemeToggle.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5import { motion } from 'framer-motion';
6import styles from './ThemeToggle.module.css';
7
8export function ThemeToggle() {
9  const [isDarkMode, setIsDarkMode] = useState(false);
10
11  useEffect(() => {
12    // Check system preferences or previously saved settings
13    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
14    const savedTheme = localStorage.getItem('theme');
15    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
16
17    setIsDarkMode(initialTheme === 'dark');
18    document.documentElement.classList.toggle('dark-theme', initialTheme === 'dark');
19  }, []);
20
21  const toggleTheme = () => {
22    const newMode = !isDarkMode;
23    setIsDarkMode(newMode);
24    document.documentElement.classList.toggle('dark-theme', newMode);
25    localStorage.setItem('theme', newMode ? 'dark' : 'light');
26  };
27
28  return (
29    <button
30      className={styles.themeToggle}
31      onClick={toggleTheme}
32      aria-label={isDarkMode ? 'Switch to light theme' : 'Switch to dark theme'}
33    >
34      <motion.div
35        className={styles.toggleTrack}
36        animate={{ backgroundColor: isDarkMode ? '#3a86ff' : '#ccc' }}
37      >
38        <motion.div
39          className={styles.toggleThumb}
40          animate={{
41            x: isDarkMode ? 22 : 0,
42            backgroundColor: isDarkMode ? '#121212' : '#fff',
43          }}
44          transition={{ type: 'spring', stiffness: 500, damping: 30 }}
45        >
46          <motion.div
47            className={styles.iconContainer}
48            animate={{ opacity: isDarkMode ? 1 : 0 }}
49          >
50            {/* Moon icon */}
51            ◐
52          </motion.div>
53          <motion.div
54            className={styles.iconContainer}
55            animate={{ opacity: isDarkMode ? 0 : 1 }}
56          >
57            {/* Sun icon */}
58            ●
59          </motion.div>
60        </motion.div>
61      </motion.div>
62    </button>
63  );
64}

Bonnes pratiques pour créer des animations

  1. L'intentionnalité : chaque animation devrait avoir un but - informatif ou de navigation
  2. La subtilité : dans la plupart des cas, des animations subtiles valent mieux que des animations exagérées
  3. La cohérence : maintiens un langage d'animation cohérent dans toute l'application
  4. La performance : optimise les animations pour de meilleures performances
  5. L'accessibilité : respecte les préférences des utilisateurs concernant les animations réduites
  6. Le responsive : adapte les animations aux différentes tailles d'écran
  7. Les tests : teste les animations sur différents appareils et navigateurs

Résumé

Les animations et les transitions sont des outils puissants qui peuvent avoir un impact significatif sur l'expérience utilisateur de ton application Next.js 16. Tout comme à Metropolis Quantum, où les animations fluides des affichages holographiques et des systèmes de la ville rendent la technologie plus intuitive et conviviale, des animations bien conçues dans ton application améliorent l'utilisabilité, l'engagement et l'impression générale.

Points clés :

  1. Choisis les bons outils - du simple CSS aux bibliothèques avancées comme Framer Motion
  2. Pense à la performance - optimise les animations pour la fluidité et une faible consommation de ressources
  3. Préserve l'accessibilité - respecte les préférences des utilisateurs concernant les animations réduites
  4. Maintiens la cohérence - crée un langage d'animation cohérent avec l'identité de ton application
  5. L'objectif avant la décoration - chaque animation devrait remplir une fonction précise

Dans le prochain chapitre, nous étudierons les fonctionnalités de performance avancées de Next.js 16 qui permettront à ton application de fonctionner de façon fluide et rapide, tout comme les systèmes les plus avancés de Metropolis Quantum.

Code de cette leçon : app/page.tsx
1// Dark Mode & Theme System - Quantum Metropolis
2'use client';
3
4import { useState } from 'react';
5
6export default function DarkModeDemo() {
7  const [theme, setTheme] = useState<'dark' | 'light' | 'neon'>('dark');
8
9  const themes = {
10    dark: {
11      background: 'linear-gradient(135deg, #0f0f23, #1a1a2e)',
12      surface: 'rgba(255,255,255,0.05)',
13      surfaceBorder: 'rgba(100,255,218,0.2)',
14      text: '#ffffff',
15      textSecondary: '#b0bec5',
16      primary: '#64ffda',
17      secondary: '#7c4dff',
18      cardBg: 'rgba(15,15,35,0.9)'
19    },
20    light: {
21      background: 'linear-gradient(135deg, #f8f9fa, #e9ecef)',
22      surface: 'rgba(255,255,255,0.9)',
23      surfaceBorder: 'rgba(0,0,0,0.1)',
24      text: '#212529',
25      textSecondary: '#6c757d',
26      primary: '#0056cc',
27      secondary: '#4400cc',
28      cardBg: '#ffffff'
29    },
30    neon: {
31      background: 'linear-gradient(135deg, #000000, #1a0033)',
32      surface: 'rgba(255,0,128,0.1)',
33      surfaceBorder: 'rgba(255,0,128,0.5)',
34      text: '#ff0080',
35      textSecondary: '#ff69b4',
36      primary: '#ff0080',
37      secondary: '#00ff7f',
38      cardBg: 'rgba(0,0,0,0.8)'
39    }
40  };
41
42  const currentTheme = themes[theme];
43
44  return (
45    <div style={{
46      minHeight: '100vh',
47      background: currentTheme.background,
48      color: currentTheme.text,
49      padding: '3rem 2rem',
50      transition: 'all 0.5s ease'
51    }}>
52      {/* Theme Toggle */}
53      <div style={{textAlign: 'center', marginBottom: '3rem'}}>
54        <h1 style={{
55          fontSize: '3rem',
56          background: `linear-gradient(45deg, ${currentTheme.primary}, ${currentTheme.secondary})`,
57          WebkitBackgroundClip: 'text',
58          WebkitTextFillColor: 'transparent',
59          marginBottom: '2rem'
60        }}>
61          Dark Mode & Theming
62        </h1>
63
64        <div style={{display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap'}}>
65          <button
66            onClick={() => setTheme('dark')}
67            style={{
68              padding: '0.75rem 2rem',
69              borderRadius: '0.75rem',
70              border: `2px solid ${theme === 'dark' ? currentTheme.primary : currentTheme.surfaceBorder}`,
71              background: theme === 'dark' ? currentTheme.primary : 'transparent',
72              color: theme === 'dark' ? '#0f0f23' : currentTheme.text,
73              fontWeight: 600,
74              cursor: 'pointer',
75              transition: 'all 0.3s ease'
76            }}
77          >
78            Dark
79          </button>
80
81          <button
82            onClick={() => setTheme('light')}
83            style={{
84              padding: '0.75rem 2rem',
85              borderRadius: '0.75rem',
86              border: `2px solid ${theme === 'light' ? currentTheme.primary : currentTheme.surfaceBorder}`,
87              background: theme === 'light' ? currentTheme.primary : 'transparent',
88              color: theme === 'light' ? '#ffffff' : currentTheme.text,
89              fontWeight: 600,
90              cursor: 'pointer',
91              transition: 'all 0.3s ease'
92            }}
93          >
94            Light
95          </button>
96
97          <button
98            onClick={() => setTheme('neon')}
99            style={{
100              padding: '0.75rem 2rem',
101              borderRadius: '0.75rem',
102              border: `2px solid ${theme === 'neon' ? currentTheme.primary : currentTheme.surfaceBorder}`,
103              background: theme === 'neon' ? currentTheme.primary : 'transparent',
104              color: theme === 'neon' ? '#000000' : currentTheme.text,
105              fontWeight: 600,
106              cursor: 'pointer',
107              transition: 'all 0.3s ease'
108            }}
109          >
110            Neon
111          </button>
112        </div>
113      </div>
114
115      {/* CSS Variables Example */}
116      <section style={{maxWidth: '1200px', margin: '0 auto 3rem', background: currentTheme.surface, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
117        <h2 style={{color: currentTheme.primary, marginBottom: '1.5rem'}}>CSS Custom Properties</h2>
118        <pre style={{background: 'rgba(0,0,0,0.3)', padding: '1.5rem', borderRadius: '0.75rem', overflow: 'auto', color: currentTheme.textSecondary, fontSize: '0.875rem'}}>
119{`:root {
120  --color-bg: ${currentTheme.background};
121  --color-surface: ${currentTheme.surface};
122  --color-text: ${currentTheme.text};
123  --color-primary: ${currentTheme.primary};
124  --color-secondary: ${currentTheme.secondary};
125}
126
127[data-theme="dark"] {
128  --color-bg: #0f0f23;
129  --color-text: #ffffff;
130}
131
132[data-theme="light"] {
133  --color-bg: #ffffff;
134  --color-text: #212529;
135}`}
136        </pre>
137      </section>
138
139      {/* Component Examples */}
140      <section style={{maxWidth: '1200px', margin: '0 auto 3rem'}}>
141        <h2 style={{color: currentTheme.primary, marginBottom: '2rem', textAlign: 'center'}}>Themed Components</h2>
142
143        <div style={{display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '2rem'}}>
144          <div style={{background: currentTheme.cardBg, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
145            <div style={{fontSize: '3rem', marginBottom: '1rem', textAlign: 'center'}}>↯</div>
146            <h3 style={{color: currentTheme.primary, marginBottom: '0.75rem', textAlign: 'center', fontSize: '1.5rem'}}>Energy System</h3>
147            <p style={{color: currentTheme.textSecondary, lineHeight: 1.6, textAlign: 'center'}}>
148              Quantum energy management in the metropolis
149            </p>
150            <div style={{marginTop: '1.5rem', width: '100%', height: '8px', background: currentTheme.surface, borderRadius: '4px', overflow: 'hidden'}}>
151              <div style={{width: '78%', height: '100%', background: `linear-gradient(90deg, ${currentTheme.primary}, ${currentTheme.secondary})`, borderRadius: '4px'}}></div>
152            </div>
153          </div>
154
155          <div style={{background: currentTheme.cardBg, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
156            <div style={{fontSize: '3rem', marginBottom: '1rem', textAlign: 'center'}}>◆</div>
157            <h3 style={{color: currentTheme.primary, marginBottom: '0.75rem', textAlign: 'center', fontSize: '1.5rem'}}>AI Core</h3>
158            <p style={{color: currentTheme.textSecondary, lineHeight: 1.6, textAlign: 'center'}}>
159              Intelligence artificielle de nouvelle génération
160            </p>
161            <div style={{marginTop: '1.5rem', width: '100%', height: '8px', background: currentTheme.surface, borderRadius: '4px', overflow: 'hidden'}}>
162              <div style={{width: '92%', height: '100%', background: `linear-gradient(90deg, ${currentTheme.primary}, ${currentTheme.secondary})`, borderRadius: '4px'}}></div>
163            </div>
164          </div>
165
166          <div style={{background: currentTheme.cardBg, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
167            <div style={{fontSize: '3rem', marginBottom: '1rem', textAlign: 'center'}}>◆</div>
168            <h3 style={{color: currentTheme.primary, marginBottom: '0.75rem', textAlign: 'center', fontSize: '1.5rem'}}>Network</h3>
169            <p style={{color: currentTheme.textSecondary, lineHeight: 1.6, textAlign: 'center'}}>
170              Global quantum network
171            </p>
172            <div style={{marginTop: '1.5rem', width: '100%', height: '8px', background: currentTheme.surface, borderRadius: '4px', overflow: 'hidden'}}>
173              <div style={{width: '65%', height: '100%', background: `linear-gradient(90deg, ${currentTheme.primary}, ${currentTheme.secondary})`, borderRadius: '4px'}}></div>
174            </div>
175          </div>
176        </div>
177      </section>
178
179      {/* System Preference Detection */}
180      <section style={{maxWidth: '1200px', margin: '0 auto 3rem', background: currentTheme.surface, padding: '2rem', borderRadius: '1rem', border: `1px solid ${currentTheme.surfaceBorder}`, transition: 'all 0.5s ease'}}>
181        <h2 style={{color: currentTheme.primary, marginBottom: '1.5rem'}}>System Preference Detection</h2>
182        <pre style={{background: 'rgba(0,0,0,0.3)', padding: '1.5rem', borderRadius: '0.75rem', overflow: 'auto', color: currentTheme.textSecondary, fontSize: '0.875rem'}}>
183{`@media (prefers-color-scheme: dark) {
184  :root {
185    --color-bg: #0f0f23;
186    --color-text: #ffffff;
187  }
188}
189
190@media (prefers-color-scheme: light) {
191  :root {
192    --color-bg: #ffffff;
193    --color-text: #212529;
194  }
195}`}
196        </pre>
197      </section>
198    </div>
199  );
200}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quelles propriétés CSS sont les plus performantes à animer (elles ne déclenchent pas de reflow), comme montré dans la leçon ?

  2. 2. À quoi sert la règle CSS @keyframes ?

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne la syntaxe de déclaration d’une transition CSS (sur une seule ligne) :

Articles utiles