Faire un quiz interactif en JavaScript : le projet complet expliqué

Un quiz est le projet idéal juste après les premiers cours : cinq questions, quatre réponses chacune, un score à la fin. Il tient dans un seul fichier, il ne demande aucune bibliothèque, et il force à passer par les trois gestes qui font une application : décrire des données, fabriquer la page à partir de ces données, réagir aux clics. Si vous avez déjà suivi le projet Snake en JavaScript, vous connaissez la recette ; sinon, c'est un bon endroit pour commencer, parce qu'il n'y a ni boucle de jeu ni canvas, seulement du HTML, une poignée de fonctions et un tableau.

À la fin, vous aurez un quiz jouable à la souris comme au clavier, qui mélange l'ordre des questions et des réponses à chaque partie, corrige chaque réponse avec une explication, affiche le score et propose de rejouer sans recharger la page. 126 lignes de JavaScript sans compter le tableau des questions, 165 avec.

Méthode habituelle de ce blog : tout le code de cette page a été exécuté pendant la rédaction, le 14 septembre 2026, dans Chromium 153.0.8010.12 (le navigateur headless de Playwright 1.63.0, même moteur que Chrome), piloté par un script Node 20.19.4 sur Windows 11 qui a joué chaque scénario et relevé ce que la page affichait. Deux défauts du premier jet ont été trouvés par ces mesures, pas à la relecture du code : la touche Entrée qui faisait disparaître l'explication avant qu'on la lise, et une fonction de mélange qui place la dernière question en dernier une fois sur deux. Les deux sont reproduits et corrigés plus bas, chiffres à l'appui.

Ce que le quiz saura faire à la fin

Avant d'écrire une ligne, la liste de ce qu'on attend, parce que c'est elle qui dicte la structure du code :

Et deux contraintes de qualité, invisibles pour qui joue à la souris mais qui changent tout pour les autres : le quiz doit se jouer entièrement au clavier, et aucune réponse ne doit pouvoir être comptée deux fois.

Les questions vivent dans un tableau, pas dans le HTML

Première décision, la plus structurante : les questions ne sont pas écrites dans la page. Elles sont décrites dans un tableau d'objets JavaScript, et c'est le script qui fabrique le HTML. Ajouter une sixième question revient alors à ajouter un objet, sans toucher à une seule balise.

const QUESTIONS = [
  {
    question:
      "Quelle balise contient le titre affiché dans l'onglet du navigateur ?",
    choix: ["<h1>", "<title>", "<header>", "<meta>"],
    bonne: 1,
    explication:
      "La balise <title> vit dans le <head>. Le <h1> est le titre visible dans la page.",
  },
  {
    question: "Quelle propriété CSS arrondit les coins d'un bloc ?",
    choix: ["border-radius", "corner-radius", "border-round", "round"],
    bonne: 0,
    explication:
      "border-radius accepte une valeur par coin, ou une seule pour les quatre.",
  },
  {
    question: "Que renvoie typeof null en JavaScript ?",
    choix: ['"null"', '"undefined"', '"object"', '"boolean"'],
    bonne: 2,
    explication:
      "C'est une bizarrerie historique du langage, conservée pour ne pas casser les sites existants.",
  },
  {
    question: "Quelle méthode ajoute un élément à la fin d'un tableau ?",
    choix: ["add()", "push()", "append()", "insert()"],
    bonne: 1,
    explication:
      "push() ajoute à la fin et renvoie la nouvelle longueur ; unshift() ajoute au début.",
  },
  {
    question:
      "Quel sélecteur CSS cible l'élément dont l'attribut id vaut menu ?",
    choix: [".menu", "menu", "#menu", "*menu"],
    bonne: 2,
    explication:
      "Le dièse cible un id, le point cible une classe, un mot seul cible une balise.",
  },
];

