Cours JavaScript et React · Module 7: Styles et animations

React Transition Group - Animer les voyages interstellaires de l'UI

10 min de lecture
Dans cette leçon6

Passer d'un état ou d'une vue à l'autre dans une application spatiale devrait être aussi fluide qu'un vaisseau filant à travers l'hyperespace. L'apparition et la disparition brusques d'éléments d'interface ressemblent plus à une panne système qu'à une expérience utilisateur bien conçue. Dans cette partie de la mission, nous allons découvrir React Transition Group, une bibliothèque qui nous aidera à créer des animations de transition fluides et cosmiquement réussies dans nos interfaces.

Qu'est-ce que React Transition Group ?

React Transition Group est une bibliothèque d'API bas niveau pour animer l'entrée et la sortie des composants. Contrairement aux solutions d'animation toutes faites, elle ne fournit pas d'effets prédéfinis : elle donne plutôt aux développeurs un contrôle total sur le processus d'animation en gérant les classes ou styles CSS aux moments clés du cycle de vie du composant.

C'est comme le panneau de contrôle d'un vaisseau : il ne pilote pas directement le vol, mais te donne les outils pour manœuvrer avec précision.

Les composants clés de React Transition Group

1. Transition

Le composant de base, de plus bas niveau, qui gère les états de transition d'un composant :

1import { Transition } from 'react-transition-group';
2import React, { useState } from 'react';
3
4// Styles pour les différents états
5const duration = 300;
6const defaultStyle = {
7  transition: `opacity ${duration}ms ease-in-out`,
8  opacity: 0,
9}
10
11const transitionStyles = {
12  entering: { opacity: 1 },
13  entered:  { opacity: 1 },
14  exiting:  { opacity: 0 },
15  exited:   { opacity: 0 },
16};
17
18function FadeExample() {
19  const [inProp, setInProp] = useState(false);
20
21  return (
22    <div>
23      <button onClick={() => setInProp(!inProp)}>
24        {inProp ? 'Disparaître' : 'Apparaître'}
25      </button>
26
27      <Transition in={inProp} timeout={duration}>
28        {state => (
29          <div style={{
30            ...defaultStyle,
31            ...transitionStyles[state]
32          }}>
33            Message depuis l'espace
34          </div>
35        )}
36      </Transition>
37    </div>
38  );
39}

2. CSSTransition

Étend Transition avec l'ajout et la suppression automatiques de classes CSS, ce qui facilite la création d'animations :

1import { CSSTransition } from 'react-transition-group';
2import React, { useState } from 'react';
3import './styles.css';
4
5function AlertExample() {
6  const [showAlert, setShowAlert] = useState(false);
7
8  return (
9    <div>
10      <button onClick={() => setShowAlert(prev => !prev)}>
11        {showAlert ? "Masquer l'alerte" : "Afficher l'alerte"}
12      </button>
13
14      <CSSTransition
15        in={showAlert}
16        timeout={300}
17        classNames="alert"
18        unmountOnExit
19      >
20        <div className="alert">
21          <h4>Message du quartier général !</h4>
22          <p>Une nouvelle planète propice à la colonisation a été détectée.</p>
23        </div>
24      </CSSTransition>
25    </div>
26  );
27}

Et dans le fichier CSS :

1/* styles.css */
2.alert {
3  background-color: #121a2b;
4  color: white;
5  padding: 20px;
6  border-radius: 8px;
7  box-shadow: 0 0 20px rgba(82, 161, 255, 0.5);
8  margin-top: 20px;
9}
10
11.alert-enter {
12  opacity: 0;
13  transform: scale(0.9);
14}
15
16.alert-enter-active {
17  opacity: 1;
18  transform: scale(1);
19  transition: opacity 300ms, transform 300ms;
20}
21
22.alert-exit {
23  opacity: 1;
24  transform: scale(1);
25}
26
27.alert-exit-active {
28  opacity: 0;
29  transform: scale(0.9);
30  transition: opacity 300ms, transform 300ms;
31}

