Building a Quiz App in JavaScript

Subscribe to my newsletter and never miss my upcoming articles

My goal is to take you on a trip from planning to building a quiz application. For that, we will use vanilla JavaScript, CSS, and HTML. No additional libraries or packages. Let’s get started by defining what our quiz app can do.

Defining the specification

The quiz will be split into two main classes. The first one will be a settings area in which the player can choose the difficulty, the category, and the number of questions he wants to answer. For that, we will create a settings-class to track all of this information. After doing that he can start the quiz.

The second area will be a quiz. The quiz-class tracks the progress of the player and decides whether or not to display the next question of the final screen.

3h5vrl9bmr7eq48rquit.png

Furthermore, the quiz-class has two other components, first of an array of question-classes that hold the data of a question, display it, and checks if the answer was right or not. The other one is the final-class that displays the last page with the player’s score.

We will be using the Open Trivia DB API for the questions so that we don’t have to come up with our own questions.

As you might already guess, because I am talking a lot about classes, we will use Object-Oriented-Programming to implement this quiz app and not Functional-Programming. If you are interested in the difference between these two paradigms go an check out my article “Functional Programming vs OOP in JavaScript”.

Prerequisites

Before we can start implementing the quiz we need to create our folder structure and the HTML and CSS. In this article, we will be focusing on the JavaScript part of the application. Therefore I will be providing the necessary HTML and CSS in this section. Let’s start by creating the folder structure like this:

$ mkdir vanilla-quiz
$ cd ./vanilla-quiz
$ mkdir quiz
$ touch index.html index.js styles.css

Go ahead and copy and paste the index.html and styles.css from these sources:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vanilla Quiz</title>
  <link rel="stylesheet" href="styles.css">
  <link href="https://fonts.googleapis.com/css2?family=Questrial&display=swap" rel="stylesheet">
</head>
<body>
  <main>
    <div class="header">
      <h2>Vanilla Quiz</h2>
    </div>
    <div class="main">
      <div class="final">
        <h3>You answered all of the questions!</h3>
        <p>Score: </p><p class="score"></p>
        <h4>Want to try it again?</h4>
        <button id="again" class="submit">Again</button>
      </div>
      <div class="quiz">
        <div class="count">
          <p class="current">0</p><p style="margin-left:40px"> / </p><p class="total"></p>
        </div>
        <h3 id="question"></h3>
        <label id="a1" class="container">
          <input type="radio" checked="checked" name="radio">
          <span class="checkmark"></span>
        </label>
        <label id="a2" class="container">
          <input type="radio" name="radio">
          <span class="checkmark"></span>
        </label>
        <label id="a3" class="container">
          <input type="radio" name="radio">
          <span class="checkmark"></span>
        </label>
        <label id="a4" class="container">
          <input type="radio" name="radio">
          <span class="checkmark"></span>
        </label>
        <button id="next" class="submit">Submit</button>
      </div>
      <div class="settings">
        <h3 style="text-align: center;">Set up your Quiz!</h3>
        <label for="category">Category</label>
        <select name="category" id="category">
          <option value="9">General Knowledge</option>
          <option value="27">Animals</option>
          <option value="15">Video Games</option>
          <option value="23">History</option>
          <option value="21">Sports</option>
        </select>
        <div class="mt30">
          <label for="difficulty">Difficulty</label>
          <label class="container" style="display: inline; margin-left: 30px;">Easy
            <input type="radio" name="radio" id="easy">
            <span class="checkmark" style="margin-top: 2px;"></span>
          </label>
          <label class="container" style="display: inline; margin-left: 30px;">Medium
            <input type="radio" name="radio" id="medium">
            <span class="checkmark" style="margin-top: 2px;"></span>
          </label>
          <label  class="container" style="display: inline; margin-left: 30px;">Hard
            <input type="radio" name="radio" id="hard">
            <span class="checkmark" style="margin-top: 2px;"></span>
          </label>
        </div>
        <div class="mt30">
          <label for="questions">Number of questions</label>
          <input name="questions" id="questions" type="text" pattern="[0-9]*" />
        </div>  
        <button id="start" class="submit">Start</button>
      </div>  
    </div>
  </main>
  <script type="module" src="index.js"></script>
</body>
</html>
:root {
  --primary-color: #5D737E;
  --secondary-color: #D6F8D6;
  --tertiary-color: #7FC6A4;
  --quaternary-color: #55505C;
  --hover-color: #4e616b;
  --shadow-color:rgba(57, 127, 93, 0.4);
  --font-style: 'Questrial';
}

