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.