Skip to main content
Back to Blog
Tutorials
5 min read
November 29, 2024

How to Build a Feedback Widget in React

Create an embeddable feedback widget with emoji ratings, text feedback, screenshots, and submission handling in React.

Ryel Banfield

Founder & Lead Developer

A feedback widget lets users share quick reactions and detailed feedback without leaving the page. Here is how to build one.

Step 1: Feedback Widget Container

// components/feedback/FeedbackWidget.tsx
"use client";

import { useState } from "react";
import { FeedbackForm } from "./FeedbackForm";

export function FeedbackWidget() {
  const [open, setOpen] = useState(false);
  const [submitted, setSubmitted] = useState(false);

  function handleClose() {
    setOpen(false);
    // Reset submitted state after close animation
    setTimeout(() => setSubmitted(false), 300);
  }

  return (
    <>
      {/* Trigger button */}
      <button
        onClick={() => setOpen(true)}
        className="fixed bottom-4 right-4 z-50 rounded-full bg-gray-900 px-4 py-2.5 text-sm font-medium text-white shadow-lg transition hover:bg-gray-800"
        aria-label="Send feedback"
      >
        Feedback
      </button>

      {/* Overlay */}
      {open && (
        <div className="fixed inset-0 z-50 flex items-end justify-end p-4 sm:items-center sm:justify-center">
          {/* Backdrop */}
          <div
            className="absolute inset-0 bg-black/20"
            onClick={handleClose}
            aria-hidden="true"
          />

          {/* Widget */}
          <div className="relative w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
            <button
              onClick={handleClose}
              className="absolute right-3 top-3 text-gray-400 hover:text-gray-600"
              aria-label="Close"
            >
              x
            </button>

            {submitted ? (
              <div className="py-6 text-center">
                <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 text-xl">
                  v
                </div>
                <h3 className="text-lg font-semibold">Thank you!</h3>
                <p className="mt-1 text-sm text-gray-600">
                  Your feedback helps us improve.
                </p>
                <button
                  onClick={handleClose}
                  className="mt-4 text-sm text-gray-500 hover:underline"
                >
                  Close
                </button>
              </div>
            ) : (
              <FeedbackForm onSubmitted={() => setSubmitted(true)} />
            )}
          </div>
        </div>
      )}
    </>
  );
}

Step 2: Feedback Form

// components/feedback/FeedbackForm.tsx
"use client";

import { useState } from "react";

type Rating = "love" | "like" | "neutral" | "dislike" | "hate";

const ratings: { value: Rating; label: string; icon: string }[] = [
  { value: "love", label: "Love it", icon: ":-D" },
  { value: "like", label: "Like it", icon: ":-)" },
  { value: "neutral", label: "Neutral", icon: ":-|" },
  { value: "dislike", label: "Dislike", icon: ":-(" },
  { value: "hate", label: "Hate it", icon: ":'(" },
];

const categories = [
  "Bug Report",
  "Feature Request",
  "General Feedback",
  "Performance Issue",
  "Other",
];

interface FeedbackFormProps {
  onSubmitted: () => void;
}

