Cours Vibe coding avec l'IA · Module 4: Clone d'Instagram

Grid Layouts et Modales - Interface Instagram

8 min de lecture
Dans cette leçon8

Le capitaine a ajouté vingt photos de butin à la galerie, toutes empilées en une longue colonne. Pour parcourir le trésor, il faut défiler sans fin, et aucune photo ne s'affiche en grand. Dans cette leçon, tu ranges les photos dans une grille comme sur Instagram et tu ajoutes une modale : une fenêtre avec la photo agrandie au-dessus du reste de la page.

La grille de photos

Tu actives la grille (CSS Grid) avec la classe grid et tu fixes le nombre de colonnes avec grid-cols-3. Voici six tuiles sur trois colonnes :

1<div className="grid grid-cols-3 gap-4">
2  <div className="bg-gray-200 aspect-square">Photo 1</div>
3  <div className="bg-gray-200 aspect-square">Photo 2</div>
4  <div className="bg-gray-200 aspect-square">Photo 3</div>
5  <div className="bg-gray-200 aspect-square">Photo 4</div>
6  <div className="bg-gray-200 aspect-square">Photo 5</div>
7  <div className="bg-gray-200 aspect-square">Photo 6</div>
8</div>

gap-4 ajoute un espacement entre les éléments de la grille (16 px par défaut), et aspect-square impose une forme carrée au ratio 1:1. Les tuiles ne savent rien des colonnes : seul le parent décide de la disposition.

La grille responsive

Sur téléphone, trois colonnes donnent des vignettes grandes comme un timbre. Tailwind part du plus petit écran : une classe sans préfixe s'applique partout, et un préfixe de point de rupture l'active à partir d'une largeur donnée :

1<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-1">
2  {/* Photos */}
3</div>

Les préfixes, du plus petit au plus grand écran :

  • sans préfixe (mobile) : 1 colonne
  • sm: dès 640 px : 2 colonnes
  • md: dès 768 px : 3 colonnes
  • lg: dès 1024 px : 4 colonnes

Chaque préfixe ne change que le nombre de colonnes, l'espacement gap-1 reste identique partout.

Une galerie façon Instagram

Place aux vraies photos. Le conteneur a grid grid-cols-3, chaque carte les classes relative aspect-square group cursor-pointer, dedans une Image avec fill et object-cover, et par-dessus un overlay avec le nombre de likes :

1import Image from 'next/image';
2
3interface Photo {
4  id: number;
5  url: string;
6  caption: string;
7  likes: number;
8}
9
10export default function TreasureGallery({ photos }: { photos: Photo[] }) {
11  return (
12    <div className="max-w-5xl mx-auto p-4">
13      <h1 className="text-2xl font-bold mb-6">Treasure Gallery</h1>
14
15      <div className="grid grid-cols-3 gap-1">
16        {photos.map((photo) => (
17          <div
18            key={photo.id}
19            className="relative aspect-square group cursor-pointer overflow-hidden"
20          >
21            <Image
22              src={photo.url}
23              alt={photo.caption}
24              fill
25              className="object-cover transition-transform group-hover:scale-110"
26            />
27
28            {/* Overlay au survol */}
29            <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
30              <div className="text-white text-center space-y-2">
31                <p className="text-2xl font-bold">{photo.likes}</p>
32                <p className="text-sm px-4">{photo.caption}</p>
33              </div>
34            </div>
35          </div>
36        ))}
37      </div>
38    </div>
39  );
40}

L'overlay fonctionne en quatre étapes : le parent a la classe group, l'overlay commence avec opacity-0, donc invisible, l'utilisateur survole la carte avec le curseur, puis group-hover:opacity-100 l'affiche. absolute inset-0 étend l'overlay sur toute la carte, et relative sur le parent le fait se mesurer à la carte plutôt qu'à toute la page.

Conseil du Capitaine Barbe-Rouge : dans Tailwind 4, les styles hover ne s'appliquent que sur les appareils qui gèrent le survol au curseur. Sur téléphone, tu ne verras pas l'overlay : ne cache donc pas le nombre de likes uniquement dedans.