body {
  font-family: var(--font-style), 'Ranchers', cursive;
  background-color: var(--secondary-color);
  width: 100vw;
  height: 100vh;
  justify-content: center;
  align-items: center;
}

h2 {
  font-size: 3.5rem;
  text-align: center;
  color: var(--primary-color);
}

.mt30 {
  margin-top: 30px;
}

.header {
  padding: 15px;
}

.main {
  display: flex;
  justify-content: center;
}

.settings {

  z-index: 1;
}

.final {
  visibility: hidden;
  z-index: 2;
}

.final p {
  font-size: 30px;
  text-align: center;
}

.final h4 {
  font-size: 33px;
  text-align: center;
}

.quiz  {
  visibility: hidden;
  z-index: 0;
}

#questions {
  font-size: 20px;
  font-family: var(--font-style), 'Ranchers', cursive;
  font-weight: 600;
  line-height: 1.3;
  color: white;
  background-color: var(--primary-color);
  appearance: none;
  border: none;
  padding: 5px;
  border-radius: 5px;
  margin-left: 30px;
  outline: none;
  text-align: center;
  width: 120px;
}
.settings select {
  font-size: 20px;
  font-family: var(--font-style), 'Ranchers', cursive;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 1px;
  color: white;
  background-color: var(--primary-color);
  -moz-appearance: none;
  -webkit-appearance: none;
  appearance: none;
  border: none;
  padding: 5px;
  border-radius: 5px;
  margin-left: 20px;
  outline: none;
  text-align: center;
}

.settings select::-ms-expand {
  display: none;
}

.settings select:hover {
  border-color: var(--hover-color);
}

.settings select:focus {
  border-color: var(--hover-color);
}

.settings select option {
  /* font-weight: bolder; */
  font-family: var(--font-style), 'Ranchers', sans-serif;
}

.settings label {
  font-size: 25px;
  margin-right: 16px;
}


.quiz, .settings, .final {
  position: absolute;
  padding: 0px 35px 35px 35px;
  max-width: 560px;
  background-color: var(--tertiary-color);
  border-radius: 7px;
  -webkit-box-shadow: 10px 10px 3px -4px var(--shadow-color);
  -moz-box-shadow: 10px 10px 3px -4px var(--shadow-color);
  box-shadow: 10px 10px 5px -4px var(--shadow-color);
}

h3 {
  display: block;
  width: 550px;
  font-size: 35px;
  font-weight: 350;
  word-wrap: break-word;
}

.submit {
  width: 100%;
  color: white;
  background-color: var(--primary-color);
  font-family: var(--font-style), 'Ranchers', cursive;
  outline: none;
  border: none;
  height: 50px;
  font-size: 1.8rem;
  margin-top: 20px;
  border-radius: 5px;
  letter-spacing: 2px;
}

.submit:hover {
  background-color: var(--hover-color);
  cursor: pointer;
  color: #FAF33E;
}

/* The container */
.count {
  display: block;
  left: 75%;
  position: relative;
  padding-left: 35px;
  margin-bottom: 100px;
  cursor: pointer;
}

.count p {
  position: absolute;
  font-size: 35px;

}

.total {
  margin-left: 50px;
}

/* The container */
.container {
  display: block;
  position: relative;
  padding-left: 35px;
  margin-bottom: 12px;
  cursor: pointer;
  font-size: 25px;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}

/* Hide the browser's default radio button */
.container input {
  position: absolute;
  opacity: 0;
  cursor: pointer;
}

/* Create a custom radio button */
.checkmark {
  position: absolute;
  top: -2px;
  left: 0px;
  height: 25px;
  width: 25px;
  background-color: white;
  border-radius: 30%;
}

/* On mouse-over, add a grey background color */
.container:hover input ~ .checkmark {
  background-color: #FAF33E;
}

/* When the radio button is checked, add a blue background */
.container input:checked ~ .checkmark {
  background-color: var(--quaternary-color);
}

/* Create the indicator (the dot/circle - hidden when not checked) */
.checkmark:after {
  content: "";
  position: absolute;
  display: none;
}

/* Show the indicator (dot/circle) when checked */
.container input:checked ~ .checkmark:after {
  display: block;
}

Now we are ready to start working on our app. The first class we will be working on is Settings.

How to get the questions?

The goal of the settings-class is that the player can choose their category, difficulty, and the number of questions they want to answer. We need to create a request to the Open Trivia DB API out of these three parameters to get the questions for the player’s playthrough.

