Skip to main content
Back to Blog
Tutorials
4 min read
November 27, 2024

How to Build an Interactive Survey and Quiz Component in React

Build a multi-step survey and quiz component with scoring, branching logic, and results display in React.

Ryel Banfield

Founder & Lead Developer

Surveys and quizzes drive engagement and collect valuable data. Here is how to build a flexible one.

Step 1: Define Quiz Structure

// types/quiz.ts
export type QuestionType = "single" | "multiple" | "text" | "rating";

export interface Option {
  id: string;
  text: string;
  score?: number;
}

export interface Question {
  id: string;
  type: QuestionType;
  text: string;
  description?: string;
  options?: Option[];
  required?: boolean;
  maxSelections?: number;
}

export interface QuizConfig {
  title: string;
  description: string;
  questions: Question[];
  showScore?: boolean;
  resultRanges?: { min: number; max: number; title: string; description: string }[];
}

Step 2: Sample Quiz Data

// data/quiz.ts
import type { QuizConfig } from "@/types/quiz";

export const sampleQuiz: QuizConfig = {
  title: "Which Tech Stack Is Right for You?",
  description: "Answer a few questions to find your ideal web development stack.",
  showScore: true,
  questions: [
    {
      id: "q1",
      type: "single",
      text: "What is the primary purpose of your project?",
      required: true,
      options: [
        { id: "a", text: "Marketing website", score: 1 },
        { id: "b", text: "Web application", score: 3 },
        { id: "c", text: "E-commerce store", score: 2 },
        { id: "d", text: "Blog or content site", score: 1 },
      ],
    },
    {
      id: "q2",
      type: "single",
      text: "How important is SEO to your project?",
      required: true,
      options: [
        { id: "a", text: "Critical — it is our main traffic source", score: 3 },
        { id: "b", text: "Important but not critical", score: 2 },
        { id: "c", text: "Not very important", score: 1 },
      ],
    },
    {
      id: "q3",
      type: "multiple",
      text: "Which features do you need?",
      maxSelections: 3,
      options: [
        { id: "a", text: "User authentication", score: 2 },
        { id: "b", text: "Real-time updates", score: 3 },
        { id: "c", text: "Payment processing", score: 2 },
        { id: "d", text: "Content management", score: 1 },
        { id: "e", text: "API integrations", score: 2 },
      ],
    },
    {
      id: "q4",
      type: "rating",
      text: "How would you rate your team's technical expertise?",
      required: true,
    },
    {
      id: "q5",
      type: "text",
      text: "What is your biggest concern about the project?",
    },
  ],
  resultRanges: [
    { min: 0, max: 8, title: "Static Site Generator", description: "A simple static site with Astro or Hugo would be perfect for your needs." },
    { min: 9, max: 14, title: "Next.js Full-Stack", description: "Next.js with a database and authentication is your best bet." },
    { min: 15, max: 25, title: "Custom Web Application", description: "You need a custom-built web application with advanced features." },
  ],
};

Step 3: Quiz Component

"use client";

import { useState } from "react";
import type { QuizConfig, Question } from "@/types/quiz";
import { ChevronRight, ChevronLeft, Check } from "lucide-react";

interface Answers {
  [questionId: string]: string | string[] | number;
}

export function Quiz({ config }: { config: QuizConfig }) {
  const [currentStep, setCurrentStep] = useState(0);
  const [answers, setAnswers] = useState<Answers>({});
  const [isComplete, setIsComplete] = useState(false);

  const question = config.questions[currentStep];
  const totalSteps = config.questions.length;
  const progress = ((currentStep + 1) / totalSteps) * 100;

  function setAnswer(questionId: string, value: string | string[] | number) {
    setAnswers((prev) => ({ ...prev, [questionId]: value }));
  }

  function next() {
    if (currentStep < totalSteps - 1) {
      setCurrentStep((s) => s + 1);
    } else {
      setIsComplete(true);
    }
  }

  function prev() {
    if (currentStep > 0) setCurrentStep((s) => s - 1);
  }

  function calculateScore(): number {
    let score = 0;
    for (const q of config.questions) {
      const answer = answers[q.id];
      if (!answer || !q.options) continue;

      if (Array.isArray(answer)) {
        for (const optId of answer) {
          const opt = q.options.find((o) => o.id === optId);
          if (opt?.score) score += opt.score;
        }
      } else if (typeof answer === "string") {
        const opt = q.options.find((o) => o.id === answer);
        if (opt?.score) score += opt.score;
      } else if (typeof answer === "number") {
        score += answer;
      }
    }
    return score;
  }

  if (isComplete) {
    const score = calculateScore();
    const result = config.resultRanges?.find(
      (r) => score >= r.min && score <= r.max
    );

    return (
      <div className="mx-auto max-w-lg rounded-2xl border p-8 text-center dark:border-gray-700">
        <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
          <Check className="h-8 w-8 text-green-600 dark:text-green-400" />
        </div>
        <h2 className="text-2xl font-bold">{result?.title || "Complete!"}</h2>
        {config.showScore && (
          <p className="mt-2 text-3xl font-bold text-blue-600">{score} points</p>
        )}
        <p className="mt-3 text-gray-600 dark:text-gray-400">
          {result?.description || "Thank you for completing the quiz."}
        </p>
        <button
          onClick={() => {
            setCurrentStep(0);
            setAnswers({});
            setIsComplete(false);
          }}
          className="mt-6 rounded-lg bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
        >
          Retake Quiz
        </button>
      </div>
    );
  }

  return (
    <div className="mx-auto max-w-lg rounded-2xl border p-6 dark:border-gray-700">
      {/* Progress */}
      <div className="mb-6">
        <div className="mb-2 flex justify-between text-xs text-gray-500">
          <span>Question {currentStep + 1} of {totalSteps}</span>
          <span>{Math.round(progress)}%</span>
        </div>
        <div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
          <div
            className="h-full rounded-full bg-blue-600 transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
      </div>

      {/* Question */}
      <h3 className="text-lg font-semibold">{question.text}</h3>
      {question.description && (
        <p className="mt-1 text-sm text-gray-500">{question.description}</p>
      )}

      {/* Answer Input */}
      <div className="mt-4">
        <QuestionInput
          question={question}
          answer={answers[question.id]}
          onChange={(v) => setAnswer(question.id, v)}
        />
      </div>

      {/* Navigation */}
      <div className="mt-6 flex justify-between">
        <button
          onClick={prev}
          disabled={currentStep === 0}
          className="flex items-center gap-1 rounded-lg border px-4 py-2 text-sm disabled:opacity-50 dark:border-gray-700"
        >
          <ChevronLeft className="h-4 w-4" /> Back
        </button>
        <button
          onClick={next}
          disabled={question.required && !answers[question.id]}
          className="flex items-center gap-1 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
        >
          {currentStep === totalSteps - 1 ? "Finish" : "Next"}
          <ChevronRight className="h-4 w-4" />
        </button>
      </div>
    </div>
  );
}

function QuestionInput({
  question,
  answer,
  onChange,
}: {
  question: Question;
  answer: string | string[] | number | undefined;
  onChange: (value: string | string[] | number) => void;
}) {
  switch (question.type) {
    case "single":
      return (
        <div className="space-y-2">
          {question.options?.map((opt) => (
            <button
              key={opt.id}
              onClick={() => onChange(opt.id)}
              className={`w-full rounded-lg border p-3 text-left text-sm transition-all ${
                answer === opt.id
                  ? "border-blue-500 bg-blue-50 dark:bg-blue-950"
                  : "border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
              }`}
            >
              {opt.text}
            </button>
          ))}
        </div>
      );

    case "multiple":
      const selected = (answer as string[]) || [];
      return (
        <div className="space-y-2">
          {question.options?.map((opt) => (
            <button
              key={opt.id}
              onClick={() => {
                if (selected.includes(opt.id)) {
                  onChange(selected.filter((id) => id !== opt.id));
                } else if (!question.maxSelections || selected.length < question.maxSelections) {
                  onChange([...selected, opt.id]);
                }
              }}
              className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left text-sm transition-all ${
                selected.includes(opt.id)
                  ? "border-blue-500 bg-blue-50 dark:bg-blue-950"
                  : "border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
              }`}
            >
              <div className={`flex h-4 w-4 items-center justify-center rounded border ${
                selected.includes(opt.id) ? "border-blue-500 bg-blue-500" : "border-gray-300"
              }`}>
                {selected.includes(opt.id) && <Check className="h-3 w-3 text-white" />}
              </div>
              {opt.text}
            </button>
          ))}
        </div>
      );

    case "rating":
      return (
        <div className="flex justify-center gap-2">
          {[1, 2, 3, 4, 5].map((n) => (
            <button
              key={n}
              onClick={() => onChange(n)}
              className={`flex h-12 w-12 items-center justify-center rounded-lg border text-lg font-semibold transition-all ${
                (answer as number) === n
                  ? "border-blue-500 bg-blue-600 text-white"
                  : "border-gray-200 hover:bg-gray-50 dark:border-gray-700"
              }`}
            >
              {n}
            </button>
          ))}
        </div>
      );

    case "text":
      return (
        <textarea
          value={(answer as string) || ""}
          onChange={(e) => onChange(e.target.value)}
          rows={3}
          placeholder="Type your answer..."
          className="w-full rounded-lg border p-3 text-sm dark:border-gray-700 dark:bg-gray-900"
        />
      );

    default:
      return null;
  }
}

Want Interactive Content on Your Site?

We build engaging web experiences with quizzes, surveys, calculators, and interactive content. Contact us to discuss your project.

surveyquizinteractiveformsReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles