Skip to main content

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.

Result0px
New tab (opens in a new tab)
Download
<!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

  1. 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>
  2. 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>
  3. 03

    Keep the results ready but hidden

    The results section is written in the HTML from the start, with the hidden attribute so it isn't shown. JavaScript only fills it in and unhides it at the end. Most elements can't take focus, but tabindex="-1" lets JavaScript move focus to it with focus(), 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

  1. 04

    Turn each answer into a card

    Clicking a <label> acts like clicking the input it's connected to. Each .option label 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;
    }
  2. 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 ::after pseudo-element adds content after an element using only CSS. Here it adds the words "Correct answer" to the .is-correct card and "Your answer" to the .is-wrong one, 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

  1. 06

    Store the questions as data

    An array is an ordered list of values, and an object groups named values together. questions is an array of objects, one per question, each with its question, its options, and the position of the right one in answer (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,
    	},
    ];
  2. 07

    Track where you are with three variables

    State is the information your code has to remember while the quiz runs. Three let variables hold it, since each one changes: currentIndex is the question you're on, score counts right answers, and isChecked records 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;
  3. 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 name answer, so picking one unpicks the others, and required stops 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";
    }
  4. 09

    Compare the pick with the answer

    A radio's value is always stored as text, even when you set it to a number, so Number() turns the picked value back into a number. === checks the value and the type match, so comparing "1" with 1 would fail without that conversion. The right option's card gets is-correct, and a wrong pick gets is-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");
    
  5. 10

    Lock the answer with disabled

    A disabled form 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 the required check, 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;
    	});
  6. 11

    Let one button check, then move on

    The same submit button does two jobs, and isChecked decides which. On submit, if the answer hasn't been checked, it calls checkAnswer(); 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();
    	}
    });
  7. 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 resets currentIndex and score to 0, swaps the screens back, and calls showQuestion(), which also resets isChecked, 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.

  1. Challenge 01

    Shuffle the questions and their options each time you play.

  2. Challenge 02

    Add a 15-second timer to each question.

  3. 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.

You finished Quiz App!

0 of 15 projects done.

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)