LearnAI ToolsCareerPractice BuildsPlayContact
JavaScriptIntermediate~2 hours

Quiz Application

Build a timed multiple-choice quiz that scores answers and shows a results summary.

ArraysObjectsEvents

Overview

A quiz application is a natural project for practicing arrays of objects, because the entire quiz — every question, its choices, and the correct answer — is just data, and the whole app is really a set of functions that walk through that data one item at a time. Layering a per-question countdown timer on top adds `setInterval`/`clearInterval` to the same event-driven skills you already used for clicks.

This tutorial builds a five-question multiple-choice quiz: each question counts down from a fixed number of seconds, locking in whatever is selected (or nothing, if time runs out) before automatically moving to the next question. At the end, a results screen swaps in to show the final score out of five.

What You'll Build
  • A `questions` array where each entry is an object with its text, its options, and the correct answer's index.
  • A render function that rebuilds the on-screen options from whichever question is current.
  • Click handling that locks in an answer, highlights right vs. wrong, and updates the score.
  • A `setInterval`-based countdown timer for every question, with automatic advance on timeout.
  • A results screen that swaps in once every question has been answered, showing the final score.

Prerequisites

  • Arrays — indexing (`questions[i]`), `forEach`, and `.length`.
  • Objects — reading properties off objects stored inside an array.
  • Events — `addEventListener`, event delegation with `closest()`, and reading `dataset`.
  • Timers — `setInterval` and `clearInterval`.
  • DOM basics — creating elements, toggling classes, and showing/hiding sections.

Project Structure

The page has two "screens" that are never both visible: `#quiz-screen`, showing the current question, its options, and the timer, and `#results-screen`, showing the final score and a restart button. One `state` object tracks which question is current, the running score, whether the current question has already been answered, and the timer's remaining seconds and interval id.

The HTML and CSS below are introduced once and stay fixed for the rest of the tutorial — options are never hard-coded into the HTML, they are always generated by JavaScript from the `questions` array, which is what lets the same markup work for a quiz of any length.

<div class="quiz-app">
<div id="quiz-screen">
<div class="quiz-header">
<span id="question-count"></span>
<span id="timer"></span>
</div>
<h2 id="question-text"></h2>
<!-- Filled in by JS with one button per option for the current question -->
<div id="options"></div>
</div>
<!-- Hidden until the last question has been answered -->
<div id="results-screen" class="hidden">
<h2>Quiz Complete!</h2>
<p id="final-score"></p>
<button id="restart-btn">Try Again</button>
</div>
</div>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.quiz-app {
width: 100%;
max-width: 460px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.quiz-header {
display: flex;
justify-content: space-between;
color: #9aa4b2;
font-size: 0.9rem;
margin-bottom: 12px;
}
#question-text {
margin-bottom: 16px;
font-size: 1.2rem;
}
#options {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-btn {
padding: 12px 14px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
text-align: left;
cursor: pointer;
font-size: 1rem;
}
.option-btn:disabled {
cursor: default; /* Options are disabled the instant one is locked in */
}
.option-btn.correct {
border-color: #3ecf8e;
background: rgba(62, 207, 142, 0.15); /* Always highlighted once answered, whether picked or not */
}
.option-btn.incorrect {
border-color: #ff6b6b;
background: rgba(255, 107, 107, 0.15); /* Only the wrongly-picked option gets this */
}
#results-screen {
text-align: center;
}
#results-screen h2 {
margin-bottom: 12px;
}
#final-score {
color: #9aa4b2;
margin-bottom: 20px;
}
#restart-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
.hidden {
display: none;
}

Step 1: Model the Questions as an Array of Objects

Every question is a plain object with three fields: the question text, an array of option strings, and `correctIndex` — the position in `options` that holds the right answer. Modeling the quiz as data like this (instead of hard-coding five separate blocks of HTML) is what lets the same rendering code handle any question.

const questions = [
{
question: 'Which keyword declares a variable that can be reassigned?',
options: ['const', 'let', 'final', 'static'],
correctIndex: 1,
},
{
question: 'What does DOM stand for?',
options: ['Data Object Model', 'Document Object Model', 'Display Output Method', 'Direct Object Mapping'],
correctIndex: 1,
},
{
question: 'Which array method adds an item to the end?',
options: ['array.push()', 'array.pop()', 'array.shift()', 'array.slice()'],
correctIndex: 0,
},
{
question: 'Which operator checks both value and type?',
options: ['==', '=', '===', '!='],
correctIndex: 2,
},
{
question: 'Which event fires when a form is submitted?',
options: ['click', 'submit', 'change', 'load'],
correctIndex: 1,
},
];

Step 2: Track Quiz State and Select Elements

Alongside element references, `state` tracks everything about where the user currently is in the quiz — which question, the running score, whether this question has already been locked in, and the countdown's remaining time and interval id (so it can be cancelled cleanly between questions).

const questionCountEl = document.getElementById('question-count');
const timerEl = document.getElementById('timer');
const questionTextEl = document.getElementById('question-text');
const optionsEl = document.getElementById('options');
const quizScreen = document.getElementById('quiz-screen');
const resultsScreen = document.getElementById('results-screen');
const finalScoreEl = document.getElementById('final-score');
const restartBtn = document.getElementById('restart-btn');
const TIME_PER_QUESTION = 15; // Seconds allowed per question
const state = {
currentIndex: 0, // Which question in questions[] is currently showing
score: 0, // Number answered correctly so far
answered: false, // True once the current question is locked in, blocks a second answer
timeLeft: TIME_PER_QUESTION,
timerId: null, // setInterval handle, so the countdown can be stopped between questions
};

Step 3: Render the Current Question

`renderQuestion` reads `questions[state.currentIndex]` and builds one button per option from scratch. It resets `answered` back to `false` and starts a fresh countdown, since both are per-question, not global.

function renderQuestion() {
const q = questions[state.currentIndex];
state.answered = false;
questionCountEl.textContent = 'Question ' + (state.currentIndex + 1) + ' of ' + questions.length;
questionTextEl.textContent = q.question;
optionsEl.innerHTML = ''; // Clear the previous question's option buttons
q.options.forEach(function (optionText, index) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'option-btn';
btn.textContent = optionText;
btn.dataset.index = index; // Lets the click handler know which option this button represents
optionsEl.appendChild(btn);
});
startTimer(); // Defined in Step 5 — begins this question's countdown
}

Step 4: Handle Answer Selection

Clicking an option (or the timer running out, in Step 5) calls `lockInAnswer`, which disables every option, reveals the correct one, marks a wrong pick if there was one, updates the score, and schedules the move to the next question.

optionsEl.addEventListener('click', function (event) {
const btn = event.target.closest('.option-btn');
if (!btn || state.answered) return; // Ignore clicks outside buttons, or a second click after answering
lockInAnswer(Number(btn.dataset.index));
});
function lockInAnswer(selectedIndex) {
state.answered = true;
clearInterval(state.timerId); // Stop the countdown the moment an answer is locked in
const q = questions[state.currentIndex];
const buttons = optionsEl.querySelectorAll('.option-btn');
buttons.forEach(function (btn, index) {
btn.disabled = true; // Prevent picking a second option after the first
if (index === q.correctIndex) {
btn.classList.add('correct'); // Always reveal the right answer, whether or not it was picked
} else if (index === selectedIndex) {
btn.classList.add('incorrect'); // Only mark the one wrong option that was actually chosen
}
});
if (selectedIndex === q.correctIndex) {
state.score++;
}
setTimeout(nextQuestion, 1000); // Brief pause so the highlight is visible before moving on
}

Step 5: Add a Per-Question Timer

`startTimer` resets the countdown and ticks it down once a second with `setInterval`. If it reaches zero before the user picks anything, it calls `lockInAnswer(-1)` — an index that can never match a real option, so a timeout always counts as a wrong answer without any special-case logic elsewhere.

function startTimer() {
state.timeLeft = TIME_PER_QUESTION;
timerEl.textContent = 'Time: ' + state.timeLeft + 's';
state.timerId = setInterval(function () {
state.timeLeft--;
timerEl.textContent = 'Time: ' + state.timeLeft + 's';
if (state.timeLeft <= 0) {
clearInterval(state.timerId);
lockInAnswer(-1); // -1 never matches a real option index, so a timeout always counts as wrong
}
}, 1000);
}
Countdown in the DOM

Click Run to see what this code prints.

Step 6: Advance to the Next Question

`nextQuestion` is the hinge between questions: if there is another question left in the array, it re-renders for the next index; otherwise the quiz is over and it shows the results screen instead.

function nextQuestion() {
state.currentIndex++;
if (state.currentIndex < questions.length) {
renderQuestion();
} else {
showResults(); // Defined in Step 7
}
}

Step 7: Show the Results Summary

Finishing the quiz means hiding `#quiz-screen` and revealing `#results-screen` with the final score filled in. The restart button resets every field on `state` back to its starting values and renders the first question again, so the same page can be replayed without a reload.

function showResults() {
quizScreen.classList.add('hidden');
resultsScreen.classList.remove('hidden');
finalScoreEl.textContent = 'You scored ' + state.score + ' out of ' + questions.length + '.';
}
restartBtn.addEventListener('click', function () {
state.currentIndex = 0;
state.score = 0;
resultsScreen.classList.add('hidden');
quizScreen.classList.remove('hidden');
renderQuestion();
});
renderQuestion(); // Kick off the quiz with the first question as soon as the script runs

Complete Code

Here is the complete project — the HTML shell, the CSS, and the fully assembled JavaScript in the order you would put them in real files (`index.html`, `styles.css`, `script.js`).

<div class="quiz-app">
<div id="quiz-screen">
<div class="quiz-header">
<span id="question-count"></span>
<span id="timer"></span>
</div>
<h2 id="question-text"></h2>
<div id="options"></div>
</div>
<div id="results-screen" class="hidden">
<h2>Quiz Complete!</h2>
<p id="final-score"></p>
<button id="restart-btn">Try Again</button>
</div>
</div>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.quiz-app {
width: 100%;
max-width: 460px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.quiz-header {
display: flex;
justify-content: space-between;
color: #9aa4b2;
font-size: 0.9rem;
margin-bottom: 12px;
}
#question-text {
margin-bottom: 16px;
font-size: 1.2rem;
}
#options {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-btn {
padding: 12px 14px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
text-align: left;
cursor: pointer;
font-size: 1rem;
}
.option-btn:disabled {
cursor: default;
}
.option-btn.correct {
border-color: #3ecf8e;
background: rgba(62, 207, 142, 0.15);
}
.option-btn.incorrect {
border-color: #ff6b6b;
background: rgba(255, 107, 107, 0.15);
}
#results-screen {
text-align: center;
}
#results-screen h2 {
margin-bottom: 12px;
}
#final-score {
color: #9aa4b2;
margin-bottom: 20px;
}
#restart-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
.hidden {
display: none;
}
const questions = [
{
question: 'Which keyword declares a variable that can be reassigned?',
options: ['const', 'let', 'final', 'static'],
correctIndex: 1,
},
{
question: 'What does DOM stand for?',
options: ['Data Object Model', 'Document Object Model', 'Display Output Method', 'Direct Object Mapping'],
correctIndex: 1,
},
{
question: 'Which array method adds an item to the end?',
options: ['array.push()', 'array.pop()', 'array.shift()', 'array.slice()'],
correctIndex: 0,
},
{
question: 'Which operator checks both value and type?',
options: ['==', '=', '===', '!='],
correctIndex: 2,
},
{
question: 'Which event fires when a form is submitted?',
options: ['click', 'submit', 'change', 'load'],
correctIndex: 1,
},
];
const questionCountEl = document.getElementById('question-count');
const timerEl = document.getElementById('timer');
const questionTextEl = document.getElementById('question-text');
const optionsEl = document.getElementById('options');
const quizScreen = document.getElementById('quiz-screen');
const resultsScreen = document.getElementById('results-screen');
const finalScoreEl = document.getElementById('final-score');
const restartBtn = document.getElementById('restart-btn');
const TIME_PER_QUESTION = 15;
const state = {
currentIndex: 0,
score: 0,
answered: false,
timeLeft: TIME_PER_QUESTION,
timerId: null,
};
function renderQuestion() {
const q = questions[state.currentIndex];
state.answered = false;
questionCountEl.textContent = 'Question ' + (state.currentIndex + 1) + ' of ' + questions.length;
questionTextEl.textContent = q.question;
optionsEl.innerHTML = '';
q.options.forEach(function (optionText, index) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'option-btn';
btn.textContent = optionText;
btn.dataset.index = index;
optionsEl.appendChild(btn);
});
startTimer();
}
optionsEl.addEventListener('click', function (event) {
const btn = event.target.closest('.option-btn');
if (!btn || state.answered) return;
lockInAnswer(Number(btn.dataset.index));
});
function lockInAnswer(selectedIndex) {
state.answered = true;
clearInterval(state.timerId);
const q = questions[state.currentIndex];
const buttons = optionsEl.querySelectorAll('.option-btn');
buttons.forEach(function (btn, index) {
btn.disabled = true;
if (index === q.correctIndex) {
btn.classList.add('correct');
} else if (index === selectedIndex) {
btn.classList.add('incorrect');
}
});
if (selectedIndex === q.correctIndex) {
state.score++;
}
setTimeout(nextQuestion, 1000);
}
function startTimer() {
state.timeLeft = TIME_PER_QUESTION;
timerEl.textContent = 'Time: ' + state.timeLeft + 's';
state.timerId = setInterval(function () {
state.timeLeft--;
timerEl.textContent = 'Time: ' + state.timeLeft + 's';
if (state.timeLeft <= 0) {
clearInterval(state.timerId);
lockInAnswer(-1);
}
}, 1000);
}
function nextQuestion() {
state.currentIndex++;
if (state.currentIndex < questions.length) {
renderQuestion();
} else {
showResults();
}
}
function showResults() {
quizScreen.classList.add('hidden');
resultsScreen.classList.remove('hidden');
finalScoreEl.textContent = 'You scored ' + state.score + ' out of ' + questions.length + '.';
}
restartBtn.addEventListener('click', function () {
state.currentIndex = 0;
state.score = 0;
resultsScreen.classList.add('hidden');
quizScreen.classList.remove('hidden');
renderQuestion();
});
renderQuestion();
Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Shuffle both the question order and each question's options on every restart using a Fisher-Yates shuffle.
  • Add a visual progress bar that fills up as `state.currentIndex` advances through `questions`.
  • Save the best score ever achieved to `localStorage` and display it as a "High Score" on the results screen.
  • Group questions into categories and let the player pick one before the quiz starts.
  • Award bonus points based on how much time was left when the correct answer was picked.

Summary

You built a timed, scored multiple-choice quiz driven entirely by an array of question objects, with a countdown timer and a results summary layered on top of the same event-handling patterns from the rest of this course. Modeling content as data — rather than hard-coding it into HTML — is the technique that let five questions, or five hundred, run through exactly the same rendering code.