Une modale avec la photo agrandie

Cliquer sur une vignette de la grille ouvre une modale avec la photo complète, et cliquer sur le bouton ou appuyer sur Escape la ferme et ramène à la grille. La photo sélectionnée est gardée dans l'état :

1'use client';
2
3import { useState } from 'react';
4import Image from 'next/image';
5
6interface Photo {
7  id: number;
8  url: string;
9  caption: string;
10  author: string;
11  likes: number;
12}
13
14export default function Gallery({ photos }: { photos: Photo[] }) {
15  const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
16
17  return (
18    <>
19      {/* Grille */}
20      <div className="grid grid-cols-3 gap-1">
21        {photos.map((photo) => (
22          <div
23            key={photo.id}
24            onClick={() => setSelectedPhoto(photo)}
25            className="relative aspect-square cursor-pointer"
26          >
27            <Image src={photo.url} alt={photo.caption} fill className="object-cover" />
28          </div>
29        ))}
30      </div>
31
32      {/* Modale */}
33      {selectedPhoto && (
34        <div
35          className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
36          onClick={() => setSelectedPhoto(null)}
37        >
38          <div
39            className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-hidden"
40            onClick={(e) => e.stopPropagation()} // Empêche la fermeture en cliquant à l'intérieur
41          >
42            {/* Header */}
43            <div className="flex items-center justify-between p-4 border-b">
44              <div className="flex items-center gap-3">
45                <div className="text-2xl">◆</div>
46                <h3 className="font-bold">{selectedPhoto.author}</h3>
47              </div>
48              <button
49                onClick={() => setSelectedPhoto(null)}
50                className="text-gray-500 hover:text-gray-700 text-2xl"
51              >
52                ×
53              </button>
54            </div>
55
56            {/* Image */}
57            <div className="relative aspect-square">
58              <Image
59                src={selectedPhoto.url}
60                alt={selectedPhoto.caption}
61                fill
62                className="object-contain"
63              />
64            </div>
65
66            {/* Footer */}
67            <div className="p-4 border-t">
68              <div className="flex items-center gap-4 mb-2">
69                <button className="text-sm font-semibold hover:scale-110 transition-transform">
70                  Aimé
71                </button>
72                <button className="text-sm font-semibold hover:scale-110 transition-transform">
73                  Commenter
74                </button>
75                <button className="text-sm font-semibold hover:scale-110 transition-transform">
76                  Partager
77                </button>
78              </div>
79              <p className="font-bold mb-1">{selectedPhoto.likes} likes</p>
80              <p className="text-gray-800">
81                <span className="font-bold">{selectedPhoto.author}</span>{' '}
82                {selectedPhoto.caption}
83              </p>
84            </div>
85          </div>
86        </div>
87      )}
88    </>
89  );
90}

setSelectedPhoto(photo) ouvre la modale et setSelectedPhoto(null) la ferme. L'overlay de la modale a les classes fixed inset-0 z-50 : fixed l'épingle à la fenêtre, inset-0 l'étend sur tout l'écran, et z-50 le place au-dessus du contenu. Un clic sur le fond ferme la modale, mais e.stopPropagation() sur la boîte blanche arrête le clic à l'intérieur avant qu'il n'atteigne le fond : la modale reste ouverte.

Portal et blocage du défilement

Une modale cachée au fond de l'arbre de composants peut être rognée par un parent en overflow-hidden. createPortal la rend directement dans document.body :

1'use client';
2
3import { useEffect, useState } from 'react';
4import { createPortal } from 'react-dom';
5
6function Modal({
7  isOpen,
8  onClose,
9  children
10}: {
11  isOpen: boolean;
12  onClose: () => void;
13  children: React.ReactNode;
14}) {
15  const [mounted, setMounted] = useState(false);
16
17  useEffect(() => {
18    setMounted(true);
19    return () => setMounted(false);
20  }, []);
21
22  useEffect(() => {
23    if (isOpen) {
24      document.body.style.overflow = 'hidden'; // Bloque le scroll
25    } else {
26      document.body.style.overflow = 'unset';
27    }
28  }, [isOpen]);
29
30  if (!mounted || !isOpen) return null;
31
32  return createPortal(
33    <div
34      className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
35      onClick={onClose}
36    >
37      <div onClick={(e) => e.stopPropagation()}>
38        {children}
39      </div>
40    </div>,
41    document.body
42  );
43}
44
45export default Modal;