Before we start creating our class we need to create an instance of it in the index.js like this:

import Settings from ‘./quiz/settings.js’;

new Settings();

This will give an error because the file settings.js does not exist yet, so let’s go ahead and create it.

$ touch ./quiz/settings.js

Next up we create the skeleton for our settings-class. For that, we need the class with a constructor and a startQuiz-method plus the export-statement. Without an export-statement, we could not import the class in index.js. This is how it should look like:

class Settings {
  constructor() {
  }

  startQuiz() {
  }
}

export default Settings;

In the constructor, we want to get all the DOM-Elements we need to start a quiz. For that, we need to grab the two div’s, quiz, and settings to toggle their visibility when the player wants to start the quiz. Next up we need all the parameters so that we can create the request for getting the questions. Last but not least we need to get the button to add the startQuiz-method to the click-event.

constructor() {
  this.quizElement = document.querySelector('.quiz');
  this.settingsElement = document.querySelector('.settings');
  this.category = document.querySelector('#category');
  this.numberOfQuestions = document.querySelector('#questions');
  this.difficulty = [
    document.querySelector('#easy'),
    document.querySelector('#medium'),
    document.querySelector('#hard'),
  ];
  this.startButton = document.querySelector('#start');

  this.quiz = { };  
  this.startButton.addEventListener('click', this.startQuiz.bind(this));
}

In the first segment, we get all the DOM-Elements, note that we store the elements for the difficulty in an array to filter them later on. After that, we initialize the quiz-property and add the startQuiz-method to the startButton. Be sure to bind this to the startQuiz-method. If you don’t do that you won’t have this available inside the method.

For the quiz to start we need to collect all the parameters and create the request dynamically. Because we are dealing with an API-call I decided to use async/await to handle the asynchronous call. To make sure no error gets lost we will wrap the whole call in a try-catch-block. So the startQuiz-method should look something like this:

async startQuiz() {
  try {
    const amount = this.getAmount();
    const categoryId = this.category.value;
    const difficulty = this.getCurrentDifficulty();

    const url = `https://opentdb.com/api.php?amount=${amount}&category=${categoryId}&difficulty=${difficulty}&type=multiple`;
    let data = await this.fetchData(url);
    this.toggleVisibility();
    this.quiz = new Quiz(this.quizElement, amount, data.results);
  } catch (error) {
    alert(error);
  }
}

What are we doing here?

First, we get all three values, for the amount and difficulty we use methods that are not yet implemented. In these methods, we will be handling errors e.g. not choosing any difficulty or entering a negative number for the number of questions.

After that, we create the URL with the parameters we just got. This URL is passed in the fetchData-method which will send the request and returns the data. After that, we call toggleVisibility and initialize a new quiz-object by passing in the result, amount, and the quizElement.

If at any point an error is thrown we will catch it and display it by using the alert-method.

The final settings-class should look like this:

import Quiz from './quiz.js';

class Settings {
  constructor() {
    this.quizElement = document.querySelector('.quiz');
    this.settingsElement = document.querySelector('.settings');
    this.category = document.querySelector('#category');
    this.numberOfQuestions = document.querySelector('#questions');
    this.difficulty = [
      document.querySelector('#easy'),
      document.querySelector('#medium'),
      document.querySelector('#hard'),
    ];
    this.startButton = document.querySelector('#start');

    this.quiz = { };

    this.startButton.addEventListener('click', this.startQuiz.bind(this));
  }

  async startQuiz() {
    try {
      const amount = this.getAmount();
      const categoryId = this.category.value;
      const difficulty = this.getCurrentDifficulty();

      const url = `https://opentdb.com/api.php?amount=${amount}&category=${categoryId}&difficulty=${difficulty}&type=multiple`;

      let data = await this.fetchData(url);
      this.toggleVisibility();
      this.quiz = new Quiz(this.quizElement, amount, data.results);
    } catch (error) {
      alert(error);
    }
  }

  toggleVisibility() {
    this.settingsElement.style.visibility = 'hidden';
    this.quizElement.style.visibility = 'visible';
  }

  async fetchData(url) {
    const response = await fetch(url);
    const result = await response.json();

    return result;
  }

  getCurrentDifficulty() {
    const checkedDifficulty = this.difficulty.filter(element => element.checked);

    if (checkedDifficulty.length === 1) {
      return checkedDifficulty[0].id;
    } else {
      throw new Error('Please select a difficulty!');
    }
  }

