Creating a mini quiz in JavaScript
Time to put the course together. This project builds a small multiple-choice quiz: three questions stored in an array, rendered onto the page by JavaScript, checked when the reader submits the form, with a score at the end. Nearly every earlier lesson turns up somewhere in it. The complete code comes first, so you can run it before you read the explanation.
The plan
Decide where each part lives before writing any code:
- The questions are data, so they go into an array of objects, the shape from the arrays lesson. Each object holds the question text, a list of choices and the index of the correct choice.
- The HTML holds only an empty form, two buttons and a line for the result. JavaScript fills in the questions.
- CSS describes how a right or a wrong answer looks. The script only adds and removes classes.
With the questions kept out of the HTML, adding a fourth one means adding one object and touching nothing else.
The page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Mini quiz</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 36rem; margin: 2rem auto; padding: 0 1rem; }
.question { border: 2px solid #ccc; border-radius: 6px; margin: 0 0 1rem; padding: 0.75rem 1rem; }
.question legend { font-weight: bold; padding: 0 0.25rem; }
.question label { display: block; margin: 0.35rem 0; cursor: pointer; }
.question.is-right { border-color: #2e8b57; background: #eef8f1; }
.question.is-wrong { border-color: #b03a2e; background: #fbeeee; }
.quiz-result { font-weight: bold; min-height: 1.5em; }
</style>
<script src="quiz.js" defer></script>
</head>
<body>
<h1>Mini quiz</h1>
<form id="quiz" class="quiz">
<div id="questions"></div>
<button type="submit">Check answers</button>
<button type="reset">Start over</button>
</form>
<p id="result" class="quiz-result" aria-live="polite"></p>
</body>
</html>
aria-live="polite" asks screen readers to announce the result line whenever its text changes, so the score is not silent for someone who cannot see it appear.
The script
Save this as quiz.js next to the page. In the online HTML editor, paste it inside a <script> element just before </body> instead, and delete the <script src> line from the head.
const questions = [
{
question: 'Which method returns the first element that matches a CSS selector?',
choices: ['querySelectorAll()', 'querySelector()', 'getElementsByClassName()'],
answer: 1
},
{
question: "What does '5' === 5 evaluate to?",
choices: ['true', 'false', 'An error'],
answer: 1
},
{
question: 'Which event fires once the HTML is parsed, without waiting for images?',
choices: ['DOMContentLoaded', 'load', 'click'],
answer: 0
}
];
const form = document.querySelector('#quiz');
const container = document.querySelector('#questions');
const result = document.querySelector('#result');
function renderQuestion(item, index) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'question';
const legend = document.createElement('legend');
legend.textContent = `${index + 1}. ${item.question}`;
fieldset.append(legend);
item.choices.forEach((choice, choiceIndex) => {
const label = document.createElement('label');
const input = document.createElement('input');
input.type = 'radio';
input.name = `q${index}`;
input.value = choiceIndex;
label.append(input, ` ${choice}`);
fieldset.append(label);
});
return fieldset;
}
function renderQuiz() {
questions.forEach((item, index) => {
container.append(renderQuestion(item, index));
});
}
function checkAnswers() {
let score = 0;
let unanswered = 0;
questions.forEach((item, index) => {
const fieldset = container.children[index];
const picked = fieldset.querySelector('input:checked');
fieldset.classList.remove('is-right', 'is-wrong');
if (picked === null) {
unanswered++;
} else if (Number(picked.value) === item.answer) {
score++;
fieldset.classList.add('is-right');
} else {
fieldset.classList.add('is-wrong');
}
});
return { score: score, unanswered: unanswered };
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const outcome = checkAnswers();
const total = questions.length;
if (outcome.unanswered > 0) {
result.textContent = `Answer every question first (${outcome.unanswered} left).`;
} else if (outcome.score === total) {
result.textContent = `All ${total} right. Nicely done.`;
} else {
result.textContent = `You got ${outcome.score} of ${total}. The highlighted boxes show where to look again.`;
}
});
form.addEventListener('reset', () => {
for (const fieldset of container.children) {
fieldset.classList.remove('is-right', 'is-wrong');
}
result.textContent = '';
});
renderQuiz();
How it works
Rendering
renderQuestion turns one question object into one <fieldset>, the HTML element made for grouping related form controls, with a <legend> as its caption. The inner forEach builds a radio button and a label for every choice. Radio buttons that share a name form a group in which only one can be selected, so the names q0, q1 and q2 keep each question's choices separate. Wrapping each input inside its <label> makes the whole line clickable without any ids. The callback's second argument, choiceIndex, becomes the radio's value, which is how the script later knows which choice was picked.
The function returns the finished fieldset instead of putting it on the page itself. That keeps it easy to inspect: type renderQuestion(questions[0], 0) in the console and look at what comes back. renderQuiz then appends one fieldset per question.
Checking
checkAnswers walks through the questions and pairs each one with its fieldset through container.children[index], one of the relational properties. fieldset.querySelector('input:checked') searches only inside that fieldset and returns the selected radio, or null when nothing was picked. An if, else if, else chain then sorts each question into unanswered, right or wrong. A radio's value is always a string, so Number() converts it before the strict comparison; '1' === 1 would be false every time and the score would stay at zero.
The function returns an object holding both counts instead of writing to the page. Logic in one place, display in another, which makes either easy to change on its own. Modern JavaScript lets you shorten { score: score, unanswered: unanswered } to { score, unanswered }; the long form just shows what is going on.
Submitting and resetting
The submit listener calls preventDefault() so the browser does not reload the page, then picks a message from the counts. Unanswered questions come first in the chain, since a score for a half-finished quiz means little. The reset button clears the radio buttons by itself, because that is built-in form behavior. Its listener only removes the color classes and empties the result line.
Where to take it next
- Show the correct answer after a wrong pick by adding a class to the right label.
- Shuffle the choices, remembering to update
answerso it still points at the correct one. - Move the questions into a
quiz.jsonfile and load them withfetch, as in the requests lesson.
For the last idea, replace the questions array at the top with an empty one declared with let, and replace the final renderQuiz(); line with this:
async function start() {
const response = await fetch('quiz.json');
if (!response.ok) {
result.textContent = `Could not load the questions (${response.status}).`;
return;
}
questions = await response.json();
renderQuiz();
}
start();
The rest of the script stays as it is, because checkAnswers reads questions only when someone submits. Remember the same-origin rule: this version needs the local server, not a double-click on the file.
Try it
Run the finished quiz in the online HTML editor and get one answer wrong on purpose to see the red box. Then add a fourth question object to the array, any question you like with three choices, and check that the result line now counts to 4 without a single change to the HTML.