Chaque question est un objet à quatre propriétés : le texte de la question, le tableau des quatre réponses proposées, l'indice de la bonne dans ce tableau (en partant de 0, donc bonne: 1 désigne la deuxième), et l'explication affichée après validation. Les cinq questions portent sur ce que couvre ce blog, et la troisième reprend une idée fausse tenace : typeof null renvoie bien "object", une bizarrerie que j'avais déjà croisée en testant ce qu'un débutant doit encore maîtriser à l'ère de l'IA.

Le nom en majuscules, QUESTIONS, est une convention : il signale une constante qui ne changera jamais pendant l'exécution. Tout ce qui bouge pendant une partie (la question en cours, le score) vivra dans d'autres variables, déclarées avec let.

un objet de QUESTIONSquestion: "Quelle…"choix: [4 textes]bonne: 1explication: "…"la pageremplie par le script<h2 id="question">bouton · choix[2]bouton · choix[0]bouton · choix[1] ✓bouton · choix[3]<p id="retour">rempli après « Valider »bonne = l'indice de labonne réponse dans choixles boutons sont créésdans un ordre mélangé,la bonne reste marquée
Une question est un objet ; le script en tire le titre, quatre boutons dans un ordre mélangé et, après validation, l'explication.

La page : quelques éléments vides que le script remplira

Le HTML est court parce qu'il ne contient aucune question. Il pose des emplacements, chacun avec un id, que le script viendra remplir. Créez un fichier quiz.html avec ce squelette (le style est réduit au minimum lisible ; le fichier complet, style compris, est à la fin de l'article).

<h1>Quiz : HTML, CSS et JavaScript</h1>

<section id="jeu">
  <p id="progression"></p>
  <h2 id="question"></h2>
  <div id="choix" class="choix"></div>
  <p id="retour" aria-live="polite"></p>
  <button id="valider" type="button">Valider</button>
  <button id="suivant" type="button" hidden>Question suivante</button>
</section>

<section id="fin" hidden>
  <h2 id="score" tabindex="-1"></h2>
  <button id="rejouer" type="button">Rejouer</button>
</section>

Trois détails méritent une phrase. L'attribut hidden cache un élément sans CSS : « Question suivante » n'apparaît qu'après validation, et la section de fin n'apparaît qu'à la dernière question. L'attribut aria-live="polite" sur le paragraphe de retour demande aux lecteurs d'écran d'annoncer son contenu quand il change, ce qui sera le cas à chaque validation. Et tabindex="-1" sur le titre du score le rend focalisable par le script, sans l'ajouter au parcours de la touche Tab ; on verra plus bas pourquoi c'est lui qui reçoit le focus, et pas le bouton Rejouer.

Les boutons portent type="button". Hors d'un formulaire, ça ne change rien ; dans un formulaire, un bouton sans type déclenche par défaut la soumission du formulaire, donc en général une navigation qui remplace la page, et c'est pour cela que l'article sur les formulaires HTML écrit toujours type="submit" en toutes lettres. Écrire le type systématiquement évite d'avoir à s'en souvenir.

Le script récupère chaque élément une seule fois, au démarrage, dans des constantes. Chercher un élément par son id à chaque clic fonctionnerait, mais ce serait du travail répété pour rien.

// Éléments de la page, récupérés une seule fois
const elProgression = document.getElementById("progression");
const elQuestion = document.getElementById("question");
const elChoix = document.getElementById("choix");
const elRetour = document.getElementById("retour");
const btnValider = document.getElementById("valider");
const btnSuivant = document.getElementById("suivant");
const elJeu = document.getElementById("jeu");
const elFin = document.getElementById("fin");
const elScore = document.getElementById("score");
const btnRejouer = document.getElementById("rejouer");

// État de la partie
let ordre = []; // les questions, dans l'ordre de cette partie
let index = 0; // numéro de la question en cours
let score = 0;
let choisi = null; // le bouton sélectionné, ou null
let repondu = false; // la question en cours a-t-elle été validée ?

Les cinq variables du bas forment l'état de la partie, tout ce que le quiz a besoin de savoir à un instant donné. ordre contiendra les questions dans l'ordre tiré au sort pour cette partie ; index dit laquelle est affichée ; choisi retient le bouton sélectionné ; repondu passe à true dès que la question est validée, et c'est lui qui interdira de compter deux fois.

