LEARN TO CODE | JAVASCRIPT | 04 APIS AND MINI PROJECTS
04.3 Project: an interactive quiz
Combine structured question data, DOM rendering and event-driven state into a quiz that cannot score the same question twice.
01 | PLAN A SMALL, FINISHED QUIZ
Your project is a five-question JavaScript knowledge quiz. It displays one question at a time, offers radio-button choices and explains the answer after checking.
Each question has exactly one correct answer . Students should not need to guess whether several options are expected. Radio buttons communicate that one-choice rule.
The result is practice feedback in the browser. The answers are included in the client-side source, so this is not a secure examination or a way to hide a mark scheme.
Use VS Code to edit index.html, script.js and style.css in one project folder. Link the external script with defer. Save changes and refresh the browser; inspect both the visible page and the Console.
The complete download has a separate folder for each project. Keep the examples independent to avoid colliding top-level variable names.
02 | DEFINE THE ACCEPTANCE CRITERIA
| Requirement | Evidence |
|---|---|
| One answer per question | Options share a radio-group name |
| No unanswered scoring | Check with no choice gives guidance |
| One score opportunity | Repeated check cannot add points |
| Explain the answer | Feedback includes a reason |
| No skipping an unchecked question | Next is hidden and its handler also checks state |
| Restart completely | Question index, score and controls return to the start |
Write the rules before coding. They reveal why the app needs more than a click listener that adds one to a score.
03 | MODEL A QUESTION AS A RECORD
{
"prompt": "Which keyword allows a variable binding to be reassigned?",
"options": [
"const",
"let",
"return"
],
"answer": 1,
"explanation": "let permits reassignment of the binding."
}
prompt holds the question. options is an ordered array of labels. answer is the zero-based index of the one correct option, so 1 means the second option. explanation provides useful feedback after checking.
The complete project declares a JavaScript array of these records directly. It does not fetch JSON yet. Keep the question data separate from the rendering and scoring functions.
When authoring another question, check that the answer index is in range and that no other option is also correct. Code can enforce an index shape, but subject accuracy still requires review.
04 | CHOOSE THE STATE VARIABLES
let index = 0;
let score = 0;
let answered = false;
index selects the current record. score counts correct answers already checked. answered records whether the current question has used its one scoring opportunity.
The visible controls reflect this state. Hiding Next is helpful, but the Next handler also checks answered. Disabling Check is helpful, but its handler also returns immediately if answered is already true.
Those guards protect the flow from repeated events. This is local application consistency, not a security boundary against a visitor editing their own script.
05 | RENDER FRESH OPTIONS
renderQuestion clears the previous options and builds a paragraph, label and radio input for each new option. Every input shares name="quiz-answer", allowing one selected option in that group.
The label wraps the input and its text, so selecting the text also selects the radio. A space is added before the text node. The surrounding fieldset and legend give the group an accessible question label.
Option text is inserted with createTextNode rather than innerHTML. Rebuilding the options resets their checked and disabled states for the new question.
06 | CHECK ONCE, THEN EXPLAIN
The submit handler first prevents page navigation, then checks the answered guard. If no option is selected, it asks the learner to choose one and does not change the score.
For a selection, the handler converts its index text to a number and compares it with the authored correct index. It updates score only if correct, then locks the answer and shows an explanation.
After checking, focus moves to the newly available Next button. This makes keyboard progression easier while feedback remains visible in a status region.
07 | THE COMPLETE QUIZ
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A one-answer interactive quiz</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>A one-answer interactive quiz</h1>
<p>Choose one answer per question. Check it once, read the explanation, then continue.</p>
<p id="quiz-progress" role="status" aria-live="polite"></p>
<form id="quiz-form">
<fieldset id="quiz-fieldset"><legend id="quiz-question"></legend><div id="quiz-options"></div></fieldset>
<button id="quiz-check" type="submit">Check answer</button>
<button id="quiz-next" type="button" hidden>Next question</button>
</form>
<p id="quiz-feedback" role="status" aria-live="polite"></p>
<section id="quiz-summary" hidden><h2>Quiz complete</h2><p id="quiz-score"></p><button id="quiz-restart" type="button">Try again</button></section>
</main>
</body>
</html>
script.js
const questions = [
{
"prompt": "Which keyword allows a variable binding to be reassigned?",
"options": [
"const",
"let",
"return"
],
"answer": 1,
"explanation": "let permits reassignment of the binding."
},
{
"prompt": "Which property or method displays a value as plain text in an element?",
"options": [
"innerHTML",
"JSON.parse",
"textContent"
],
"answer": 2,
"explanation": "textContent inserts plain text rather than parsing markup."
},
{
"prompt": "What does a function with no explicit return value normally return?",
"options": [
"undefined",
"0",
"false"
],
"answer": 0,
"explanation": "Reaching the end of an ordinary function returns undefined."
},
{
"prompt": "Which check detects an HTTP 404 response after fetch fulfils?",
"options": [
"response.ok",
"Array.isArray(response)",
"response === null"
],
"answer": 0,
"explanation": "response.ok is false for a 404 HTTP status."
},
{
"prompt": "Which method turns a plain data object into JSON text?",
"options": [
"JSON.parse",
"JSON.stringify",
"querySelector"
],
"answer": 1,
"explanation": "JSON.stringify serialises suitable data into JSON text."
}
];
const form = document.querySelector("#quiz-form");
const progress = document.querySelector("#quiz-progress");
const legend = document.querySelector("#quiz-question");
const optionsBox = document.querySelector("#quiz-options");
const checkButton = document.querySelector("#quiz-check");
const nextButton = document.querySelector("#quiz-next");
const feedback = document.querySelector("#quiz-feedback");
const summary = document.querySelector("#quiz-summary");
let index = 0;
let score = 0;
let answered = false;
function renderQuestion() {
answered = false;
const question = questions[index];
progress.textContent = `Question ${index + 1} of ${questions.length}`;
legend.textContent = question.prompt;
optionsBox.replaceChildren();
question.options.forEach((text, optionIndex) => {
const paragraph = document.createElement("p");
const label = document.createElement("label");
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "quiz-answer";
radio.value = String(optionIndex);
label.append(radio, document.createTextNode(` ${text}`));
paragraph.append(label);
optionsBox.append(paragraph);
});
checkButton.disabled = false;
nextButton.hidden = true;
nextButton.textContent = index === questions.length - 1 ? "Show score" : "Next question";
feedback.textContent = "";
}
form.addEventListener("submit", (event) => {
event.preventDefault();
if (answered) return;
const selected = optionsBox.querySelector('input[name="quiz-answer"]:checked');
if (selected === null) {
feedback.textContent = "Choose one answer before checking.";
return;
}
const question = questions[index];
const correct = Number(selected.value) === question.answer;
if (correct) score += 1;
answered = true;
for (const radio of optionsBox.querySelectorAll("input")) radio.disabled = true;
checkButton.disabled = true;
nextButton.hidden = false;
feedback.textContent = `${correct ? "Correct." : "Not quite."} ${question.explanation}`;
nextButton.focus();
});
nextButton.addEventListener("click", () => {
if (!answered || index >= questions.length) return;
index += 1;
if (index === questions.length) {
form.hidden = true;
summary.hidden = false;
progress.textContent = "All questions completed.";
feedback.textContent = "";
document.querySelector("#quiz-score").textContent = `You scored ${score} out of ${questions.length}.`;
document.querySelector("#quiz-restart").focus();
return;
}
renderQuestion();
optionsBox.querySelector("input").focus();
});
document.querySelector("#quiz-restart").addEventListener("click", () => {
index = 0;
score = 0;
summary.hidden = true;
form.hidden = false;
renderQuestion();
optionsBox.querySelector("input").focus();
});
renderQuestion();
08 | TRY THE COMPLETE FLOW
Choose one answer per question. Check it once, read the explanation, then continue.
Quiz complete
Try checking without selecting, answer incorrectly once, complete the rest and restart. A restart should not retain the previous score or leave disabled options behind.
TRY IT YOURSELF
Why are both a disabled Check button and an answered guard useful?
Reveal guidance
The disabled control guides ordinary interaction. The handler guard protects the score logic if another submit event occurs. Both support the same one-scoring-opportunity requirement.
09 | FINISH WITHOUT READING PAST THE ARRAY
After the last checked question, Next increments index to questions.length. The handler checks that boundary before trying to render another question.
The form is hidden, the summary is shown and the final score is displayed. There is no attempt to read a question beyond the final array position.
Restart sets index and score back to zero, reveals the form and calls renderQuestion. answered is reset there. Keeping the reset logic explicit makes it easier to test.
10 | TEST A FEW COMPLETE ATTEMPTS
| Attempt | Expected |
|---|---|
| Every answer correct | 5 out of 5 |
| Every answer wrong | 0 out of 5 |
| No selection then Check | Guidance only; same question |
| Repeated submission after a correct answer | No extra point |
| Next before checking | No advancement |
| Restart after a completed quiz | Question 1, fresh options and fresh score |
Also use only the keyboard through one attempt. Inspect the fieldset, labels and focus progression. Do not announce the interface accessible solely because it contains one ARIA attribute.
11 | CHOOSE ONE DELIBERATE EXTENSION
TRY IT YOURSELF
Add a sixth single-answer question. Which checks should you repeat?
Reveal guidance
Check the new correct index and explanation, then repeat all-correct/all-wrong totals, progress labels, final boundary and restart. The existing questions.length expressions should adapt without hard-coded five-question logic.
A later extension can fetch the question bank using the previous lesson’s loading and validation pattern. Validate every question’s options and answer index before enabling the quiz, and provide a retry state if loading fails.
KEEP LEARNING
Previous page 04.4 Project: a to-do list
Download Section 04 example programs
Further reading: Promises · Fetch · JSON · Browser storage