export function FeedbackForm({ onSubmitted }: FeedbackFormProps) {
  const [rating, setRating] = useState<Rating | null>(null);
  const [category, setCategory] = useState("");
  const [message, setMessage] = useState("");
  const [email, setEmail] = useState("");
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!rating) return;

    setSubmitting(true);
    setError(null);

    try {
      const res = await fetch("/api/feedback", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          rating,
          category: category || undefined,
          message: message || undefined,
          email: email || undefined,
          page: window.location.pathname,
          userAgent: navigator.userAgent,
          screenSize: `${window.innerWidth}x${window.innerHeight}`,
        }),
      });

      if (!res.ok) throw new Error("Failed to submit feedback");
      onSubmitted();
    } catch {
      setError("Something went wrong. Please try again.");
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <h3 className="text-lg font-semibold">How is your experience?</h3>

      {/* Emoji rating */}
      <div className="flex justify-between">
        {ratings.map((r) => (
          <button
            key={r.value}
            type="button"
            onClick={() => setRating(r.value)}
            className={`flex flex-col items-center gap-1 rounded-lg px-3 py-2 transition ${
              rating === r.value
                ? "bg-blue-50 ring-2 ring-blue-600"
                : "hover:bg-gray-50"
            }`}
            aria-label={r.label}
          >
            <span className="text-xl font-mono">{r.icon}</span>
            <span className="text-xs text-gray-500">{r.label}</span>
          </button>
        ))}
      </div>

      {rating && (
        <>
          {/* Category */}
          <div>
            <label htmlFor="feedback-category" className="mb-1 block text-sm font-medium">
              Category (optional)
            </label>
            <select
              id="feedback-category"
              value={category}
              onChange={(e) => setCategory(e.target.value)}
              className="w-full rounded-lg border px-3 py-2 text-sm"
            >
              <option value="">Select a category</option>
              {categories.map((cat) => (
                <option key={cat} value={cat}>
                  {cat}
                </option>
              ))}
            </select>
          </div>

          {/* Message */}
          <div>
            <label htmlFor="feedback-message" className="mb-1 block text-sm font-medium">
              Tell us more (optional)
            </label>
            <textarea
              id="feedback-message"
              value={message}
              onChange={(e) => setMessage(e.target.value)}
              rows={3}
              placeholder="What can we improve?"
              className="w-full rounded-lg border px-3 py-2 text-sm"
              maxLength={1000}
            />
          </div>

          {/* Email */}
          <div>
            <label htmlFor="feedback-email" className="mb-1 block text-sm font-medium">
              Email (optional, for follow-up)
            </label>
            <input
              id="feedback-email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="you@example.com"
              className="w-full rounded-lg border px-3 py-2 text-sm"
            />
          </div>

          {error && <p className="text-sm text-red-600">{error}</p>}

          <button
            type="submit"
            disabled={submitting}
            className="w-full rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
          >
            {submitting ? "Submitting..." : "Send Feedback"}
          </button>
        </>
      )}
    </form>
  );
}

Step 3: Feedback API Route

// app/api/feedback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/db";
import { feedback } from "@/db/schema";

const feedbackSchema = z.object({
  rating: z.enum(["love", "like", "neutral", "dislike", "hate"]),
  category: z.string().optional(),
  message: z.string().max(1000).optional(),
  email: z.string().email().optional(),
  page: z.string().optional(),
  userAgent: z.string().optional(),
  screenSize: z.string().optional(),
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = feedbackSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  await db.insert(feedback).values(parsed.data);

  // Optionally notify via Slack/email for negative feedback
  if (parsed.data.rating === "dislike" || parsed.data.rating === "hate") {
    // await notifyTeam(parsed.data);
  }

  return NextResponse.json({ success: true });
}

Step 4: Inline Page Feedback

// components/feedback/PageFeedback.tsx
"use client";

import { useState } from "react";

export function PageFeedback({ page }: { page: string }) {
  const [voted, setVoted] = useState<"yes" | "no" | null>(null);
  const [showForm, setShowForm] = useState(false);
  const [comment, setComment] = useState("");
  const [submitted, setSubmitted] = useState(false);

  async function handleVote(helpful: "yes" | "no") {
    setVoted(helpful);
    if (helpful === "no") {
      setShowForm(true);
    } else {
      await submitFeedback(helpful);
    }
  }

  async function submitFeedback(helpful: string, message?: string) {
    await fetch("/api/feedback", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        rating: helpful === "yes" ? "like" : "dislike",
        category: "Page Helpfulness",
        message,
        page,
      }),
    });
    setSubmitted(true);
  }

  if (submitted) {
    return (
      <div className="rounded-lg border bg-gray-50 p-4 text-center text-sm text-gray-600">
        Thank you for your feedback!
      </div>
    );
  }

  return (
    <div className="rounded-lg border p-4">
      <p className="text-sm font-medium">Was this page helpful?</p>
      <div className="mt-2 flex gap-2">
        <button
          onClick={() => handleVote("yes")}
          className={`rounded-lg border px-4 py-2 text-sm ${
            voted === "yes" ? "border-green-600 bg-green-50" : "hover:bg-gray-50"
          }`}
        >
          Yes
        </button>
        <button
          onClick={() => handleVote("no")}
          className={`rounded-lg border px-4 py-2 text-sm ${
            voted === "no" ? "border-red-600 bg-red-50" : "hover:bg-gray-50"
          }`}
        >
          No
        </button>
      </div>

      {showForm && (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            submitFeedback("no", comment);
          }}
          className="mt-3 space-y-2"
        >
          <textarea
            value={comment}
            onChange={(e) => setComment(e.target.value)}
            placeholder="How can we improve this page?"
            rows={2}
            className="w-full rounded-lg border px-3 py-2 text-sm"
          />
          <button
            type="submit"
            className="rounded-lg bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-800"
          >
            Send
          </button>
        </form>
      )}
    </div>
  );
}

