Project 13 · Advanced
Quiz App
A five-question quiz on web basics. Each question shows on its own, checking an answer marks it right or wrong with a reason, and the end shows your score, with a button to play again.
- 3 HTML ideas
- 2 CSS ideas
- 7 JavaScript ideas
Why build it
Building an interface from an array of data lets you build personality tests, revision flashcards, a product finder that asks a few questions and suggests a match, and step-by-step onboarding. Because the questions are data, not code, the same code can run any quiz, and that idea, a template filled in from data, is at the heart of most web apps.
Skills you'll earn
- Build a whole interface from an array of data, so new content needs no new code
- Create form controls like radio buttons from JavaScript
- Keep track of progress and a score as someone moves through screens
- Tell screen readers whether an answer was right, with role="status"
- Move focus to the right place each time the screen changes
01
Try it
Play with it first, then read the code that makes it work.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quiz App</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="quiz">
<h1 class="quiz-title">Web basics quiz</h1>
<section id="question-screen">
<p class="progress" id="progress"></p>
<!-- Radios in a form: one answer at a time, and Enter submits it. -->
<form id="question-form">
<!-- The legend is the question, so screen readers read it with each option. -->
<fieldset class="question">
<legend class="question-text" id="question-text"></legend>
<div class="options" id="options"></div>
</fieldset>
<!-- role="status" reads out whether the answer was right. -->
<p class="feedback" id="feedback" role="status"></p>
<button type="submit" class="quiz-button" id="quiz-button">
Check answer
</button>
</form>
</section>
<!-- tabindex="-1" lets JavaScript move focus here at the end. -->
<section class="results" id="results" tabindex="-1" hidden>
<h2 class="results-title">Your score</h2>
<p class="results-score" id="results-score"></p>
<p class="results-message" id="results-message"></p>
<button type="button" class="quiz-button" id="play-again">
Play again
</button>
</section>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
HTMLthe structure
- 01
Make each question a
<fieldset>with a<legend>A
<fieldset>groups related form controls, and its<legend>names the group. Here the legend holds the question, so screen readers read it along with each answer option, and the options make sense on their own. Wrapping it all in a<form>also gives you keyboard behaviour for free: pressing Enter submits the answer.<!-- Radios in a form: one answer at a time, and Enter submits it. --> <form id="question-form"> <!-- The legend is the question, so screen readers read it with each option. --> <fieldset class="question"> <legend class="question-text" id="question-text"></legend> <div class="options" id="options"></div> </fieldset> - 02
Announce right or wrong with
role="status"role="status"turns an element into a polite live region: when its text changes, screen readers read the new text out once the person pauses, without interrupting. Here that's the feedback line, so "Correct!" or the right answer is announced as soon as it appears, even though focus stays on the button.<!-- role="status" reads out whether the answer was right. --> <p class="feedback" id="feedback" role="status"></p> - 03
Keep the results ready but hidden
The results section is written in the HTML from the start, with the
hiddenattribute so it isn't shown. JavaScript only fills it in and unhides it at the end. Most elements can't take focus, buttabindex="-1"lets JavaScript move focus to it withfocus(), without adding it to the Tab order.<!-- tabindex="-1" lets JavaScript move focus here at the end. --> <section class="results" id="results" tabindex="-1" hidden> <h2 class="results-title">Your score</h2> <p class="results-score" id="results-score"></p> <p class="results-message" id="results-message"></p> <button type="button" class="quiz-button" id="play-again"> Play again </button> </section>
CSSthe look
- 04
Turn each answer into a card
Clicking a
<label>acts like clicking the input it's connected to. Each.optionlabel wraps its radio and text, so a click anywhere on the card picks that answer.:has()lets a parent be styled by what's inside it:.option:has(input:checked)highlights the picked card, and:has(input:focus-visible)outlines it for keyboard users./* The whole card is the label, so a click anywhere on it picks the answer. */ .option { display: flex; align-items: center; gap: 0.75rem; padding: 0.875rem 1rem; border: 2px solid #e4e4e8; border-radius: 0.875rem; cursor: pointer; } .option:hover { border-color: #1c1c22; } .option:has(input:checked) { border-color: #1c1c22; background: #f4f4f6; } .option:has(input:focus-visible) { outline: 3px solid #2563eb; outline-offset: 2px; } - 05
Say why in words, not only colour
Some people can't tell green from red, so colour on its own can't carry meaning. The
::afterpseudo-element adds content after an element using only CSS. Here it adds the words "Correct answer" to the.is-correctcard and "Your answer" to the.is-wrongone, so the result is clear without seeing colour./* Colour alone isn't enough, so each marked option also says why in words. */ .option.is-correct { border-color: #15803d; background: #f0fdf4; } .option.is-correct::after { content: "Correct answer"; margin-left: auto; font-size: 0.75rem; font-weight: 600; color: #15803d; } .option.is-wrong { border-color: #b91c1c; background: #fef2f2; } .option.is-wrong::after { content: "Your answer"; margin-left: auto; font-size: 0.75rem; font-weight: 600; color: #b91c1c; }
JavaScriptthe behaviour
- 06
Store the questions as data
An array is an ordered list of values, and an object groups named values together.
questionsis an array of objects, one per question, each with itsquestion, itsoptions, and the position of the right one inanswer(counting from 0). The code reads from this list, so changing the quiz means editing the data, not the code.// The quiz is data: each question, its options, and the right option's index. const questions = [ { question: "Which language gives a web page its structure?", options: ["HTML", "CSS", "JavaScript"], answer: 0, }, { question: "Which CSS property changes the colour of text?", options: ["font-color", "color", "text-color"], answer: 1, }, { question: "What does document.querySelector() give you back?", options: [ "Every matching element", "The first matching element", "How many elements match", ], answer: 1, }, { question: "Which of these still has your data after the page is closed?", options: ["A let variable", "localStorage", "A const variable"], answer: 1, }, { question: "What does === check?", options: ["Only the value", "The value and the type", "Only the type"], answer: 1, }, ]; - 07
Track where you are with three variables
State is the information your code has to remember while the quiz runs. Three
letvariables hold it, since each one changes:currentIndexis the question you're on,scorecounts right answers, andisCheckedrecords whether the current question has been checked yet. The rest of the code reads these to decide what to show.let currentIndex = 0; let score = 0; // Once an answer is checked, the button moves on instead of checking again. let isChecked = false; - 08
Build the options from the data
map()runs a function on every item in an array and returns a new array of the results. Here it turns each option's text into a<label>with a radio inside. The radios share the nameanswer, so picking one unpicks the others, andrequiredstops the form submitting with nothing chosen.replaceChildren()then swaps the old options for the new ones.function showQuestion() { const { question, options } = questions[currentIndex]; progress.textContent = `Question ${currentIndex + 1} of ${questions.length}`; questionText.textContent = question; // One radio per option; sharing a name makes them a single choice. const optionLabels = options.map((option, index) => { const label = document.createElement("label"); label.className = "option"; const radio = document.createElement("input"); radio.type = "radio"; radio.name = "answer"; radio.value = index; // required stops the form submitting with nothing picked. radio.required = true; label.append(radio, option); return label; }); optionsList.replaceChildren(...optionLabels); feedback.textContent = ""; isChecked = false; quizButton.textContent = "Check answer"; } - 09
Compare the pick with the answer
A radio's
valueis always stored as text, even when you set it to a number, soNumber()turns the picked value back into a number.===checks the value and the type match, so comparing"1"with1would fail without that conversion. The right option's card getsis-correct, and a wrong pick getsis-wrong.function checkAnswer() { const { options, answer } = questions[currentIndex]; const picked = form.querySelector('input[name="answer"]:checked'); const isCorrect = Number(picked.value) === answer; if (isCorrect) score++; // Mark the right answer, and the wrong pick, so the reason is visible. const labels = optionsList.querySelectorAll(".option"); labels[answer].classList.add("is-correct"); if (!isCorrect) picked.closest(".option").classList.add("is-wrong"); - 10
Lock the answer with
disabledA
disabledform control can't be clicked, changed or focused. Once an answer is checked,forEach()goes through every radio and disables it, so the answer can't be swapped afterwards. Disabled inputs are also skipped by therequiredcheck, which is what lets the form submit again to move on.// Lock the options, so the answer can't be changed after checking. optionsList.querySelectorAll("input").forEach((radio) => { radio.disabled = true; }); - 11
Let one button check, then move on
The same submit button does two jobs, and
isCheckeddecides which. On submit, if the answer hasn't been checked, it callscheckAnswer(); otherwise it moves to the next question or the results.event.preventDefault()stops the form reloading the page. The button text changes to match, so it always says what it'll do next.feedback.textContent = isCorrect ? "Correct!" : `Not quite. The answer is ${options[answer]}.`; isChecked = true; const isLast = currentIndex === questions.length - 1; quizButton.textContent = isLast ? "See your score" : "Next question"; } function showResults() { questionScreen.hidden = true; results.hidden = false; resultsScore.textContent = `${score} out of ${questions.length}`; resultsMessage.textContent = score === questions.length ? "A perfect score. Well done!" : "Have another go to beat your score."; // Move focus to the results, so screen readers announce them. results.focus(); } // The same button checks the answer, then moves to the next question. form.addEventListener("submit", (event) => { event.preventDefault(); if (!isChecked) { checkAnswer(); return; } currentIndex++; if (currentIndex < questions.length) { showQuestion(); // Start keyboard users on the new question's first option. optionsList.querySelector("input").focus(); } else { showResults(); } }); - 12
Show the score, then start again
After the last question,
showResults()hides the question screen, shows the score and a message, and moves focus to the results so screen readers announce them. Play again resetscurrentIndexandscoreto 0, swaps the screens back, and callsshowQuestion(), which also resetsisChecked, so the quiz starts fresh.function showResults() { questionScreen.hidden = true; results.hidden = false; resultsScore.textContent = `${score} out of ${questions.length}`; resultsMessage.textContent = score === questions.length ? "A perfect score. Well done!" : "Have another go to beat your score."; // Move focus to the results, so screen readers announce them. results.focus(); } // The same button checks the answer, then moves to the next question. form.addEventListener("submit", (event) => { event.preventDefault(); if (!isChecked) { checkAnswer(); return; } currentIndex++; if (currentIndex < questions.length) { showQuestion(); // Start keyboard users on the new question's first option. optionsList.querySelector("input").focus(); } else { showResults(); } }); playAgainButton.addEventListener("click", () => { currentIndex = 0; score = 0; results.hidden = true; questionScreen.hidden = false; showQuestion(); optionsList.querySelector("input").focus(); }); showQuestion();
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Shuffle the questions and their options each time you play.
- Challenge 02
Add a 15-second timer to each question.
- Challenge 03
Save the best score in localStorage and show it on the results screen.
Finished Quiz App?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)