Afficher une question : des boutons fabriqués à la volée

La fonction centrale lit la question en cours dans ordre, écrit son texte dans le titre, vide la zone des réponses et y crée quatre boutons. Un bouton par réponse, dans un ordre mélangé.

function afficherQuestion() {
  const q = ordre[index];
  choisi = null;
  repondu = false;

  elProgression.textContent = `Question ${index + 1} sur ${ordre.length} · Score : ${score}`;
  elQuestion.textContent = q.question;
  elRetour.textContent = "";
  elRetour.className = "";
  elChoix.replaceChildren();

  // Chaque option garde la trace de sa justesse : on peut mélanger sans rien perdre
  const options = q.choix.map((texte, i) => ({
    texte,
    estBonne: i === q.bonne,
  }));
  for (const option of melanger(options)) {
    const bouton = document.createElement("button");
    bouton.type = "button";
    bouton.textContent = option.texte;
    bouton.dataset.bonne = option.estBonne;
    bouton.setAttribute("aria-pressed", "false");
    bouton.addEventListener("click", () => choisir(bouton));
    elChoix.append(bouton);
  }

  btnValider.disabled = false;
  btnSuivant.hidden = true;
}

Le passage par options règle un problème qu'on ne voit qu'au moment de mélanger. L'indice bonne désigne une position dans choix ; si on mélange directement les quatre textes, la position 1 ne contient plus la même réponse et l'indice pointe n'importe où. La parade : transformer chaque texte en un petit objet qui sait s'il est la bonne réponse, puis mélanger ces objets. L'information voyage avec la réponse, l'indice n'a plus à survivre au mélange.

Chaque bouton reçoit ensuite cette information dans dataset.bonne, ce qui se traduit par un attribut data-bonne="true" ou "false" dans le HTML. Vous pouvez le voir dans l'inspecteur, et donc tricher : c'est un quiz d'apprentissage, pas un examen, et un contrôle côté navigateur n'a jamais été une protection. Le texte des réponses passe par textContent et non par innerHTML, ce qui affiche <h1> tel quel au lieu de créer un vrai titre ; les réponses de la première question en dépendent.

La méthode replaceChildren() sans argument vide un élément de tous ses enfants, en une ligne. Les boutons de la question précédente disparaissent avec leurs écouteurs, et on repart de zéro. La mise en page de la zone des réponses se contente d'un display: grid avec un gap, une colonne, une réponse par ligne ; si Grid est nouveau pour vous, le guide CSS Grid en fait le tour.

Choisir, valider, expliquer : l'état d'une question

Choisir une réponse ne fait que la marquer. Le marquage passe par l'attribut aria-pressed, qui a deux mérites : les lecteurs d'écran annoncent le bouton comme enfoncé, et le CSS peut cibler l'état sans classe supplémentaire, avec .choix button[aria-pressed="true"].

function choisir(bouton) {
  if (repondu) return;
  if (choisi) choisi.setAttribute("aria-pressed", "false");
  choisi = bouton;
  choisi.setAttribute("aria-pressed", "true");
}

La première ligne interdit de changer d'avis une fois la question validée. Au banc, un clic sur une mauvaise réponse après validation a laissé les quatre attributs aria-pressed exactement dans l'état où ils étaient. Avant validation, en revanche, on change d'avis librement : un clic sur le premier bouton donne true, false, false, false, un clic sur le deuxième donne false, true, false, false. Une seule réponse enfoncée à la fois, c'est ce que garantit la remise à false de l'ancien bouton.

La validation est la fonction la plus longue, parce qu'elle fait quatre choses : refuser de valider sans choix, compter le point, écrire le retour, colorer les boutons.

function valider() {
  if (repondu) return;
  if (!choisi) {
    elRetour.textContent = "Choisissez une réponse avant de valider.";
    return;
  }
  repondu = true;
  btnValider.disabled = true;

  const estBonne = choisi.dataset.bonne === "true";
  const q = ordre[index];
  if (estBonne) {
    score++;
    elRetour.textContent = `Bonne réponse ! ${q.explication}`;
    elRetour.className = "ok";
  } else {
    elRetour.textContent = `Raté : la bonne réponse était ${q.choix[q.bonne]}. ${q.explication}`;
    elRetour.className = "ko";
  }
  for (const bouton of elChoix.children) {
    bouton.classList.add(
      bouton.dataset.bonne === "true" ? "bonne" : "fausse",
    );
  }
  elProgression.textContent = `Question ${index + 1} sur ${ordre.length} · Score : ${score}`;

  btnSuivant.hidden = false;
}

Un piège classique se cache dans choisi.dataset.bonne === "true". Un attribut HTML est toujours une chaîne de caractères : on a écrit le booléen true dans dataset.bonne, le navigateur a stocké le texte "true", et c'est ce texte qu'on relit. Comparer à true sans guillemets serait toujours faux. Mesuré au banc, dans les deux sens : après une bonne réponse, le retour affiche « Bonne réponse ! push() ajoute à la fin et renvoie la nouvelle longueur ; unshift() ajoute au début. » avec la classe ok et la progression passe à « Score : 1 » ; après une mauvaise, « Raté : la bonne réponse était push(). push() ajoute à la fin et renvoie la nouvelle longueur ; unshift() ajoute au début. » avec la classe ko, et le score reste à 0. Dans les deux cas, le bouton de la bonne réponse reçoit la classe bonne et les trois autres la classe fausse.

Un clic sur « Valider » sans avoir rien choisi affiche « Choisissez une réponse avant de valider. » et ne fait rien d'autre : le score ne bouge pas, « Question suivante » reste caché.

Piège n° 1 : la touche Entrée qui fait disparaître l'explication

Mon premier jet différait de la version ci-dessus sur trois points, tous dans valider() : pas de garde if (repondu) return, pas de btnValider.disabled = true, et une dernière ligne btnSuivant.focus() (avec son jumeau btnRejouer.focus() en fin de terminer()), pour que la personne au clavier n'ait plus qu'à appuyer sur Entrée pour enchaîner. Une intention louable, et le banc l'a démolie en un scénario.

Le scénario : sélectionner la bonne réponse, mettre le focus sur « Valider », puis appuyer deux fois sur Entrée, ou simplement tenir la touche enfoncée un peu trop longtemps. Résultat mesuré sur le premier jet, en jouant trois enfoncements d'Entrée sans relâcher la touche, comme le fait la répétition automatique d'un clavier : deux clics, le premier sur « Valider », le second sur « Question suivante », et à la fin de la séquence la page affiche « Question 2 sur 5 · Score : 1 » avec un paragraphe de retour vide. Relevé entre le premier enfoncement et le deuxième, ce paragraphe contenait pourtant « Bonne réponse ! La balise <title> vit dans le <head>. Le <h1> est le titre visible dans la page. » : le point est compté, l'explication a été écrite puis effacée par l'enfoncement suivant. La même chose au dernier « Question suivante », qui donnait le focus à « Rejouer » : après la première pression, l'écran de fin affiche « Terminé : 5 bonnes réponses sur 5 » ; après la seconde, il est de nouveau caché et la partie a redémarré à « Question 1 sur 5 · Score : 0 ».

La cause est le déplacement du focus vers un bouton. Dès que le focus arrive sur « Question suivante », la touche encore enfoncée le déclenche. La correction est de ne plus déplacer le focus vers un bouton : après validation, « Valider » est désactivé et perd le focus (le navigateur le rend au document, c'est la règle de « focus fixup » de la spécification HTML), mais le point de départ de la navigation au clavier reste sur lui, et une pression sur Tab conduit à « Question suivante » (mesuré deux images après la validation : l'élément actif est body, et le Tab suivant donne suivant). En fin de partie, le focus va sur le titre du score, grâce à son tabindex="-1" : un titre ne réagit pas à Entrée, il est simplement lu. Sur la version corrigée, les trois enfoncements d'Entrée produisent un seul clic, le retour reste affiché, et deux Entrée sur le dernier « Question suivante » laissent l'écran de fin visible, focus sur le titre.

Et le double-clic à la souris, que j'attendais comme le vrai danger ? Sur le premier jet, un double-clic sur « Valider » n'a compté qu'un point. Pas grâce au code : parce que l'explication, sur deux lignes (48 px), avait poussé le bouton de 24 px vers le bas, et le second clic est tombé dans le vide. Il a suffi de raccourcir l'explication en « Oui. » pour que le bouton ne bouge plus, et le double-clic a affiché « Score : 2 » pour une seule question. Un comportement correct par accident de mise en page n'est pas un comportement correct. C'est le rôle de btnValider.disabled = true et de la garde if (repondu) return : avec la version finale, le même double-clic sur la même explication courte laisse « Score : 1 ».

Le score final et le bouton Rejouer

Passer à la question suivante revient à avancer l'indice et à réafficher, jusqu'à ce qu'il dépasse la dernière question.

function suivant() {
  index++;
  if (index < ordre.length) {
    afficherQuestion();
  } else {
    terminer();
  }
}

function terminer() {
  const s = score > 1 ? "s" : "";
  elScore.textContent = `Terminé : ${score} bonne${s} réponse${s} sur ${ordre.length}`;
  elJeu.hidden = true;
  elFin.hidden = false;
  elScore.focus();
}

function demarrer() {
  ordre = melanger(QUESTIONS);
  index = 0;
  score = 0;
  elFin.hidden = true;
  elJeu.hidden = false;
  afficherQuestion();
}

btnValider.addEventListener("click", valider);
btnSuivant.addEventListener("click", suivant);
btnRejouer.addEventListener("click", demarrer);

demarrer();

La petite variable s gère le pluriel. Trois parties jouées au banc, avec cinq, une puis zéro bonnes réponses, ont affiché « Terminé : 5 bonnes réponses sur 5 », « Terminé : 1 bonne réponse sur 5 » et « Terminé : 0 bonne réponse sur 5 » : en français, après zéro le nom reste au singulier, et la condition score > 1 le respecte.

Rejouer ne recharge pas la page : demarrer() remet l'état à zéro, tire un nouvel ordre au sort et réaffiche la première question. C'est la même fonction qui lance la première partie, en dernière ligne du script. Après un clic sur « Rejouer », la page montre « Question 1 sur 5 · Score : 0 », quatre boutons neufs et un retour vide.

Le clavier a été vérifié en même temps. Depuis le haut de la page, cinq Tab parcourent les quatre réponses puis « Valider », et la touche Espace sur une réponse la sélectionne (aria-pressed passe à true sur elle seule). Aucune ligne de code n'a été écrite pour ça : c'est le comportement natif de <button>, et la raison pour laquelle les réponses sont des boutons et non des <div> cliquables, qui n'auraient ni focus ni touche Espace.

Piège n° 2 : sort() ne mélange pas, il triche

Reste la fonction melanger. La version qu'on trouve partout, et que j'avais écrite dans le premier jet, tient en une ligne : trier le tableau avec une fonction de comparaison qui renvoie un nombre au hasard.

function melanger(tableau) {
  return [...tableau].sort(() => Math.random() - 0.5);
}

Ça a l'air de marcher : d'une partie à l'autre, l'ordre des questions change, et rien n'alerte. Mais « ça a l'air » n'est pas une mesure. J'ai donc demandé au navigateur de mélanger cent mille fois le tableau [0, 1, 2, 3, 4] avec cette fonction, et de noter où finissait chaque élément. Si le mélange était honnête, l'élément 4, le dernier du tableau de départ, finirait à chacune des cinq positions environ 20 000 fois. Voici ce qu'a donné le tirage du 14 septembre 2026 dans Chromium 153 : 6 299, 6 297, 12 721, 24 722 et 49 961. La dernière question de la liste reste dernière une fois sur deux. Le premier élément, lui, finit en première ou deuxième position 61 fois sur 100 (30 500 et 30 827). Et le tableau ressort exactement dans son ordre de départ 6 176 fois, contre 833 attendues (une permutation sur 120).

Où finit le dernier élément (100 000 mélanges)sort() aléatoireFisher-Yates025 %50 %6 %6 %13 %25 %50 %pos. 1pos. 2pos. 3pos. 4pos. 5Chromium 153, tirage du 14 septembre 2026
Avec un comparateur aléatoire, le dernier élément reste dernier une fois sur deux ; l'algorithme de Fisher-Yates donne les 20 % attendus à chaque position.

Pourquoi ? Parce que sort() n'est pas conçu pour ça. La méthode attend une fonction de comparaison cohérente, qui dit toujours la même chose des mêmes éléments ; la documentation MDN prévient que si la fonction ne respecte pas ces règles, « the program's behavior is not well-defined ». Un comparateur qui répond au hasard n'est pas cohérent, et l'algorithme fait alors ce que sa mécanique interne lui dicte. Dans V8, le moteur de Chrome, cette mécanique est TimSort depuis la version 70 de Chrome (billet « Getting things sorted in V8 » du 28 septembre 2018) ; sur un tableau de cinq éléments, trop court pour ses fusions, TimSort complète par un tri par insertion (le billet parle d'une série « boosted artificially » par Insertion Sort), et les chiffres ci-dessus montrent que les éléments restent alors près de leur place de départ. Un autre moteur donnerait d'autres chiffres, tout aussi biaisés ou non : le comportement n'est simplement pas garanti.

La bonne méthode s'appelle le mélange de Fisher-Yates, du nom des deux statisticiens qui l'ont décrite en 1938, dans sa version informatique due à Richard Durstenfeld en 1964. On parcourt le tableau depuis la fin ; à chaque position, on tire au sort un indice entre 0 et la position courante, et on échange les deux éléments. Avec des tirages indépendants et uniformes, chaque permutation a exactement la même probabilité de sortir.

function melanger(tableau) {
  const copie = [...tableau];
  for (let i = copie.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [copie[i], copie[j]] = [copie[j], copie[i]];
  }
  return copie;
}

Même mesure, même tableau, cent mille tirages : le dernier élément finit 20 012, 20 099, 19 974, 20 073 et 19 842 fois à chacune des cinq positions, et l'ordre de départ ressort 778 fois pour 833 attendues. Sur les 120 permutations possibles, la moins fréquente est sortie 757 fois et la plus fréquente 911, contre 80 et 6 233 avec sort(). Relancez la mesure et les chiffres bougeront de quelques centaines, mais la forme des deux résultats, plate d'un côté, en escalier de l'autre, ne bouge pas. Ces proportions sont celles de Chromium 153 : le même script exécuté dans Node v20.19.4 (V8 11.3.244.8-node.29) donne un autre relief pour sort(), 17 149, 10 871, 22 010, 21 880 et 28 090, et l'ordre de départ y ressort 9 292 fois ; le biais change de forme avec le moteur et sa version, il ne disparaît pas, tandis que Fisher-Yates y reste plat (19 895, 20 122, 19 922, 19 995 et 20 066).

La copie par [...tableau] en première ligne n'est pas décorative : sans elle, la boucle mélangerait QUESTIONS lui-même, et chaque partie repartirait de l'ordre laissé par la précédente. Le tableau de départ reste intact, la fonction renvoie un tableau neuf ; sort(), à l'inverse, trie sur place, et la copie était là aussi pour cette raison.

Le fichier complet

Voici quiz.html en entier, style compris, tel qu'il a été mesuré. Copiez-le dans un fichier, ouvrez-le dans votre navigateur, et jouez.

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Quiz : HTML, CSS et JavaScript</title>
    <style>
      body {
        font-family: system-ui, sans-serif;
        max-width: 40rem;
        margin: 2rem auto;
        padding: 0 1rem;
        line-height: 1.5;
      }
      .choix {
        display: grid;
        gap: 0.5rem;
        margin: 1rem 0;
      }
      .choix button {
        text-align: left;
        padding: 0.75rem 1rem;
        font: inherit;
        cursor: pointer;
        border: 2px solid #8a8f98;
        border-radius: 8px;
        background: #fff;
      }
      .choix button[aria-pressed="true"] {
        border-color: #1a56db;
        background: #e8f0fe;
      }
      .choix button.bonne {
        border-color: #0a7a2f;
        background: #e6f6ea;
      }
      .choix button.fausse {
        border-color: #b3261e;
        background: #fbe9e7;
      }
      #retour {
        min-height: 1.5rem;
        font-weight: bold;
      }
      #retour.ok {
        color: #0a7a2f;
      }
      #retour.ko {
        color: #b3261e;
      }
    </style>
  </head>
  <body>
    <h1>Quiz : HTML, CSS et JavaScript</h1>

    <section id="jeu">
      <p id="progression"></p>
      <h2 id="question"></h2>
      <div id="choix" class="choix"></div>
      <p id="retour" aria-live="polite"></p>
      <button id="valider" type="button">Valider</button>
      <button id="suivant" type="button" hidden>Question suivante</button>
    </section>

    <section id="fin" hidden>
      <h2 id="score" tabindex="-1"></h2>
      <button id="rejouer" type="button">Rejouer</button>
    </section>

    <script>
      const QUESTIONS = [
        {
          question:
            "Quelle balise contient le titre affiché dans l'onglet du navigateur ?",
          choix: ["<h1>", "<title>", "<header>", "<meta>"],
          bonne: 1,
          explication:
            "La balise <title> vit dans le <head>. Le <h1> est le titre visible dans la page.",
        },
        {
          question: "Quelle propriété CSS arrondit les coins d'un bloc ?",
          choix: ["border-radius", "corner-radius", "border-round", "round"],
          bonne: 0,
          explication:
            "border-radius accepte une valeur par coin, ou une seule pour les quatre.",
        },
        {
          question: "Que renvoie typeof null en JavaScript ?",
          choix: ['"null"', '"undefined"', '"object"', '"boolean"'],
          bonne: 2,
          explication:
            "C'est une bizarrerie historique du langage, conservée pour ne pas casser les sites existants.",
        },
        {
          question: "Quelle méthode ajoute un élément à la fin d'un tableau ?",
          choix: ["add()", "push()", "append()", "insert()"],
          bonne: 1,
          explication:
            "push() ajoute à la fin et renvoie la nouvelle longueur ; unshift() ajoute au début.",
        },
        {
          question:
            "Quel sélecteur CSS cible l'élément dont l'attribut id vaut menu ?",
          choix: [".menu", "menu", "#menu", "*menu"],
          bonne: 2,
          explication:
            "Le dièse cible un id, le point cible une classe, un mot seul cible une balise.",
        },
      ];

      // Éléments de la page, récupérés une seule fois
      const elProgression = document.getElementById("progression");
      const elQuestion = document.getElementById("question");
      const elChoix = document.getElementById("choix");
      const elRetour = document.getElementById("retour");
      const btnValider = document.getElementById("valider");
      const btnSuivant = document.getElementById("suivant");
      const elJeu = document.getElementById("jeu");
      const elFin = document.getElementById("fin");
      const elScore = document.getElementById("score");
      const btnRejouer = document.getElementById("rejouer");

      // État de la partie
      let ordre = []; // les questions, dans l'ordre de cette partie
      let index = 0; // numéro de la question en cours
      let score = 0;
      let choisi = null; // le bouton sélectionné, ou null
      let repondu = false; // la question en cours a-t-elle été validée ?

      function melanger(tableau) {
        const copie = [...tableau];
        for (let i = copie.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          [copie[i], copie[j]] = [copie[j], copie[i]];
        }
        return copie;
      }

      function afficherQuestion() {
        const q = ordre[index];
        choisi = null;
        repondu = false;

        elProgression.textContent = `Question ${index + 1} sur ${ordre.length} · Score : ${score}`;
        elQuestion.textContent = q.question;
        elRetour.textContent = "";
        elRetour.className = "";
        elChoix.replaceChildren();

        // Chaque option garde la trace de sa justesse : on peut mélanger sans rien perdre
        const options = q.choix.map((texte, i) => ({
          texte,
          estBonne: i === q.bonne,
        }));
        for (const option of melanger(options)) {
          const bouton = document.createElement("button");
          bouton.type = "button";
          bouton.textContent = option.texte;
          bouton.dataset.bonne = option.estBonne;
          bouton.setAttribute("aria-pressed", "false");
          bouton.addEventListener("click", () => choisir(bouton));
          elChoix.append(bouton);
        }

        btnValider.disabled = false;
        btnSuivant.hidden = true;
      }

      function choisir(bouton) {
        if (repondu) return;
        if (choisi) choisi.setAttribute("aria-pressed", "false");
        choisi = bouton;
        choisi.setAttribute("aria-pressed", "true");
      }

      function valider() {
        if (repondu) return;
        if (!choisi) {
          elRetour.textContent = "Choisissez une réponse avant de valider.";
          return;
        }
        repondu = true;
        btnValider.disabled = true;

        const estBonne = choisi.dataset.bonne === "true";
        const q = ordre[index];
        if (estBonne) {
          score++;
          elRetour.textContent = `Bonne réponse ! ${q.explication}`;
          elRetour.className = "ok";
        } else {
          elRetour.textContent = `Raté : la bonne réponse était ${q.choix[q.bonne]}. ${q.explication}`;
          elRetour.className = "ko";
        }
        for (const bouton of elChoix.children) {
          bouton.classList.add(
            bouton.dataset.bonne === "true" ? "bonne" : "fausse",
          );
        }
        elProgression.textContent = `Question ${index + 1} sur ${ordre.length} · Score : ${score}`;

        btnSuivant.hidden = false;
      }

      function suivant() {
        index++;
        if (index < ordre.length) {
          afficherQuestion();
        } else {
          terminer();
        }
      }

      function terminer() {
        const s = score > 1 ? "s" : "";
        elScore.textContent = `Terminé : ${score} bonne${s} réponse${s} sur ${ordre.length}`;
        elJeu.hidden = true;
        elFin.hidden = false;
        elScore.focus();
      }

      function demarrer() {
        ordre = melanger(QUESTIONS);
        index = 0;
        score = 0;
        elFin.hidden = true;
        elJeu.hidden = false;
        afficherQuestion();
      }

      btnValider.addEventListener("click", valider);
      btnSuivant.addEventListener("click", suivant);
      btnRejouer.addEventListener("click", demarrer);

      demarrer();
    </script>
  </body>
</html>

Ce que vous pouvez ajouter ensuite

Le quiz est complet, et c'est le moment de le faire vôtre. Trois extensions, dans l'ordre de difficulté : remplacer les cinq questions par les vôtres, ce qui ne demande que d'éditer le tableau ; afficher un message différent selon le score final (une condition sur score dans terminer) ; retenir le meilleur score entre deux visites, ce qui passe par localStorage et les précautions de relecture décrites dans la todo-list en TypeScript, où des données relues depuis le stockage peuvent avoir n'importe quelle forme.

Et si vous voulez en faire une vitrine, un quiz jouable est un projet plus parlant qu'une capture d'écran dans un portfolio de développeur : le visiteur peut cliquer, se tromper, et voir que le code tient.

Ce qu'il faut emporter tient en trois lignes. Les données d'abord, le HTML ensuite, généré à partir d'elles. Des boutons pour tout ce qui se clique, parce que le clavier et les lecteurs d'écran viennent avec. Et une mesure pour chaque « ça a l'air de marcher » : les deux défauts de ce quiz étaient invisibles à la relecture et sont tombés au premier scénario joué.