3. TransitionGroup

Gère une collection de composants Transition ou CSSTransition, idéal pour animer des listes :

1import { TransitionGroup, CSSTransition } from 'react-transition-group';
2import React, { useState } from 'react';
3import './styles.css';
4
5function StarsList() {
6  const [items, setItems] = useState([
7    { id: 1, name: 'Alpha Centauri' },
8    { id: 2, name: 'Proxima Centauri' },
9    { id: 3, name: 'Betelgeuse' }
10  ]);
11
12  const [nextId, setNextId] = useState(4);
13
14  const addStar = () => {
15    const newStars = [
16      'Sirius', 'Vega', 'Polaris', 'Canopus', 'Arcturus',
17      'Antares', 'Rigel', 'Procyon', 'Deneb'
18    ];
19    const randomStar = newStars[Math.floor(Math.random() * newStars.length)];
20
21    setItems([...items, { id: nextId, name: randomStar }]);
22    setNextId(nextId + 1);
23  };
24
25  const removeStar = id => {
26    setItems(items.filter(item => item.id !== id));
27  };
28
29  return (
30    <div>
31      <h2>Étoiles à visiter</h2>
32      <button onClick={addStar}>Ajouter une étoile</button>
33
34      <TransitionGroup className="stars-list">
35        {items.map(({ id, name }) => (
36          <CSSTransition
37            key={id}
38            timeout={500}
39            classNames="star-item"
40          >
41            <div className="star-item">
42              <span>{name}</span>
43              <button onClick={() => removeStar(id)}>
44                &times;
45              </button>
46            </div>
47          </CSSTransition>
48        ))}
49      </TransitionGroup>
50    </div>
51  );
52}

Le CSS pour cet exemple :

1/* styles.css */
2.stars-list {
3  margin-top: 20px;
4}
5
6.star-item {
7  display: flex;
8  justify-content: space-between;
9  align-items: center;
10  padding: 12px 20px;
11  margin-bottom: 10px;
12  background-color: #0d1b2a;
13  border-radius: 8px;
14  color: #e0f7fa;
15  border-left: 4px solid #4fc3f7;
16}
17
18.star-item-enter {
19  opacity: 0;
20  transform: translateX(-30px);
21}
22
23.star-item-enter-active {
24  opacity: 1;
25  transform: translateX(0);
26  transition: opacity 500ms, transform 500ms;
27}
28
29.star-item-exit {
30  opacity: 1;
31}
32
33.star-item-exit-active {
34  opacity: 0;
35  transform: translateX(30px);
36  transition: opacity 500ms, transform 500ms;
37}

Techniques avancées

1. Animations séquentielles

Animer les éléments l'un après l'autre pour un effet en cascade :

1import { CSSTransition, TransitionGroup } from 'react-transition-group';
2import React, { useState, useEffect } from 'react';
3import './styles.css';
4
5function LaunchSequence() {
6  const [steps, setSteps] = useState([]);
7  const allSteps = [
8    { id: 1, text: "Démarrage des systèmes" },
9    { id: 2, text: "Vérification du moteur principal" },
10    { id: 3, text: "Vérification des systèmes de navigation" },
11    { id: 4, text: "Verrouillage des sas" },
12    { id: 5, text: "Compte à rebours avant le lancement" }
13  ];
14
15  useEffect(() => {
16    if (steps.length < allSteps.length) {
17      const timer = setTimeout(() => {
18        setSteps(prevSteps => [
19          ...prevSteps,
20          allSteps[prevSteps.length]
21        ]);
22      }, 1000);
23
24      return () => clearTimeout(timer);
25    }
26  }, [steps]);
27
28  return (
29    <div className="launch-sequence">
30      <h2>Séquence de lancement</h2>
31      <TransitionGroup>
32        {steps.map(step => (
33          <CSSTransition
34            key={step.id}
35            timeout={500}
36            classNames="launch-step"
37          >
38            <div className="launch-step">
39              <div className="step-number">{step.id}</div>
40              <div className="step-text">{step.text}</div>
41            </div>
42          </CSSTransition>
43        ))}
44      </TransitionGroup>
45    </div>
46  );
47}