Step 5: NPS Survey Widget

// components/feedback/NpsSurvey.tsx
"use client";

import { useState } from "react";

export function NpsSurvey({ onDismiss }: { onDismiss: () => void }) {
  const [score, setScore] = useState<number | null>(null);
  const [comment, setComment] = useState("");
  const [submitted, setSubmitted] = useState(false);

  async function handleSubmit() {
    if (score === null) return;

    await fetch("/api/feedback", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        rating: score >= 9 ? "love" : score >= 7 ? "like" : score >= 5 ? "neutral" : "dislike",
        category: "NPS Survey",
        message: `NPS Score: ${score}. ${comment}`,
        page: window.location.pathname,
      }),
    });

    setSubmitted(true);
  }

  if (submitted) {
    return (
      <div className="fixed bottom-4 right-4 z-50 rounded-xl bg-white p-6 shadow-2xl">
        <p className="text-sm font-medium">Thank you for your feedback!</p>
        <button
          onClick={onDismiss}
          className="mt-2 text-sm text-gray-500 hover:underline"
        >
          Close
        </button>
      </div>
    );
  }

  return (
    <div className="fixed bottom-4 right-4 z-50 w-80 rounded-xl bg-white p-6 shadow-2xl">
      <button
        onClick={onDismiss}
        className="absolute right-3 top-3 text-gray-400 hover:text-gray-600"
        aria-label="Close"
      >
        x
      </button>

      <p className="text-sm font-medium">
        How likely are you to recommend us to a friend or colleague?
      </p>

      <div className="mt-3 flex gap-1">
        {Array.from({ length: 11 }, (_, i) => (
          <button
            key={i}
            onClick={() => setScore(i)}
            className={`flex h-8 w-8 items-center justify-center rounded text-xs ${
              score === i
                ? "bg-blue-600 text-white"
                : "bg-gray-100 text-gray-600 hover:bg-gray-200"
            }`}
          >
            {i}
          </button>
        ))}
      </div>

      <div className="mt-1 flex justify-between text-xs text-gray-400">
        <span>Not likely</span>
        <span>Very likely</span>
      </div>

      {score !== null && (
        <>
          <textarea
            value={comment}
            onChange={(e) => setComment(e.target.value)}
            placeholder={
              score >= 9
                ? "What do you love most?"
                : "What can we improve?"
            }
            rows={2}
            className="mt-3 w-full rounded-lg border px-3 py-2 text-sm"
          />
          <button
            onClick={handleSubmit}
            className="mt-2 w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700"
          >
            Submit
          </button>
        </>
      )}
    </div>
  );
}

Need User Feedback Tools?

We build feedback systems, NPS surveys, and analytics dashboards to help you understand your users. Contact us to learn more.

feedbackwidgetuser experienceReacttutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles