Skip to content

JavaScript Tutorial Practical Projects

Project 13: A Quiz App

Lesson 252 of 258 2 min read By Deepak Paswan Updated Oct 4, 2026

A quiz moves through steps, keeps a score, and finishes with a result. It is a good project for managing state and flow.

index.html
HTML
<style>
  .quiz { font-family: system-ui, sans-serif; max-width: 360px; }
  .progress { height: 6px; background: #eee; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
  .progress div { height: 100%; background: #1a5276; width: 0; transition: width .3s; }
  .options { display: grid; gap: 6px; margin: 10px 0; padding: 0; border: 0; }
  .options label { display: block; padding: 10px; border: 2px solid #c9dbe6; border-radius: 8px; cursor: pointer; }
  .options label.correct { border-color: #1e8449; background: #eafaf1; }
  .options label.wrong { border-color: #b00020; background: #fdecea; }
  button { font: inherit; padding: 8px 16px; background: #1a5276; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
  button:disabled { opacity: .4; cursor: not-allowed; }
  .feedback { min-height: 1.4em; font-weight: 600; }
</style>

<div class="quiz" id="quiz">
  <div class="progress" aria-hidden="true"><div id="bar"></div></div>
  <div id="stage"></div>
</div>

<script>
  const questions = [
    { q: "Which keyword declares a variable that cannot be reassigned?", options: ["let", "const", "var"], answer: 1,
      why: "const stops the name from being reassigned." },
    { q: "What does === check?", options: ["Value only", "Type only", "Value and type"], answer: 2,
      why: "Strict equality compares both value and type." },
    { q: "Which method creates a new array from an old one by transforming each item?", options: ["forEach", "map", "push"], answer: 1,
      why: "map returns a new array with each item transformed." },
    { q: "What does fetch() return?", options: ["The data", "A Promise", "A string"], answer: 1,
      why: "fetch returns a Promise that resolves to a Response." }
  ];

  let index = 0;
  let score = 0;
  let answered = false;

  const stage = document.querySelector("#stage");
  const bar = document.querySelector("#bar");

  function el(tag, props = {}, ...children) {
    const node = Object.assign(document.createElement(tag), props);
    node.append(...children);
    return node;
  }

  function showQuestion() {
    answered = false;
    const item = questions[index];
    bar.style.width = (index / questions.length) * 100 + "%";

    const fieldset = el("fieldset", { className: "options" }, el("legend", { textContent: `Question ${index + 1} of ${questions.length}: ${item.q}` }));
    item.options.forEach((text, i) => {
      const input = el("input", { type: "radio", name: "answer", value: i });
      fieldset.append(el("label", {}, input, " " + text));
    });

    const feedback = el("p", { className: "feedback", role: "status" });
    const next = el("button", { textContent: index === questions.length - 1 ? "Finish" : "Next", disabled: true });
    const check = el("button", { textContent: "Check answer" });

    check.addEventListener("click", () => {
      const chosen = fieldset.querySelector("input:checked");
      if (!chosen || answered) { feedback.textContent = answered ? feedback.textContent : "Choose an answer first."; return; }
      answered = true;
      const correct = Number(chosen.value) === item.answer;
      if (correct) score++;
      fieldset.querySelectorAll("label")[item.answer].classList.add("correct");
      if (!correct) chosen.closest("label").classList.add("wrong");
      fieldset.querySelectorAll("input").forEach(i => { i.disabled = true; });
      feedback.textContent = (correct ? "Correct! " : "Not quite. ") + item.why;
      check.disabled = true;
      next.disabled = false;
    });

    next.addEventListener("click", () => {
      index++;
      index < questions.length ? showQuestion() : showResult();
    });

    stage.replaceChildren(fieldset, feedback, check, " ", next);
  }

  function showResult() {
    bar.style.width = "100%";
    const percent = Math.round((score / questions.length) * 100);
    const restart = el("button", { textContent: "Try again" });
    restart.addEventListener("click", () => { index = 0; score = 0; showQuestion(); });

    stage.replaceChildren(
      el("h3", { textContent: `You scored ${score} out of ${questions.length} (${percent}%)` }),
      el("p", { textContent: percent === 100 ? "A perfect score!" : "Review the explanations and try again." }),
      restart
    );
  }

  showQuestion();
</script>

How it works

  • Data drives the quiz: add an object to questions and a new step appears.
  • State: index (where we are), score, answered (so one question cannot be scored twice).
  • showQuestion() and showResult() each rebuild the stage from scratch. A tiny el() helper makes building elements short.
  • Radio buttons in a <fieldset> with a <legend>: accessible and keyboard-friendly by default.
  • Feedback uses text, colour and a message, so colour is not the only signal.

Try changing it

  1. Shuffle the questions and options (see the Fisher-Yates idea in the practice chapter).
  2. Add a timer per question.
  3. Save the best score in localStorage.
  4. Load the questions from a JSON file with fetch.
  5. Allow questions with multiple correct answers (checkboxes).