Le second useEffect met document.body.style.overflow = 'hidden' tant que la modale est ouverte : la page en arrière-plan ne défile plus. Le portal ne change que la place dans le DOM, l'ouverture et la fermeture fonctionnent pareil. Les navigateurs ont aussi un élément <dialog> tout prêt : ouvert avec showModal(), il se ferme seul avec Escape et bloque le reste de la page.

Fermer avec la touche Escape

Au clavier, on s'attend à ce qu'Escape ferme la modale. On le gère en écoutant l'événement keydown :

1'use client';
2
3import { useEffect } from 'react';
4
5function useKeyPress(key: string, callback: () => void) {
6  useEffect(() => {
7    const handler = (e: KeyboardEvent) => {
8      if (e.key === key) {
9        callback();
10      }
11    };
12
13    window.addEventListener('keydown', handler);
14    return () => window.removeEventListener('keydown', handler);
15  }, [key, callback]);
16}
17
18function Modal({ isOpen, onClose, children }: ModalProps) {
19  useKeyPress('Escape', onClose);
20
21  // Reste du code...
22}

Le hook vérifie e.key === 'Escape' et appelle onClose. La fonction renvoyée par useEffect retire l'écouteur quand la modale disparaît, sinon chaque ouverture en ajouterait un.

Le carrousel de photos

Des flèches dans la modale font défiler les photos sans fermer la fenêtre :

1'use client';
2
3import { useState } from 'react';
4
5export default function Gallery({ photos }: { photos: Photo[] }) {
6  const [currentIndex, setCurrentIndex] = useState(0);
7  const [isModalOpen, setIsModalOpen] = useState(false);
8
9  const nextPhoto = () => {
10    setCurrentIndex((prev) => (prev + 1) % photos.length);
11  };
12
13  const prevPhoto = () => {
14    setCurrentIndex((prev) => (prev - 1 + photos.length) % photos.length);
15  };
16
17  const currentPhoto = photos[currentIndex];
18
19  return (
20    <>
21      {/* Grille - cliquer ouvre la modale avec l'index donné */}
22      <div className="grid grid-cols-3 gap-1">
23        {photos.map((photo, index) => (
24          <div
25            key={photo.id}
26            onClick={() => {
27              setCurrentIndex(index);
28              setIsModalOpen(true);
29            }}
30            className="relative aspect-square cursor-pointer"
31          >
32            <Image src={photo.url} alt={photo.caption} fill className="object-cover" />
33          </div>
34        ))}
35      </div>
36
37      {/* Modale avec carousel */}
38      {isModalOpen && (
39        <div className="fixed inset-0 bg-black/95 z-50 flex items-center justify-center">
40          <button
41            onClick={prevPhoto}
42            className="absolute left-4 text-white text-4xl hover:scale-110 transition-transform"
43          >
44            ‹
45          </button>
46
47          <div className="max-w-4xl w-full">
48            <div className="relative aspect-square">
49              <Image
50                src={currentPhoto.url}
51                alt={currentPhoto.caption}
52                fill
53                className="object-contain"
54              />
55            </div>
56            <p className="text-white text-center mt-4">{currentPhoto.caption}</p>
57          </div>
58
59          <button
60            onClick={nextPhoto}
61            className="absolute right-4 text-white text-4xl hover:scale-110 transition-transform"
62          >
63            ›
64          </button>
65
66          <button
67            onClick={() => setIsModalOpen(false)}
68            className="absolute top-4 right-4 text-white text-3xl"
69          >
70            ×
71          </button>
72        </div>
73      )}
74    </>
75  );
76}

L'index de la photo suivante vaut (prev + 1) % photos.length. L'opérateur % donne le reste de la division : après la dernière photo, on revient au début. En reculant, on ajoute photos.length pour ne jamais passer sous zéro.

Masonry, la mise en page façon Pinterest

Quand les photos ont des hauteurs différentes, on utilise des colonnes au lieu d'une grille :

1<div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-4">
2  {photos.map((photo) => (
3    <div key={photo.id} className="break-inside-avoid">
4      <Image
5        src={photo.url}
6        alt={photo.caption}
7        width={300}
8        height={Math.random() * 200 + 200} // Hauteurs variables
9        className="w-full rounded-lg"
10      />
11    </div>
12  ))}
13</div>

columns-3 (ici responsive, de columns-1 à columns-4) découpe le conteneur en colonnes, et break-inside-avoid empêche qu'une photo soit coupée entre deux colonnes. Math.random() simule seulement des hauteurs variées : il renvoie un nouveau nombre à chaque rendu, donc dans une vraie galerie tu passes les dimensions enregistrées de la photo.

À retenir : la grille organise la galerie, et la modale est une couche fixed au-dessus, ouverte et fermée par un seul état. Dans l'éditeur ci-dessous, tu as une galerie de trésors responsive avec un overlay au survol : change la largeur de l'aperçu, compte les colonnes, puis fais les exercices.

Code de cette leçon : App.jsx
1// Grille de galerie responsive avec effets de survol
2const photos = [
3  { id: 1, emoji: '', title: 'Black Pearl', likes: 156 },
4  { id: 2, emoji: '', title: 'Ancre', likes: 89 },
5  { id: 3, emoji: '', title: 'Carte au trésor', likes: 234 },
6  { id: 4, emoji: '', title: 'Or', likes: 412 },
7  { id: 5, emoji: '', title: 'Perroquet Polly', likes: 178 },
8  { id: 6, emoji: '', title: 'Sabres', likes: 267 },
9  { id: 7, emoji: '', title: 'Île', likes: 345 },
10  { id: 8, emoji: '', title: 'Mer', likes: 123 },
11  { id: 9, emoji: '', title: 'Jolly Roger', likes: 456 },
12];
13
14export default function App() {
15  return (
16    <div className="min-h-screen bg-gray-100 p-8">
17      <div className="max-w-6xl mx-auto">
18        <h1 className="text-3xl font-bold mb-6">Treasure Gallery</h1>
19
20        {/* Grille responsive : 1 col mobile, 2 tablette, 3 desktop */}
21        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1">
22          {photos.map((photo) => (
23            <div
24              key={photo.id}
25              className="relative aspect-square group cursor-pointer overflow-hidden bg-gradient-to-br from-slate-800 to-blue-900"
26            >
27              {/* Photo */}
28              <div className="absolute inset-0 flex items-center justify-center text-9xl transition-transform group-hover:scale-110">
29                {photo.emoji}
30              </div>
31
32              {/* Overlay au survol */}
33              <div className="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white">
34                <p className="text-3xl font-bold mb-2">{photo.likes}</p>
35                <p className="text-lg font-medium">{photo.title}</p>
36              </div>
37            </div>
38          ))}
39        </div>
40      </div>
41    </div>
42  );
43}

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. Quelle classe Tailwind crée une grille avec 3 colonnes ?

  2. 2. Que fait la classe aspect-square dans Tailwind ?

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

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Clique sur les classes Tailwind dans l'ordre pour construire une grille de 3 colonnes avec un espacement :

  • Tri horizontal

    Ordonne les classes pour créer une grille avec 3 colonnes et un espacement :

  • Tri vertical

    Ordonne les préfixes de points de rupture Tailwind du plus petit au plus grand écran :

  • Éditeur de code

    Grille responsive

  • Tri horizontal

    Ordonne les classes de grille responsive du mobile à l'ordinateur (1 → 2 → 3 colonnes) :

  • Clique dans l'ordre

    Clique sur les classes Tailwind dans l'ordre pour que l'overlay de la modale recouvre tout l'écran au-dessus du contenu :

  • Éditeur de code

    Composant Modal

  • Tri vertical

    Ordonne les étapes de l'interaction d'un utilisateur avec la modale de photo agrandie :

Articles utiles