  getAmount() {
    const amount = this.numberOfQuestions.value;
    // Not negative, not 0 and not over 50
    if (amount > 0 && amount < 51) {
      return amount;
    }
    throw new Error('Please enter a number of questions between 1 and 50!');
  }
}

export default Settings;

Both methods getAmount and getCurrentDifficulty are returning an error if the player did not select anything or the selected value is out of bounds (for the number of questions). We also added the import-statement for the quiz-class at the top of this file. The other two methods (fetchData and toggleVisibility) do exactly what their names suggest. Now we can focus on the quiz-class next.

It's Quiz Time!

Before we start thinking about the quiz-class we need to create a file that will contain it.

$ touch ./quiz/quiz.js

We start as we did with settings.js by creating a skeleton.

class Quiz {
  constructor(quizElement, amount, questions) {
    this.quizElement = quizElement;
    this.totalAmount = amount;
    this.questions = this.setQuestions(questions);
  }
  setQuestions(questions) {
    return questions.map(question => new Question(question));
  }
  nextQuestion() {
  }
  endQuiz() {
  }
}
export default Settings;

This time we have some arguments that got passed in by the settings-object that we need to deal with. For the questions, we create a single question-object for each question that got passed in by the settings-object. The constructor needs some more set up so we will add some more DOM-Elements and an event-listener to the nextButton too. So let’s go ahead and do this!

constructor(quizElement, amount, questions) {
  this.quizElement = quizElement;
  this.currentElement = document.querySelector('.current');
  this.totalElement = document.querySelector('.total');
  this.nextButton = document.querySelector('#next');
  this.finalElement = document.querySelector('.final')
  this.totalAmount = amount;
  this.answeredAmount = 0;
  this.questions = this.setQuestions(questions);
  this.nextButton.addEventListener('click',
  this.nextQuestion.bind(this));
  this.renderQuestion();
}

As you can see it almost looks like the constructor in settings.js. One major difference is the call of renderQuestion at the end. The purpose of this call is that we want to render the first question immediately.

Between setQuestions and nextQuestion we create the method renderQuestion and implement it like this:

renderQuestion() {
  this.questions[this.answeredAmount].render();
  this.currentElement.innerHTML = this.answeredAmount;
  this.totalElement.innerHTML = this.totalAmount;
}

At the start of the quiz, the answeredAmount is 0 so we call the render-method on the first question in the questions-array. After that, we set the current progress of the player. Because we did not implement the question-class yet this code throws an error, but we will fix this soon.

Let’s implement the nextQuestion-method. For that, if the player checked an answer and if so, which answer was checked. Next up we need to show the result to the player and increment the answeredAmount by one. Finally, we have to check if there is another question left and if so display it. If this was the last one we need to proceed to the result screen.

nextQuestion() {
  const checkedElement = this.questions[this.answeredAmount].answerElements.filter(el => el.firstChild.checked);
  if (checkedElement.length === 0) {
    alert(‘You need to select an answer’);
  } else {
    this.questions[this.answeredAmount].answer(checkedElement)
    this.showResult();
    this.answeredAmount++;
    (this.answeredAmount < this.totalAmount) ? this.renderQuestion() : this.endQuiz();
  }
}

The only methods that are missing in this class are showResult, endQuiz, and a method to sum up all the correct answers for the result screen. The final quiz.js should look like this:

import Final from './final.js';
import Question from './question.js'

class Quiz {
  constructor(quizElement, amount, questions) {
    this.quizElement = quizElement;
    this.currentElement = document.querySelector('.current');
    this.totalElement = document.querySelector('.total');
    this.nextButton = document.querySelector('#next');
    this.finalElement = document.querySelector('.final')

    this.totalAmount = amount;
    this.answeredAmount = 0;
    this.questions = this.setQuestions(questions);

    this.nextButton.addEventListener('click', this.nextQuestion.bind(this));
    this.renderQuestion();
  }

  setQuestions(questions) {
    return questions.map(question => new Question(question));
  }

  renderQuestion() {
    this.questions[this.answeredAmount].render();
    this.currentElement.innerHTML = this.answeredAmount;
    this.totalElement.innerHTML = this.totalAmount;
  }

  nextQuestion() {
    const checkedElement = this.questions[this.answeredAmount].answerElements.filter(el => el.firstChild.checked);
    if (checkedElement.length === 0) {
      alert('You need to select an answer');
    } else {
      this.questions[this.answeredAmount].answer(checkedElement)
      this.showResult();
      this.answeredAmount++;
      (this.answeredAmount < this.totalAmount) ? this.renderQuestion() : this.endQuiz();
    }
  }

