Quiz App
Multiple-choice quiz with score tracking, answer feedback, progress indicator, and results screen.
Project Overview
Build a fully interactive quiz app driven by a data array. Each question is rendered from the array; the DOM is rebuilt for each question. This practises: array traversal, state management across screens, dynamic DOM building, and conditional rendering.
What you will build
- Question data array (easily swap in your own questions)
- Progress bar and question counter (e.g. "3 / 10")
- Four answer buttons rendered dynamically per question
- Instant feedback: correct answers turn green, wrong turns red + correct revealed
- Score tracking throughout
- Results screen with score, percentage, and grade
- Restart button to play again
Concepts used
- Array of objects as a data source
- Index-based state to track current question
- Dynamic DOM creation (
createElement,textContent) - CSS class toggling for correct/wrong feedback
- Conditional rendering (quiz screen vs results screen)
Key Logic
Question data structure
const questions = [
{
question: 'Which method adds an element to the END of an array?',
answers: ['unshift', 'push', 'splice', 'concat'],
correct: 1 // index of the correct answer
},
{
question: 'What does typeof null return in JavaScript?',
answers: ['null', 'undefined', 'object', 'boolean'],
correct: 2
},
// ... more questions
];
Rendering a question
function showQuestion() {
const q = questions[currentIndex];
questionEl.textContent = q.question;
answersEl.innerHTML = ''; // clear previous buttons
q.answers.forEach((answer, i) => {
const btn = document.createElement('button');
btn.className = 'answer-btn';
btn.textContent = answer;
btn.dataset.index = i;
answersEl.appendChild(btn);
});
// Update progress
counterEl.textContent = `${currentIndex + 1} / ${questions.length}`;
progressFill.style.width = `${((currentIndex) / questions.length) * 100}%`;
}
Checking an answer
function handleAnswer(btn, selectedIndex) {
const q = questions[currentIndex];
// Disable all buttons immediately
document.querySelectorAll('.answer-btn').forEach(b => b.disabled = true);
if (selectedIndex === q.correct) {
btn.classList.add('correct');
score++;
} else {
btn.classList.add('wrong');
// Reveal the correct answer
document.querySelectorAll('.answer-btn')[q.correct].classList.add('correct');
}
// Auto-advance after 1.2 seconds
setTimeout(nextQuestion, 1200);
}
Results screen
function showResults() {
const pct = Math.round((score / questions.length) * 100);
const grade =
pct >= 90 ? 'Excellent!' :
pct >= 70 ? 'Good job!' :
pct >= 50 ? 'Keep practising' : 'Study more!';
quizScreen.hidden = true;
resultScreen.hidden = false;
scoreEl.textContent = `${score} / ${questions.length}`;
pctEl.textContent = `${pct}%`;
gradeEl.textContent = grade;
resultFill.style.width = `${pct}%`;
}
Complete Working Code
<!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>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #1e1b4b, #312e81);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.quiz-card {
background: #fff;
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 560px;
box-shadow: 0 20px 60px #0005;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: .85rem;
color: #64748b;
}
.progress-bar {
height: 6px;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.progress-fill {
height: 100%;
background: #6366f1;
border-radius: 999px;
transition: width .4s ease;
width: 0%;
}
.question {
font-size: 1.15rem;
font-weight: 600;
color: #1e293b;
margin-bottom: 1.5rem;
line-height: 1.5;
min-height: 60px;
}
.answers {
display: flex;
flex-direction: column;
gap: .75rem;
margin-bottom: 1.5rem;
}
.answer-btn {
padding: .85rem 1.25rem;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: .6rem;
font-size: .95rem;
color: #1e293b;
cursor: pointer;
text-align: left;
transition: background .15s, border-color .15s;
}
.answer-btn:hover:not(:disabled) {
background: #eff6ff;
border-color: #6366f1;
}
.answer-btn.correct {
background: #dcfce7;
border-color: #22c55e;
color: #166534;
}
.answer-btn.wrong {
background: #fee2e2;
border-color: #ef4444;
color: #991b1b;
}
.answer-btn:disabled { cursor: default; }
.score-display {
text-align: right;
font-size: .85rem;
color: #64748b;
}
/* Results screen */
.result-screen { text-align: center; }
.result-screen h2 { font-size: 1.6rem; color: #1e293b; margin-bottom: .5rem; }
.result-score {
font-size: 3.5rem;
font-weight: 800;
color: #6366f1;
margin: 1rem 0 .25rem;
}
.result-pct { font-size: 1.2rem; color: #64748b; margin-bottom: .5rem; }
.result-grade { font-size: 1.1rem; font-weight: 600; color: #1e293b; margin-bottom: 1.5rem; }
.result-bar { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; margin-bottom: 1.5rem; }
.result-bar-fill { height: 100%; background: #6366f1; border-radius: 999px; transition: width .8s ease; width: 0; }
.restart-btn {
padding: .9rem 2.5rem;
background: #6366f1;
color: #fff;
border: none;
border-radius: .6rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.restart-btn:hover { background: #4f46e5; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div class="quiz-card">
<!-- Quiz screen -->
<div id="quiz-screen">
<div class="header">
<span id="counter">1 / 10</span>
<span class="score-display">Score: <span id="score-live">0</span></span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="question" id="question"></div>
<div class="answers" id="answers"></div>
</div>
<!-- Results screen -->
<div id="result-screen" class="result-screen" hidden>
<h2>Quiz Complete!</h2>
<div class="result-score" id="result-score"></div>
<div class="result-pct" id="result-pct"></div>
<div class="result-grade" id="result-grade"></div>
<div class="result-bar">
<div class="result-bar-fill" id="result-bar-fill"></div>
</div>
<button class="restart-btn" id="restart-btn">Play Again</button>
</div>
</div>
<script>
const questions = [
{
question: 'Which array method creates a NEW array by transforming every element?',
answers: ['forEach', 'filter', 'map', 'reduce'],
correct: 2
},
{
question: 'What does typeof null return?',
answers: ['null', 'undefined', 'object', 'boolean'],
correct: 2
},
{
question: 'Which keyword declares a block-scoped variable that CANNOT be reassigned?',
answers: ['var', 'let', 'const', 'static'],
correct: 2
},
{
question: 'What will console.log(0.1 + 0.2 === 0.3) print?',
answers: ['true', 'false', 'NaN', 'undefined'],
correct: 1
},
{
question: 'Which method removes and returns the LAST element of an array?',
answers: ['shift', 'unshift', 'pop', 'splice'],
correct: 2
},
{
question: 'What does the spread operator (...) do when used with an array?',
answers: [
'Joins the array into a string',
'Expands the array into individual elements',
'Sorts the array',
'Reverses the array'
],
correct: 1
},
{
question: 'Which Promise method waits for ALL promises and rejects if ANY reject?',
answers: ['Promise.any', 'Promise.race', 'Promise.allSettled', 'Promise.all'],
correct: 3
},
{
question: 'What does fetch() return?',
answers: ['The response body as text', 'A Promise', 'An XMLHttpRequest', 'A callback'],
correct: 1
},
{
question: 'Which CSS property makes a flex container wrap its children onto new lines?',
answers: ['flex-flow', 'flex-wrap: wrap', 'flex-direction: column', 'align-items: wrap'],
correct: 1
},
{
question: 'What is event delegation?',
answers: [
'Removing event listeners after use',
'Adding the same listener to every child element',
'Listening on a parent and checking event.target for the child',
'Preventing events from bubbling'
],
correct: 2
}
];
let currentIndex = 0;
let score = 0;
const quizScreen = document.querySelector('#quiz-screen');
const resultScreen = document.querySelector('#result-screen');
const questionEl = document.querySelector('#question');
const answersEl = document.querySelector('#answers');
const counterEl = document.querySelector('#counter');
const scoreLiveEl = document.querySelector('#score-live');
const progressFill = document.querySelector('#progress-fill');
const resultScore = document.querySelector('#result-score');
const resultPct = document.querySelector('#result-pct');
const resultGrade = document.querySelector('#result-grade');
const resultFill = document.querySelector('#result-bar-fill');
const restartBtn = document.querySelector('#restart-btn');
function showQuestion() {
const q = questions[currentIndex];
questionEl.textContent = q.question;
answersEl.innerHTML = '';
q.answers.forEach((answer, i) => {
const btn = document.createElement('button');
btn.className = 'answer-btn';
btn.textContent = answer;
btn.dataset.index = i;
answersEl.appendChild(btn);
});
counterEl.textContent = `${currentIndex + 1} / ${questions.length}`;
progressFill.style.width = `${(currentIndex / questions.length) * 100}%`;
scoreLiveEl.textContent = score;
}
function handleAnswer(btn, selectedIndex) {
const q = questions[currentIndex];
// Disable all buttons immediately to prevent double-clicks
document.querySelectorAll('.answer-btn').forEach(b => {
b.disabled = true;
});
if (selectedIndex === q.correct) {
btn.classList.add('correct');
score++;
scoreLiveEl.textContent = score;
} else {
btn.classList.add('wrong');
// Reveal correct answer
document.querySelectorAll('.answer-btn')[q.correct].classList.add('correct');
}
setTimeout(nextQuestion, 1200);
}
function nextQuestion() {
currentIndex++;
if (currentIndex < questions.length) {
showQuestion();
} else {
showResults();
}
}
function showResults() {
const pct = Math.round((score / questions.length) * 100);
const grade =
pct >= 90 ? 'Excellent! You really know your stuff.' :
pct >= 70 ? 'Good job! Keep it up.' :
pct >= 50 ? 'Not bad — keep practising!' :
'Time to hit the books!';
quizScreen.hidden = true;
resultScreen.hidden = false;
resultScore.textContent = `${score} / ${questions.length}`;
resultPct.textContent = `${pct}%`;
resultGrade.textContent = grade;
// Trigger CSS transition after a tiny delay
requestAnimationFrame(() => {
resultFill.style.width = `${pct}%`;
});
}
function restart() {
currentIndex = 0;
score = 0;
resultFill.style.width = '0%';
resultScreen.hidden = true;
quizScreen.hidden = false;
showQuestion();
}
// Event delegation — one listener for all answer buttons
answersEl.addEventListener('click', e => {
const btn = e.target.closest('.answer-btn');
if (!btn || btn.disabled) return;
handleAnswer(btn, Number(btn.dataset.index));
});
restartBtn.addEventListener('click', restart);
// Start
showQuestion();
</script>
</body>
</html>
Code Explained
Data-driven rendering
The questions array is the single source of truth. showQuestion() reads from questions[currentIndex] and rebuilds the DOM entirely — the same pattern used in React and Vue. To change the quiz content, you only edit the data array.
Event delegation on the answers container
Instead of adding a click listener to each button, one listener is placed on the #answers container. e.target.closest('.answer-btn') finds the clicked button even if the click lands on a text node inside it. Checking btn.disabled guards against double-clicks during the feedback delay.
Disabling buttons before feedback
After any click, all answer buttons are immediately set to disabled = true. This prevents the user from clicking again while the 1.2-second feedback animation plays. The correct answer always gets the green class — either because it was chosen, or to reveal it when the wrong answer was chosen.
requestAnimationFrame for CSS transitions
When switching to the results screen, resultFill.style.width is set inside requestAnimationFrame. This gives the browser one frame to paint the element at width: 0 first, so the CSS transition from 0% to the score percentage is visible rather than skipped.
Challenges
- Shuffle the questions array on each restart using the Fisher-Yates algorithm so players get a different order every time.
- Shuffle the answer options too — so the correct answer isn't always in the same position.
- Add a countdown timer (e.g. 15 seconds per question) — auto-advance and count as wrong if time runs out.
- Add a "Review Answers" screen after results that shows each question, the user's choice, and the correct answer.
- Load questions from a JSON file using
fetch()so the data is separate from the UI code.