A quiz moves through steps, keeps a score, and finishes with a result. It is a good project for managing state and flow.
<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
questionsand a new step appears. - State:
index(where we are),score,answered(so one question cannot be scored twice). showQuestion()andshowResult()each rebuild the stage from scratch. A tinyel()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
- Shuffle the questions and options (see the Fisher-Yates idea in the practice chapter).
- Add a timer per question.
- Save the best score in
localStorage. - Load the questions from a JSON file with
fetch. - Allow questions with multiple correct answers (checkboxes).