2. Chorégraphie d'animations

Synchroniser plusieurs animations pour créer des séquences complexes :

1import { Transition } from 'react-transition-group';
2import React, { useState } from 'react';
3
4function SpaceshipDashboard() {
5  const [active, setActive] = useState(false);
6
7  // Durées pour les différents éléments
8  const durations = {
9    background: 500,
10    systems: 400,
11    controls: 600,
12    status: 700,
13  };
14
15  // Styles pour les différents états
16  const getStyles = (elementName, state) => {
17    const baseStyles = {
18      transition: `all ${durations[elementName]}ms ease-out`,
19    };
20
21    const elementSpecificStyles = {
22      background: {
23        entering: { opacity: 1, filter: 'brightness(1)' },
24        entered: { opacity: 1, filter: 'brightness(1)' },
25        exiting: { opacity: 0.5, filter: 'brightness(0.4)' },
26        exited: { opacity: 0.5, filter: 'brightness(0.4)' },
27      },
28      systems: {
29        entering: { opacity: 1, transform: 'translateY(0)' },
30        entered: { opacity: 1, transform: 'translateY(0)' },
31        exiting: { opacity: 0, transform: 'translateY(20px)' },
32        exited: { opacity: 0, transform: 'translateY(20px)' },
33      },
34      controls: {
35        entering: { opacity: 1, transform: 'scale(1)' },
36        entered: { opacity: 1, transform: 'scale(1)' },
37        exiting: { opacity: 0, transform: 'scale(0.8)' },
38        exited: { opacity: 0, transform: 'scale(0.8)' },
39      },
40      status: {
41        entering: { opacity: 1, transform: 'translateX(0)' },
42        entered: { opacity: 1, transform: 'translateX(0)' },
43        exiting: { opacity: 0, transform: 'translateX(-20px)' },
44        exited: { opacity: 0, transform: 'translateX(-20px)' },
45      },
46    };
47
48    return {
49      ...baseStyles,
50      ...elementSpecificStyles[elementName][state],
51    };
52  };
53
54  // Styles initiaux des éléments
55  const initialStyles = {
56    background: { opacity: 0.5, filter: 'brightness(0.4)' },
57    systems: { opacity: 0, transform: 'translateY(20px)' },
58    controls: { opacity: 0, transform: 'scale(0.8)' },
59    status: { opacity: 0, transform: 'translateX(-20px)' },
60  };
61
62  return (
63    <div className="dashboard-container">
64      <button onClick={() => setActive(!active)}>
65        {active ? 'Arrêter les systèmes' : 'Démarrer les systèmes'}
66      </button>
67
68      <div className="spaceship-dashboard">
69        <Transition in={active} timeout={durations.background}>
70          {state => (
71            <div
72              className="dashboard-background"
73              style={{
74                ...initialStyles.background,
75                ...getStyles('background', state)
76              }}
77            />
78          )}
79        </Transition>
80
81        <Transition in={active} timeout={durations.systems}>
82          {state => (
83            <div
84              className="system-panels"
85              style={{
86                ...initialStyles.systems,
87                ...getStyles('systems', state)
88              }}
89            >
90              Diagnostic des systèmes
91            </div>
92          )}
93        </Transition>
94
95        <Transition in={active} timeout={durations.controls}>
96          {state => (
97            <div
98              className="control-panels"
99              style={{
100                ...initialStyles.controls,
101                ...getStyles('controls', state)
102              }}
103            >
104              Commandes de vol
105            </div>
106          )}
107        </Transition>
108
109        <Transition in={active} timeout={durations.status}>
110          {state => (
111            <div
112              className="status-panels"
113              style={{
114                ...initialStyles.status,
115                ...getStyles('status', state)
116              }}
117            >
118              Moniteurs de statut
119            </div>
120          )}
121        </Transition>
122      </div>
123    </div>
124  );
125}

Bonnes pratiques pour des animations cosmiquement fluides

  1. Évite d'animer des propriétés coûteuses en calcul - des propriétés comme width, height et top peuvent provoquer un reflow. Préfère transform et opacity.

  2. Utilise des outils de débogage d'animations - des outils comme Chrome DevTools permettent d'analyser les performances des animations.

  3. Réfléchis aux points de départ et d'arrivée - une animation bien conçue a des états clairement définis.

  4. N'exagère pas la durée - les animations doivent être assez courtes pour ne pas retarder l'interaction de l'utilisateur (généralement 150-500ms).

  5. Ajoute des délais pour les séquences complexes - utilise delay pour créer des effets en cascade.

  6. Assure l'accessibilité - pense aux utilisateurs qui préfèrent peut-être des animations réduites (prefers-reduced-motion).

Exemple : Portail interstellaire

Créons un composant portail qui anime la transition entre les « mondes » (vues) de notre application cosmique :

1import { CSSTransition } from 'react-transition-group';
2import React, { useState } from 'react';
3import './portal.css';
4
5function InterstellarPortal({ children, show, onExited }) {
6  return (
7    <CSSTransition
8      in={show}
9      timeout={1000}
10      classNames="portal"
11      unmountOnExit
12      onExited={onExited}
13    >
14      <div className="portal-container">
15        <div className="portal-content">
16          {children}
17        </div>
18      </div>
19    </CSSTransition>
20  );
21}
22
23function App() {
24  const [currentWorld, setCurrentWorld] = useState('home');
25  const [showPortal, setShowPortal] = useState(true);
26  const [nextWorld, setNextWorld] = useState(null);
27
28  const worlds = {
29    home: {
30      title: "Station spatiale Alpha",
31      content: "Bienvenue à bord de la station spatiale Alpha, le centre de commandement de notre opération interstellaire."
32    },
33    mars: {
34      title: "Colonie martienne One",
35      content: "Bienvenue dans la première colonie sur Mars. Les travaux de terraformation sont en cours."
36    },
37    europa: {
38      title: "Base sous-marine Europe",
39      content: "Bienvenue dans la base de recherche sous la coquille glacée d'Europe, la lune de Jupiter."
40    }
41  };
42
43  const navigateTo = (world) => {
44    setNextWorld(world);
45    setShowPortal(false);
46  };
47
48  const onPortalExited = () => {
49    setCurrentWorld(nextWorld);
50    setShowPortal(true);
51  };
52
53  return (
54    <div className="app">
55      <nav className="navigation">
56        <button onClick={() => navigateTo('home')} disabled={currentWorld === 'home'}>
57          Station Alpha
58        </button>
59        <button onClick={() => navigateTo('mars')} disabled={currentWorld === 'mars'}>
60          Mars
61        </button>
62        <button onClick={() => navigateTo('europa')} disabled={currentWorld === 'europa'}>
63          Europe
64        </button>
65      </nav>
66
67      <InterstellarPortal show={showPortal} onExited={onPortalExited}>
68        <h1>{worlds[currentWorld].title}</h1>
69        <p>{worlds[currentWorld].content}</p>
70      </InterstellarPortal>
71    </div>
72  );
73}

Le CSS du portail :

1/* portal.css */
2.portal-container {
3  position: relative;
4  overflow: hidden;
5  min-height: 300px;
6  padding: 30px;
7  background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
8  border-radius: 12px;
9  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
10}
11
12.portal-content {
13  background: rgba(0, 0, 0, 0.7);
14  padding: 30px;
15  border-radius: 8px;
16  color: white;
17}
18
19/* Animation d'entrée */
20.portal-enter {
21  opacity: 0;
22  transform: scale(0.7);
23}
24
25.portal-enter-active {
26  opacity: 1;
27  transform: scale(1);
28  transition: opacity 1000ms ease-out, transform 1000ms cubic-bezier(0.34, 1.56, 0.64, 1);
29}
30
31/* Animation de sortie */
32.portal-exit {
33  opacity: 1;
34  transform: scale(1);
35}
36
37.portal-exit-active {
38  opacity: 0;
39  transform: scale(1.3);
40  transition: opacity 1000ms ease-in, transform 1000ms cubic-bezier(0.36, 0, 0.66, -0.56);
41}
42
43/* Style de la navigation */
44.navigation {
45  display: flex;
46  justify-content: center;
47  gap: 15px;
48  margin-bottom: 30px;
49}
50
51.navigation button {
52  background-color: #2c3e50;
53  color: white;
54  border: none;
55  padding: 10px 15px;
56  border-radius: 5px;
57  cursor: pointer;
58  transition: all 0.3s;
59}
60
61.navigation button:hover:not(:disabled) {
62  background-color: #34495e;
63  transform: translateY(-2px);
64}
65
66.navigation button:disabled {
67  background-color: #95a5a6;
68  cursor: not-allowed;
69}

Exercice pratique : Crée une galerie de planètes animée

Conçois et implémente une galerie animée des planètes de notre système solaire. Lorsque l'utilisateur navigue entre les planètes, utilise React Transition Group pour créer des transitions fluides entre les vues. Pour chaque planète, affiche :

  1. Le nom de la planète
  2. Une image de la planète
  3. Quelques faits clés (taille, distance au Soleil, température)

Ajoute les animations suivantes :

  • Une transition fluide entre les planètes
  • Un effet en cascade pour afficher les faits sur la planète
  • Un bouton « Retour à la vue d'ensemble » avec une animation de transition

N'oublie pas qu'une bonne animation enrichit l'expérience utilisateur, sans la perturber. Les animations doivent être fluides et naturelles, comme le voyage à travers l'espace lui-même : élégantes, majestueuses et inspirantes.

Code de cette leçon : App.jsx
1import React, { useState, useRef } from 'react';
2import './styles.css';
3
4// ===== SIMULATION REACT TRANSITION GROUP =====
5// Sandpack ne prend pas en charge react-transition-group,
6// on implémente donc des animations CSS avec des classes de transition
7
8// Composant CSSTransition - version simplifiée
9function CSSTransition({ inProp, timeout, children, classNames, unmountOnExit }) {
10  const [mounted, setMounted] = React.useState(inProp);
11  const [animClass, setAnimClass] = React.useState('');
12
13  React.useEffect(() => {
14    if (inProp) {
15      setMounted(true);
16      setAnimClass(classNames + '-enter');
17      requestAnimationFrame(() => {
18        requestAnimationFrame(() => {
19          setAnimClass(classNames + '-enter-active');
20        });
21      });
22    } else {
23      setAnimClass(classNames + '-exit');
24      requestAnimationFrame(() => {
25        requestAnimationFrame(() => {
26          setAnimClass(classNames + '-exit-active');
27        });
28      });
29      const timer = setTimeout(() => {
30        if (unmountOnExit) setMounted(false);
31      }, timeout);
32      return () => clearTimeout(timer);
33    }
34  }, [inProp]);
35
36  if (!mounted && unmountOnExit) return null;
37
38  return (
39    <div className={animClass}>
40      {children}
41    </div>
42  );
43}
44
45// ===== DEMO 1: Fade In/Out =====
46function FadeDemo() {
47  const [show, setShow] = useState(false);
48
49  return (
50    <div className="demo-section">
51      <h3>Fade In / Out</h3>
52      <button
53        className="demo-btn"
54        onClick={() => setShow(!show)}
55      >
56        {show ? 'Hide' : 'Show'} komunikat
57      </button>
58
59      <CSSTransition
60        inProp={show}
61        timeout={400}
62        classNames="fade"
63        unmountOnExit
64      >
65        <div className="alert-box">
66          Une nouvelle planète propice à la colonisation a été détectée !
67        </div>
68      </CSSTransition>
69    </div>
70  );
71}
72
73// ===== DEMO 2 : Liste avec animations =====
74function AnimatedList() {
75  const [items, setItems] = useState([
76    { id: 1, name: 'Alpha Centauri', visible: true },
77    { id: 2, name: 'Proxima Centauri', visible: true },
78    { id: 3, name: 'Betelgeuse', visible: true },
79  ]);
80  const nextId = useRef(4);
81
82  const starNames = [
83    'Sirius', 'Vega', 'Polaris', 'Canopus',
84    'Arcturus', 'Antares', 'Rigel', 'Procyon',
85  ];
86
87  const addStar = () => {
88    const name = starNames[Math.floor(Math.random() * starNames.length)];
89    const newItem = { id: nextId.current++, name, visible: false };
90    setItems(prev => [...prev, newItem]);
91    // Lance l'animation d'entrée après le montage
92    setTimeout(() => {
93      setItems(prev =>
94        prev.map(item =>
95          item.id === newItem.id ? { ...item, visible: true } : item
96        )
97      );
98    }, 50);
99  };
100
101  const removeStar = (id) => {
102    // Lance d'abord l'animation de sortie
103    setItems(prev =>
104      prev.map(item =>
105        item.id === id ? { ...item, visible: false } : item
106      )
107    );
108    // Puis supprime l'élément
109    setTimeout(() => {
110      setItems(prev => prev.filter(item => item.id !== id));
111    }, 500);
112  };
113
114  return (
115    <div className="demo-section">
116      <h3>Liste d'étoiles animée</h3>
117      <button className="demo-btn" onClick={addStar}>
118        + Ajouter une étoile
119      </button>
120
121      <div className="star-list">
122        {items.map(item => (
123          <div
124            key={item.id}
125            className={"star-item " + (item.visible ? 'star-item-enter-active' : 'star-item-exit-active')}
126          >
127            <span>{item.name}</span>
128            <button
129              className="remove-btn"
130              onClick={() => removeStar(item.id)}
131            >
132              ×
133            </button>
134          </div>
135        ))}
136      </div>
137    </div>
138  );
139}
140
141// TODO : Crée un composant SlideDemo avec une animation slide
142// Le composant doit :
143// 1. Avoir un bouton pour basculer la visibilité
144// 2. Animer l'élément avec un décalage depuis le haut (translateY)
145// 3. Utiliser les classes CSS : slide-enter, slide-enter-active, slide-exit-active
146function SlideDemo() {
147  const [show, setShow] = useState(false);
148
149  return (
150    <div className="demo-section">
151      <h3>Slide Transition</h3>
152      <button className="demo-btn" onClick={() => setShow(!show)}>
153        {show ? 'Réduire' : 'Déplier'} le panneau
154      </button>
155
156      {/* TODO : Ajoute un panneau animé avec la classe slide */}
157      {/* Utilise CSSTransition ou une logique de classes manuelle */}
158      <CSSTransition
159        inProp={show}
160        timeout={400}
161        classNames="slide"
162        unmountOnExit
163      >
164        <div className="slide-panel">
165          <h4>Panneau de contrôle de mission</h4>
166          <p>État des moteurs : ONLINE</p>
167          <p>Kurs: Alpha Centauri</p>
168          <p>ETA : 4.37 années-lumière</p>
169        </div>
170      </CSSTransition>
171    </div>
172  );
173}
174
175// ===== APPLICATION PRINCIPALE =====
176function App() {
177  return (
178    <div className="app-container">
179      <h1 className="app-title">React Transition Group</h1>
180      <p className="app-subtitle">Animations de transition dans une application cosmique</p>
181      <FadeDemo />
182      <AnimatedList />
183      <SlideDemo />
184    </div>
185  );
186}
187
188export default App;

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

Tâches pratiques dans le jeu

  • Tri vertical

    Classe les étapes pour ajouter des animations CSS à un composant React :

Articles utiles