Free tutorials & notes in Hindi & English · Clean code examples 🧰 Free PDF & Image Tools →
🚀 Days 28–30 – Projects  ·  Day 28

Day 28: Mini Project: Quiz App with Timer

⏱ 3 hours📶 Project🎯 100% self-study
Day 28 / 30

✅ 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

▶  Enter a name, start, answer with clicks or keys 1–4, then review your answers

Step 1: plan screens and state

Start screen – name + best scoreQuestion screen – progress, timer, 4 options, feedback, NextResult screen – score, message, review listPlay again → start screen
Three screens, one state object
State fieldHoldsChanged by
questionsThis round’s 10 questions with shuffled optionsstartQuiz()
indexCurrent question numbernextQuestion()
scoreCorrect answersanswer()
answersHistory for the review listanswer()
timeLeft / timerIdCountdown and interval idstartTimer()
lockedPrevents answering twiceanswer() / renderQuestion()

Step 2: questions as data

JavaScript – questions.js (first 3 of 15)
// 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
];
💡 Data and code are separate

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

JavaScript – 1. settings & helpers + 2. state
// ---------- 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
};
⚠️ Do not shuffle with sort(() => Math.random() - 0.5)

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

JavaScript – 3. screens + 4. question & 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

JavaScript – 5. answering
// ---------- 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

JavaScript – 6. results & review
// ---------- 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

JavaScript – 7. events + 8. start
// ---------- 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');
FeatureDay learnt
Arrays, shuffle, map, findIndex6, 8
Objects as data, destructuring7
Rendering with createElement + replaceChildren12
Event delegation for options, keyboard shortcuts11
setInterval / clearInterval19
localStorage with try/catch15
Page Visibility API22
Accessible focus, aria-expanded, role="status"10, HTML course

Step 8: our test run

TestResult
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

HTML – index.html
<!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>
CSS – style.css
: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

✔ Build a multi-screen app from one state object✔ Shuffle fairly and keep data separate from code✔ Manage timers safely and pause them with the Visibility API✔ Add keyboard support, feedback and saved results

Practice tasks

🏋️ Practice – do it yourself

Task 1: Add categories (JS, HTML, CSS) to questions and a category picker on the start screen.
Task 2: Load questions from a JSON file with fetch, showing a loading message and an error message with a retry button.
Task 3: Keep a top-5 leaderboard (name, score, date) in localStorage and show it on the result screen.

Quick quiz

🧠 Quick Quiz – test yourself

1. Why clearInterval before starting a new timer?
One active interval.
2. Fair shuffle algorithm:
Equal probability.
3. The locked flag prevents…
Also blocks keys.
4. Which API pauses the timer when switching tabs?
document.hidden.
5. Why focus the question heading?
Accessibility.

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.
➡️ Next

Day 29 – Major project part 1: the Sharma Sweets online shop catalogue – live API, search, filters, URL state and product details.

Frequently Asked Questions

Can students cheat by reading the source?
Yes – answers are in the JavaScript. For real exams, keep questions and checking on the server and send only the current question.
Can I use this for my coaching institute?
Yes – replace questions.js with your own questions, change the colours in style.css and host it free.
← JavaScript (30 Days)
🔗

Share this topic with a friend

यह topic किसी दोस्त को भेजें

Found it useful? Send it to a classmate learning the same thing.

अच्छा लगा? जो दोस्त यही सीख रहा है, उसे भेज दीजिए।

Next topicDay 29: Major Project 1: Online Shop Catalogue Your progress: 28 / 30 (93%) · Lesson 29 / 30 Continue →

How was this lesson?

Your feedback helps us make the content better.

Write a review or suggestion (optional)

After approval, your name and city may appear publicly with your review. Please do not write your email or phone number.

▶ CodingEasily on YouTube

Learn with video lessons in easy Hindi

Featured video: RDBMS Part 3 – Class XI Commerce IT

▶ Subscribe – it’s free