Cours JavaScript et React · Module 14: Styles avancés

Patterns de Responsive Design dans React - S'adapter à chaque espace

4 min de lecture
Dans cette leçon7

Pilote, ton vaisseau spatial doit fonctionner aussi bien sur le petit écran d'un terminal portable que sur le grand moniteur d'un centre de commandement. Dans le monde de React, le responsive design consiste à créer des composants qui s'adaptent élégamment à n'importe quelle taille d'écran -- des appareils mobiles aux moniteurs ultra-larges.

Le hook useMediaQuery -- la responsivité en JavaScript

Parfois, tu dois changer non seulement les styles mais toute la structure du composant en fonction de la taille de l'écran. C'est là qu'un hook personnalisé useMediaQuery devient utile :

1function useMediaQuery(query) {
2  const [matches, setMatches] = useState(
3    () => window.matchMedia(query).matches
4  );
5
6  useEffect(() => {
7    const mediaQuery = window.matchMedia(query);
8    const handler = (e) => setMatches(e.matches);
9
10    mediaQuery.addEventListener('change', handler);
11    return () => mediaQuery.removeEventListener('change', handler);
12  }, [query]);
13
14  return matches;
15}

Utilisation dans un composant :

1function MissionDashboard() {
2  const isMobile = useMediaQuery('(max-width: 767px)');
3  const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
4
5  if (isMobile) return <MobileDashboard />;
6  if (isTablet) return <TabletDashboard />;
7  return <DesktopDashboard />;
8}

Le hook écoute les changements de taille de la fenêtre et renvoie true/false -- React ré-affiche automatiquement le composant quand la valeur change.

CSS mobile-first dans React

L'approche mobile-first signifie : on commence par les styles pour les plus petits écrans, puis on étend pour les plus grands. Comme concevoir un cockpit en partant du terminal le plus simple :

1/* Styles de base -- mobile */
2.grid {
3  display: grid;
4  grid-template-columns: 1fr;
5  gap: 16px;
6}
7
8/* Tablette -- on étend */
9@media (min-width: 768px) {
10  .grid {
11    grid-template-columns: repeat(2, 1fr);
12  }
13}
14
15/* Desktop -- encore plus large */
16@media (min-width: 1024px) {
17  .grid {
18    grid-template-columns: repeat(3, 1fr);
19  }
20}

Composition de composants responsive

Au lieu d'un énorme composant avec beaucoup de conditions, il vaut mieux découper l'UI en parties plus petites qui peuvent être composées différemment :

1function Sidebar({ items }) {
2  return (
3    <nav className="sidebar">
4      {items.map(item => (
5        <a key={item.id} href={item.href}>{item.label}</a>
6      ))}
7    </nav>
8  );
9}
10
11function BottomNav({ items }) {
12  return (
13    <nav className="bottom-nav">
14      {items.map(item => (
15        <a key={item.id} href={item.href}>{item.icon}</a>
16      ))}
17    </nav>
18  );
19}
20
21function Navigation({ items }) {
22  const isMobile = useMediaQuery('(max-width: 767px)');
23  return isMobile ? <BottomNav items={items} /> : <Sidebar items={items} />;
24}

Sur mobile, la navigation est en bas de l'écran (comme dans une application), et sur desktop -- sur le côté en tant que sidebar. Même jeu de données, présentation différente.

Container Queries dans React

Les container queries sont une nouvelle fonctionnalité CSS puissante. Elles permettent de styliser un élément en fonction de la taille de son parent, et non de toute la fenêtre du navigateur :

1.card-container {
2  container-type: inline-size;
3}
4
5@container (min-width: 400px) {
6  .card {
7    display: flex;
8    gap: 16px;
9  }
10}
11
12@container (min-width: 600px) {
13  .card {
14    grid-template-columns: 1fr 2fr;
15  }
16}

Dans React, on définit container-type sur le parent :

1function CardGrid({ children }) {
2  return (
3    <div style={{ containerType: 'inline-size' }}>
4      {children}
5    </div>
6  );
7}

Les container queries sont parfaites pour les composants réutilisables -- une carte de mission peut avoir un aspect différent dans une sidebar étroite et dans une zone de contenu principale large, sans connaître la taille de la fenêtre.

Typographie fluide

Au lieu de changer la taille de police par paliers entre les breakpoints, tu peux utiliser clamp() pour un redimensionnement en douceur :

1.heading {
2  /* Minimum 18px, optimal 4vw, maximum 36px */
3  font-size: clamp(18px, 4vw, 36px);
4}
5
6.body-text {
7  font-size: clamp(14px, 2vw, 18px);
8  line-height: clamp(20px, 3vw, 28px);
9}

Dans React, tu peux l'implémenter sous forme de système :

1const fluidType = {
2  heading: 'clamp(18px, 4vw, 36px)',
3  subheading: 'clamp(16px, 3vw, 24px)',
4  body: 'clamp(14px, 2vw, 18px)',
5  caption: 'clamp(11px, 1.5vw, 14px)',
6};
7
8function Heading({ children }) {
9  return <h1 style={{ fontSize: fluidType.heading }}>{children}</h1>;
10}

Images responsive dans React

Les images sont souvent les ressources les plus lourdes d'une page. React permet d'implémenter des images responsive :

1function ResponsiveImage({ src, alt, sizes }) {
2  return (
3    <img
4      src={src}
5      alt={alt}
6      srcSet={`${src}?w=400 400w, ${src}?w=800 800w, ${src}?w=1200 1200w`}
7      sizes={sizes || '(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'}
8      loading="lazy"
9      style={{ width: '100%', height: 'auto' }}
10    />
11  );
12}

L'attribut srcSet indique au navigateur les tailles disponibles, et sizes -- quelle taille utiliser pour une taille de fenêtre donnée.

Quand le CSS, quand le JavaScript ?

  • Media queries en CSS -- quand tu ne changes que les styles (couleurs, tailles, disposition)
  • useMediaQuery en JS -- quand tu changes la structure du composant (composants différents, logique différente)
  • Container queries -- quand un composant doit réagir à la taille de son conteneur
  • clamp() -- pour un redimensionnement en douceur des valeurs entre un minimum et un maximum
Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2import './styles.css';
3
4// Hook useMediaQuery -- écoute les changements de taille de la fenêtre
5function useMediaQuery(query) {
6  const [matches, setMatches] = useState(
7    () => window.matchMedia(query).matches
8  );
9
10  useEffect(() => {
11    const mq = window.matchMedia(query);
12    const handler = (e) => setMatches(e.matches);
13    mq.addEventListener('change', handler);
14    return () => mq.removeEventListener('change', handler);
15  }, [query]);
16
17  return matches;
18}
19
20// Carte de mission -- contenu différent sur mobile et desktop
21function MissionCard({ name, status, crew, description, stats }) {
22  const isMobile = useMediaQuery('(max-width: 500px)');
23
24  return (
25    <div className="mission-card">
26      <div className="card-header">
27        <h3 className="card-name">{name}</h3>
28        <span className={`badge badge-${status}`}>{status}</span>
29      </div>
30      <p className="card-desc">{description}</p>
31      {!isMobile && (
32        <div className="stats-row">
33          {stats.map(s => (
34            <div key={s.label} className="stat">
35              <span className="stat-value">{s.value}</span>
36              <span className="stat-label">{s.label}</span>
37            </div>
38          ))}
39        </div>
40      )}
41      <div className="card-footer">
42        <span className="crew">Équipage : {crew}</span>
43        {!isMobile && <button className="btn">Détails</button>}
44      </div>
45    </div>
46  );
47}
48
49// Navigation -- sidebar sur desktop, bottom nav sur mobile
50function Navigation({ items, active, onSelect }) {
51  const isMobile = useMediaQuery('(max-width: 500px)');
52
53  if (isMobile) {
54    return (
55      <nav className="bottom-nav">
56        {items.map(item => (
57          <button
58            key={item.id}
59            className={`nav-item ${active === item.id ? 'nav-active' : ''}`}
60            onClick={() => onSelect(item.id)}
61          >
62            {item.icon} {item.label}
63          </button>
64        ))}
65      </nav>
66    );
67  }
68
69  return (
70    <nav className="sidebar">
71      <h2 className="sidebar-title">Navigation</h2>
72      {items.map(item => (
73        <button
74          key={item.id}
75          className={`sidebar-item ${active === item.id ? 'sidebar-active' : ''}`}
76          onClick={() => onSelect(item.id)}
77        >
78          {item.icon} {item.label}
79        </button>
80      ))}
81    </nav>
82  );
83}
84
85const navItems = [
86  { id: 'missions', label: 'Missions', icon: '' },
87  { id: 'crew', label: 'Équipage', icon: '' },
88  { id: 'systems', label: 'Systèmes', icon: '' },
89];
90
91const missions = [
92  { name: 'Apollo X', status: 'active', crew: 4, description: 'Exploration de Mars',
93    stats: [{ label: 'Distance', value: '225M km' }, { label: 'Temps', value: '7 mois' }, { label: 'Progrès', value: '67%' }] },
94  { name: 'Nebula Run', status: 'planning', crew: 6, description: 'Mission vers la nébuleuse d\'Orion',
95    stats: [{ label: 'Distance', value: '1344 al' }, { label: 'Temps', value: '2 ans' }, { label: 'Progrès', value: '12%' }] },
96];
97
98export default function App() {
99  const [activeNav, setActiveNav] = useState('missions');
100  const isMobile = useMediaQuery('(max-width: 500px)');
101
102  return (
103    <div className="app">
104      <div className="layout">
105        {!isMobile && (
106          <Navigation items={navItems} active={activeNav} onSelect={setActiveNav} />
107        )}
108        <main className="main">
109          <h1 className="heading">Responsive Design dans React</h1>
110          <p className="subtitle">
111            {isMobile ? 'Vue mobile' : 'Vue desktop'} --
112            change la taille de la fenêtre d'aperçu !
113          </p>
114          <div className="missions-grid">
115            {missions.map(m => <MissionCard key={m.name} {...m} />)}
116          </div>
117        </main>
118      </div>
119      {isMobile && (
120        <Navigation items={navItems} active={activeNav} onSelect={setActiveNav} />
121      )}
122    </div>
123  );
124}

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. Quel est le but du hook personnalisé useMediaQuery dans React ?

  2. 2. Que signifie l'approche mobile-first dans le responsive design ?

Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    Ordonne la syntaxe d'une media query pour les écrans à partir de 768px :

  • Éditeur de code

    Implémente une navigation responsive avec useMediaQuery

  • Clique dans l'ordre

    Ordonne la syntaxe de la typographie fluide utilisant clamp() en CSS :

  • Tri vertical

    Ordonne les breakpoints mobile-first typiques du plus petit au plus grand :

  • Éditeur de code

    Implémente une carte de mission avec un contenu différent sur mobile et desktop

Articles utiles