Day 28: Mini Project: Quiz App with Timer
✅ Today you will learn
- Model questions as data and shuffle them fairly
- Drive three screens from one state object
- A countdown timer that pauses when the tab is hidden
- Keyboard-friendly, accessible answering with instant feedback
- Results, review and a saved best score
🏢 In the real world
Coaching institutes, schools and HR teams all need online quizzes – it is one of the most requested small apps for freelancers, and a favourite machine-coding interview task (“build a quiz with a timer in 90 minutes”). This project combines arrays, objects, DOM rendering, events, timers, storage and accessibility into one polished app.
Play the finished quiz
Step 1: plan screens and state
| State field | Holds | Changed by |
|---|---|---|
| questions | This round’s 10 questions with shuffled options | startQuiz() |
| index | Current question number | nextQuestion() |
| score | Correct answers | answer() |
| answers | History for the review list | answer() |
| timeLeft / timerId | Countdown and interval id | startTimer() |
| locked | Prevents answering twice | answer() / renderQuestion() |
Step 2: questions as data
// Question bank – edit freely. answer = index of the correct option (0-based).
const QUESTIONS = [
{ q: 'Which keyword declares a variable that cannot be re-assigned?', options: ['var', 'let', 'const', 'static'], answer: 2, why: 'const prevents re-assignment (objects it points to can still change).' },
{ q: "What does '5' + 3 return?", options: ['8', "'53'", 'NaN', 'Error'], answer: 1, why: '+ with a string joins text.' },
{ q: 'Which method creates a NEW array with transformed items?', options: ['forEach', 'map', 'push', 'splice'], answer: 1, why: 'map returns a new array of the same length.' },
// … 12 more
];A teacher can add questions without touching quiz.js. Later the same array could come from an API with fetch (Day 20) or a Google Sheet – the app code stays the same.
Step 3: helpers – fair shuffle and safe storage
// ---------- 1. SETTINGS & HELPERS ----------
const TOTAL = 10; // questions per round
const SECONDS = 20; // time per question
const BEST_KEY = 'ce-quiz-best';
const $ = (sel) => document.querySelector(sel);
function shuffle(list) { // Fisher–Yates: fair random order
const copy = [...list];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
const storage = {
get(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch (e) { return fallback; } },
set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* blocked – ignore */ } },
};
// ---------- 2. STATE ----------
const state = {
player: '',
questions: [], // this round's questions (with shuffled options)
index: 0,
score: 0,
answers: [], // { question, chosen, correct }
timeLeft: SECONDS,
timerId: null,
locked: false, // true after answering, until Next
};That popular one-liner is biased – some orders appear far more often. Fisher–Yates swaps each item with a random earlier position and gives every order an equal chance.
Step 4: rendering a question and the timer
// ---------- 3. SCREENS ----------
function show(screenId) {
['start-screen', 'question-screen', 'result-screen'].forEach(id => { $('#' + id).hidden = id !== screenId; });
}
function renderBest() {
const best = storage.get(BEST_KEY, null);
$('#best').textContent = best ? `🏆 Best score: ${best.score}/${TOTAL} by ${best.name}` : 'No best score yet – be the first!';
}
// ---------- 4. QUESTION + TIMER ----------
function startQuiz() {
state.player = $('#player').value.trim() || 'Guest';
state.questions = shuffle(QUESTIONS).slice(0, TOTAL).map(q => {
// shuffle options but remember which one is correct
const order = shuffle(q.options.map((text, i) => ({ text, isCorrect: i === q.answer })));
return { q: q.q, why: q.why, options: order };
});
state.index = 0;
state.score = 0;
state.answers = [];
show('question-screen');
renderQuestion();
}
function renderQuestion() {
const current = state.questions[state.index];
state.locked = false;
$('#progress-text').textContent = `Question ${state.index + 1} of ${TOTAL}`;
$('#progress').value = state.index;
$('#question').textContent = current.q;
$('#feedback').textContent = '';
$('#feedback').className = 'feedback';
$('#next-btn').hidden = true;
const box = $('#options');
box.replaceChildren(...current.options.map((opt, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'option';
btn.dataset.index = i;
const key = document.createElement('span');
key.className = 'key';
key.textContent = i + 1;
const text = document.createElement('span');
text.textContent = opt.text; // safe text
btn.append(key, text);
return btn;
}));
$('#question').focus();
startTimer();
}
function startTimer() {
clearInterval(state.timerId);
state.timeLeft = SECONDS;
updateTimer();
state.timerId = setInterval(() => {
state.timeLeft--;
updateTimer();
if (state.timeLeft <= 0) answer(null); // time is up = wrong answer
}, 1000);
}
function updateTimer() {
const t = $('#timer');
t.textContent = state.timeLeft;
t.classList.toggle('low', state.timeLeft <= 5);
}- Options are shuffled per round but each keeps an isCorrect flag – no need to track indexes by hand.
- Option text is set with textContent, so questions containing <tags> display safely.
- setInterval ticks every second; clearInterval runs before every new question so timers never stack up.
- The heading gets focus on every new question so screen-reader users hear it immediately.
Step 5: answering and feedback
// ---------- 5. ANSWERING ----------
function answer(chosenIndex) {
if (state.locked) return;
state.locked = true;
clearInterval(state.timerId);
const current = state.questions[state.index];
const buttons = [...$('#options').children];
const correctIndex = current.options.findIndex(o => o.isCorrect);
const isRight = chosenIndex === correctIndex;
if (isRight) state.score++;
buttons.forEach((btn, i) => {
btn.disabled = true;
if (i === correctIndex) btn.classList.add('correct');
if (i === chosenIndex && !isRight) btn.classList.add('wrong');
});
const fb = $('#feedback');
fb.className = 'feedback ' + (isRight ? 'ok' : 'bad');
fb.textContent = chosenIndex === null ? `⏰ Time up! ${current.why}`
: isRight ? `✅ Correct! ${current.why}`
: `❌ Not quite. ${current.why}`;
state.answers.push({
question: current.q,
chosen: chosenIndex === null ? '(no answer)' : current.options[chosenIndex].text,
correct: current.options[correctIndex].text,
isRight,
});
const next = $('#next-btn');
next.textContent = state.index === TOTAL - 1 ? 'See result →' : 'Next →';
next.hidden = false;
next.focus();
}
function nextQuestion() {
if (!state.locked) return;
state.index++;
if (state.index < TOTAL) renderQuestion();
else showResult();
}Step 6: results, review and best score
// ---------- 6. RESULTS & REVIEW ----------
function showResult() {
$('#progress').value = TOTAL;
show('result-screen');
const { score } = state;
const percent = Math.round(score / TOTAL * 100);
$('#result-title').textContent = percent >= 80 ? `Excellent, ${state.player}! 🎉` : percent >= 50 ? `Good job, ${state.player}! 👍` : `Keep practising, ${state.player} 💪`;
$('#score').textContent = `${score}/${TOTAL}`;
const best = storage.get(BEST_KEY, null);
if (!best || score > best.score) {
storage.set(BEST_KEY, { score, name: state.player, date: new Date().toISOString().slice(0, 10) });
$('#result-message').textContent = `${percent}% – new best score! 🏆`;
} else {
$('#result-message').textContent = `${percent}% – best is ${best.score}/${TOTAL} by ${best.name}.`;
}
const review = $('#review');
review.hidden = true;
$('#review-btn').setAttribute('aria-expanded', 'false');
review.replaceChildren(...state.answers.map(a => {
const li = document.createElement('li');
const q = document.createElement('b');
q.textContent = a.question;
const you = document.createElement('span');
you.className = 'you ' + (a.isRight ? 'ok' : 'bad');
you.textContent = a.isRight ? `✔ ${a.correct}` : `✖ You: ${a.chosen} · Correct: ${a.correct}`;
li.append(q, you);
return li;
}));
}Step 7: events – mouse, keyboard and tab visibility
// ---------- 7. EVENTS ----------
$('#start-btn').addEventListener('click', startQuiz);
$('#player').addEventListener('keydown', e => { if (e.key === 'Enter') startQuiz(); });
$('#options').addEventListener('click', e => {
const btn = e.target.closest('.option');
if (btn) answer(Number(btn.dataset.index));
});
$('#next-btn').addEventListener('click', nextQuestion);
$('#restart-btn').addEventListener('click', () => { show('start-screen'); renderBest(); $('#player').focus(); });
$('#review-btn').addEventListener('click', e => {
const review = $('#review');
review.hidden = !review.hidden;
e.currentTarget.setAttribute('aria-expanded', String(!review.hidden));
});
document.addEventListener('keydown', e => {
if ($('#question-screen').hidden) return;
if (['1', '2', '3', '4'].includes(e.key) && !state.locked) answer(Number(e.key) - 1);
if (e.key === 'Enter' && state.locked && document.activeElement !== $('#next-btn')) nextQuestion();
});
document.addEventListener('visibilitychange', () => { // pause the timer when the tab is hidden
if ($('#question-screen').hidden || state.locked) return;
if (document.hidden) clearInterval(state.timerId);
else { const left = state.timeLeft; startTimer(); state.timeLeft = left; updateTimer(); }
});
// ---------- 8. START ----------
renderBest();
show('start-screen');| Feature | Day learnt |
|---|---|
| Arrays, shuffle, map, findIndex | 6, 8 |
| Objects as data, destructuring | 7 |
| Rendering with createElement + replaceChildren | 12 |
| Event delegation for options, keyboard shortcuts | 11 |
| setInterval / clearInterval | 19 |
| localStorage with try/catch | 15 |
| Page Visibility API | 22 |
| Accessible focus, aria-expanded, role="status" | 10, HTML course |
Step 8: our test run
| Test | Result |
|---|---|
| Play a full round with keys 1–4 and Enter | ✅ 10 questions, result screen shown |
| Score saved as best score and shown on the start screen | ✅ “Best score: 3/10 by Riya” |
| Review list | ✅ 10 items with your answer and the correct one |
| Let the timer run out | ✅ “Time up!” and the correct option highlighted |
| Switch tabs during a question | ✅ Timer pauses and resumes |
| Console errors | ✅ none |
Complete HTML and CSS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JavaScript Quiz – Timed Quiz App (Mini Project)</title>
<meta name="description" content="A timed JavaScript quiz built with plain JavaScript: random questions, 20-second timer, instant feedback, score, answer review and best score.">
<link rel="stylesheet" href="style.css">
<script src="questions.js" defer></script>
<script src="quiz.js" defer></script>
</head>
<body>
<main class="quiz" id="app">
<!-- START SCREEN -->
<section class="screen" id="start-screen">
<p class="eyebrow">Mini project · Day 28</p>
<h1>JavaScript Quiz ⚡</h1>
<p>10 random questions · 20 seconds each · instant feedback</p>
<p class="best" id="best"></p>
<label class="field">Your name
<input id="player" maxlength="30" placeholder="e.g. Riya" autocomplete="given-name">
</label>
<button class="btn" id="start-btn">Start quiz</button>
<p class="hint">Tip: press keys 1–4 to answer, Enter for next.</p>
</section>
<!-- QUESTION SCREEN -->
<section class="screen" id="question-screen" hidden>
<div class="top">
<span id="progress-text">Question 1 of 10</span>
<span class="timer" id="timer" aria-live="off">20</span>
</div>
<progress id="progress" max="10" value="0"></progress>
<h2 id="question" tabindex="-1"></h2>
<div class="options" id="options" role="group" aria-labelledby="question"></div>
<p class="feedback" id="feedback" role="status"></p>
<button class="btn" id="next-btn" hidden>Next →</button>
</section>
<!-- RESULT SCREEN -->
<section class="screen" id="result-screen" hidden>
<h2 id="result-title"></h2>
<p class="score" id="score"></p>
<p id="result-message"></p>
<div class="actions">
<button class="btn" id="restart-btn">Play again</button>
<button class="btn btn--ghost" id="review-btn" aria-expanded="false" aria-controls="review">Review answers</button>
</div>
<ol class="review" id="review" hidden></ol>
</section>
</main>
</body>
</html>:root { --brand: #ca8a04; --ok: #15803d; --bad: #b91c1c; --ink: #1f2937; --muted: #6b7280; --line: #e5e7eb; --bg: #fefce8; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, "Segoe UI", Roboto, sans-serif; color: var(--ink);
background: radial-gradient(circle at 20% 10%, #fef08a, transparent 40%), var(--bg); }
button, input { font: inherit; }
:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
.quiz { width: min(620px, 100% - 24px); background: #fff; border-radius: 20px; padding: 24px; box-shadow: 0 20px 50px rgb(0 0 0 / 10%); margin: 24px 0; }
.screen h1 { margin: 4px 0 8px; font-size: clamp(1.6rem, 5vw, 2.2rem); }
.eyebrow { margin: 0; color: var(--brand); font-weight: 700; text-transform: uppercase; letter-spacing: .1em; font-size: .75rem; }
.best { font-weight: 600; }
.field { display: grid; gap: 6px; font-weight: 600; margin: 16px 0; }
.field input { padding: 10px 12px; border: 1.5px solid var(--line); border-radius: 10px; }
.hint { color: var(--muted); font-size: .85rem; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 12px; padding: 12px 22px; font-weight: 700; cursor: pointer; }
.btn:hover { filter: brightness(.95); }
.btn--ghost { background: #fff; color: var(--ink); border: 1.5px solid var(--line); }
.top { display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-weight: 600; }
.timer { min-width: 48px; text-align: center; padding: 4px 10px; border-radius: 99px; background: #fef3c7; color: #92400e; font-variant-numeric: tabular-nums; }
.timer.low { background: #fee2e2; color: var(--bad); animation: pulse 1s infinite; }
@keyframes pulse { 50% { transform: scale(1.08); } }
progress { width: 100%; height: 8px; accent-color: var(--brand); margin: 10px 0 4px; }
#question { font-size: 1.25rem; line-height: 1.4; outline: none; }
.options { display: grid; gap: 10px; }
.option { text-align: left; padding: 12px 14px; border: 1.5px solid var(--line); border-radius: 12px; background: #fff; cursor: pointer; display: flex; gap: 10px; align-items: center; }
.option:hover:not(:disabled) { border-color: var(--brand); background: #fffbeb; }
.option .key { flex: 0 0 26px; height: 26px; border-radius: 8px; background: #f3f4f6; display: grid; place-items: center; font-size: .8rem; font-weight: 700; }
.option.correct { border-color: var(--ok); background: #dcfce7; }
.option.wrong { border-color: var(--bad); background: #fee2e2; }
.option:disabled { cursor: default; color: inherit; opacity: 1; }
.feedback { min-height: 1.4em; font-weight: 600; }
.feedback.ok { color: var(--ok); }
.feedback.bad { color: var(--bad); }
.score { font-size: 3rem; font-weight: 800; margin: 0; color: var(--brand); }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.review { margin-top: 16px; padding-left: 20px; }
.review li { margin-bottom: 10px; }
.review .you { display: block; font-size: .9rem; }
.review .you.bad { color: var(--bad); }
.review .you.ok { color: var(--ok); }
@media (prefers-reduced-motion: reduce) { .timer.low { animation: none; } }🏅 Job skills unlocked today
Practice tasks
🏋️ Practice – do it yourself
Quick quiz
🧠 Quick Quiz – test yourself
Interview corner
💼 Interview Corner
How would you design a quiz app in a machine-coding round?
Start with the data shape and state (questions, index, score, answers, timer), write render functions per screen, use delegation for options, a single interval cleared on every transition, store results in localStorage, then add accessibility (focus, keys) and polish if time allows. Keep it working at every step.
How do you avoid timer bugs?
Keep one interval id in state, clear it before starting another and when leaving the screen, compute remaining time from timestamps for accuracy, and pause on visibilitychange.
Summary
- Data (questions) + state + render per screen = clean multi-screen app.
- Fisher–Yates shuffle; options keep an isCorrect flag.
- One timer, always cleared; paused when the tab is hidden.
- Keyboard, focus and feedback make it accessible; best score in localStorage.
Day 29 – Major project part 1: the Sharma Sweets online shop catalogue – live API, search, filters, URL state and product details.