  showResult() {
    this.questions[this.answeredAmount].isCorrect ? alert('Correct answer :)') : alert('Wrong answer :(');
  }

  endQuiz() {
    this.quizElement.style.visibility = 'hidden';
    this.finalElement.style.visibility = 'visible';
    const correctAnswersTotal = this.calculateCorrectAnswers();
    this.final = new Final(correctAnswersTotal, this.totalAmount);
  }

  calculateCorrectAnswers() {
    let count = 0;
    this.questions.forEach(el => {
      if (el.isCorrect) {
        count++;
      }
    });
    return count;
  }
}

export default Quiz;

We added the two imports at the top for question.js and final.js. Additionally, we implemented showResult by checking if the question was correctly answered with the ternary operator.

The endQuiz-method looks a little bit like the toggleVisibility-method from our settings.js, except that it sums up all the correct answers by calling calculateCorrectAnswers and then passing it to a new instance of the final-class (we still need to implement that class).

Displaying a Question and the Result

Our quiz-class does not work at the moment because two dependencies do not exist yet. Let’s change this by adding the two files like this:

$ touch ./quiz/question.js ./quiz/final.js

We start by implementing the question-class. First of all, we add a skeleton to the file like this:

class Question {
  constructor(question) {
    this.correctAnswer = question.correct_answer;
    this.question = question.question;
    this.answers = this.shuffleAnswers([
      question.correct_answer,
      ...question.incorrect_answers
    ]);
  }
  shuffleAnswers(answers) {
  }

  answer(checkedElement) {
  }
  render() {
  }
}
export default Question;

So what did we do here?

We save the question, correct answer, and an array of answers that we shuffle before we store it.

The next step is to implement the shuffleAnswers, answer, and render methods. For the shuffling of the array, we will use the Fisher-Yates-Shuffle-Algorithm.

The answer-method will just compare the choice of the player with the correctAnswer property and the render method will display the question and all the possible answers. For this to work, we need to get the respective DOM-Elements and end up with this question.js:

class Question {
  constructor(question) {
    this.questionElement = document.querySelector('#question');
    this.answerElements = [
      document.querySelector('#a1'),
      document.querySelector('#a2'),
      document.querySelector('#a3'),
      document.querySelector('#a4'),
    ];

    this.correctAnswer = question.correct_answer;
    this.question = question.question;
    this.isCorrect = false;

    this.answers = this.shuffleAnswers([
      question.correct_answer, 
      ...question.incorrect_answers
    ]);
  }

  shuffleAnswers(answers) {
    for (let i = answers.length - 1; i > 0; i--){
      const j = Math.floor(Math.random() * i)
      const temp = answers[i]
      answers[i] = answers[j]
      answers[j] = temp
    }
    return answers;
  }

  answer(checkedElement) {
     this.isCorrect = (checkedElement[0].textContent === this.correctAnswer) ? true : false;
  }

  render() {
    this.questionElement.innerHTML = this.question;
    this.answerElements.forEach((el, index) => {
      el.innerHTML = '<input type="radio" name="radio"><span class="checkmark"></span>' + this.answers[index];
    });
  }
}

export default Question;

Now the only thing missing is the final-class. This class is really simple we just need to get the DOM-Elements to display the final result to the player. To add some convenience we can add an again-button that reloads the page so the player can start again. This is how it should look like:

class Final {
  constructor(count, totalAmount) {
    this.scoreElement = document.querySelector('.score');
    this.againButton = document.querySelector('#again');

    this.render(count, totalAmount);
    this.againButton.addEventListener('click', location.reload.bind(location));
  }

  render(count, totalAmount) {
    this.scoreElement.innerHTML = `You answered ${count} out of ${totalAmount} correct!`;
  }
}

export default Final;

Conclusion

The quiz app is now complete. We implemented this with just plain old JavaScript and used the concept of Object-Oriented-Programming. I hope you enjoyed this and as always you can find the code on my GitHub.

Screenshots

The players can choose a category, difficulty, and the number of questions they want to answer and then start the quiz. 1_nTSSoJczoYasfy-RoXKfRA.png When started the multiple-choice questions show up like this. 1_o9yJW-DtuYP8C-rYZxoxlQ.png And at the end of all questions, you get to see this final page. 1_JoaeW6mwee4UYsWPxDaqpg.png

I hope you had fun following along. Try and improve this version if you want to.

Photo by Emily Morter on Unsplash

Comments (1)

Shamaayil Ahmed's photo

Good..