Feedback Form
Original · freefeedback form with emoji rating
byWeb InnoventixReact + Tailwind
formfeedbackforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-feedback.jsonform-feedback.tsx
"use client";
import { useId, useMemo, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "idle" | "submitting" | "success";
type Rating = {
level: number;
label: string;
hint: string;
};
const RATINGS: readonly Rating[] = [
{ level: 1, label: "Awful", hint: "That one is on us — tell us what broke." },
{ level: 2, label: "Poor", hint: "We clearly missed the mark somewhere." },
{ level: 3, label: "Okay", hint: "It worked, but there is room to improve." },
{ level: 4, label: "Good", hint: "Glad it landed. What would make it great?" },
{ level: 5, label: "Amazing", hint: "You just made the whole team's day." },
];
const TOPICS: readonly string[] = ["Product", "Pricing", "Support", "Something else"];
const LEVEL_TEXT: Record<number, string> = {
1: "text-rose-500",
2: "text-amber-500",
3: "text-sky-500",
4: "text-indigo-500",
5: "text-emerald-500",
};
const LEVEL_GLOW: Record<number, string> = {
1: "ring-rose-400/60 bg-rose-50 dark:bg-rose-500/10",
2: "ring-amber-400/60 bg-amber-50 dark:bg-amber-500/10",
3: "ring-sky-400/60 bg-sky-50 dark:bg-sky-500/10",
4: "ring-indigo-400/60 bg-indigo-50 dark:bg-indigo-500/10",
5: "ring-emerald-400/60 bg-emerald-50 dark:bg-emerald-500/10",
};
const MOUTHS: Record<number, string> = {
1: "M15 35 Q24 26 33 35",
2: "M15 33 Q24 28.5 33 33",
3: "M15 31 L33 31",
4: "M15 30 Q24 35.5 33 30",
5: "M15 29 Q24 39 33 29",
};
function EmojiFace({ level }: { level: number }) {
return (
<svg viewBox="0 0 48 48" width="100%" height="100%" aria-hidden="true" focusable="false">
<circle cx="24" cy="24" r="21" fill="currentColor" fillOpacity={0.14} />
<circle cx="24" cy="24" r="21" fill="none" stroke="currentColor" strokeOpacity={0.32} strokeWidth={1.4} />
{level === 5 ? (
<>
<path d="M14.5 21.5 Q18 17.5 21.5 21.5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" />
<path d="M26.5 21.5 Q30 17.5 33.5 21.5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" />
</>
) : level === 1 ? (
<>
<circle cx="18" cy="20.5" r="2.2" fill="currentColor" />
<circle cx="30" cy="20.5" r="2.2" fill="currentColor" />
<path d="M14.5 15.5 L21 18" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" />
<path d="M33.5 15.5 L27 18" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" />
</>
) : (
<>
<circle cx="18" cy="20.5" r="2.2" fill="currentColor" />
<circle cx="30" cy="20.5" r="2.2" fill="currentColor" />
</>
)}
<path d={MOUTHS[level]} fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export default function FormFeedback() {
const reduce = useReducedMotion();
const uid = useId();
const groupLabelId = `${uid}-rating-label`;
const messageId = `${uid}-message`;
const emailId = `${uid}-email`;
const errorId = `${uid}-error`;
const [rating, setRating] = useState<number | null>(null);
const [hover, setHover] = useState<number | null>(null);
const [topic, setTopic] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [email, setEmail] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const faceRefs = useRef<(HTMLButtonElement | null)[]>([]);
const MAX = 500;
const display = hover ?? rating;
const current = useMemo(() => RATINGS.find((r) => r.level === display) ?? null, [display]);
const emailValid = email.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
function pick(level: number) {
setRating(level);
if (error) setError(null);
}
function handleKey(e: KeyboardEvent<HTMLButtonElement>, index: number) {
const key = e.key;
let next = -1;
if (key === "ArrowRight" || key === "ArrowDown") next = (index + 1) % RATINGS.length;
else if (key === "ArrowLeft" || key === "ArrowUp") next = (index - 1 + RATINGS.length) % RATINGS.length;
else if (key === "Home") next = 0;
else if (key === "End") next = RATINGS.length - 1;
else return;
e.preventDefault();
pick(RATINGS[next].level);
faceRefs.current[next]?.focus();
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (rating === null) {
setError("Please pick a rating so we know how it went.");
faceRefs.current[0]?.focus();
return;
}
if (!emailValid) {
setError("That email address does not look quite right.");
return;
}
setError(null);
setStatus("submitting");
window.setTimeout(() => setStatus("success"), 1100);
}
function reset() {
setRating(null);
setHover(null);
setTopic(null);
setMessage("");
setEmail("");
setError(null);
setStatus("idle");
}
const panelMotion = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.15 } }
: {
initial: { opacity: 0, y: 14 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -14 },
transition: { duration: 0.32, ease: [0.22, 1, 0.36, 1] as [number, number, number, number] },
};
const submitted = RATINGS.find((r) => r.level === rating) ?? null;
return (
<section
aria-labelledby={`${uid}-title`}
className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-slate-950"
>
<style>{`
@keyframes ff_float {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -22px, 0) scale(1.06); }
}
@keyframes ff_draw { to { stroke-dashoffset: 0; } }
@keyframes ff_pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.12); }
100% { transform: scale(1); opacity: 1; }
}
.ff-blob { animation: ff_float 10s ease-in-out infinite; }
.ff-blob-2 { animation: ff_float 13s ease-in-out infinite reverse; }
.ff-check { stroke-dasharray: 42; stroke-dashoffset: 42; animation: ff_draw 0.7s ease-out 0.15s forwards; }
.ff-pop { animation: ff_pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
@media (prefers-reduced-motion: reduce) {
.ff-blob, .ff-blob-2, .ff-check, .ff-pop { animation: none !important; }
.ff-check { stroke-dashoffset: 0; }
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div className="ff-blob absolute -left-24 top-4 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
<div className="ff-blob-2 absolute -right-20 bottom-0 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-600/20" />
</div>
<div className="relative mx-auto w-full max-w-xl">
<div className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-sm sm:p-9 dark:border-slate-800 dark:bg-slate-900/80 dark:shadow-black/40">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div key="success" {...panelMotion} className="flex flex-col items-center py-6 text-center">
<div className="ff-pop relative mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500/12 ring-1 ring-emerald-400/50">
<svg viewBox="0 0 52 52" className="h-11 w-11 text-emerald-500" aria-hidden="true">
<path
className="ff-check"
d="M14 27 L22 35 L38 18"
fill="none"
stroke="currentColor"
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
Thank you — this really helps.
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{submitted ? (
<>
You rated your experience{" "}
<span className={`font-semibold ${LEVEL_TEXT[submitted.level]}`}>
{submitted.label.toLowerCase()}
</span>
{topic ? <> on {topic.toLowerCase()}</> : null}. A real person on our team reads every note.
</>
) : (
"A real person on our team reads every note that comes through."
)}
</p>
<button
type="button"
onClick={reset}
className="mt-8 inline-flex items-center justify-center gap-2 rounded-full border border-slate-300 px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:border-slate-400 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:border-slate-600 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Send another response
</button>
</motion.div>
) : (
<motion.form key="form" {...panelMotion} onSubmit={handleSubmit} noValidate>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-500 dark:text-indigo-400">
Feedback
</p>
<h2
id={`${uid}-title`}
className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
>
How was your experience?
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Your honest take shapes what we build next. It takes about twenty seconds.
</p>
{/* Emoji rating */}
<div className="mt-8">
<span id={groupLabelId} className="sr-only">
Rate your experience from awful to amazing
</span>
<div
role="radiogroup"
aria-labelledby={groupLabelId}
aria-required="true"
className="flex items-center justify-between gap-1.5 sm:gap-2.5"
>
{RATINGS.map((r, i) => {
const active = rating === r.level;
const isHover = hover === r.level;
return (
<button
key={r.level}
type="button"
role="radio"
aria-checked={active}
aria-label={r.label}
tabIndex={rating === null ? (i === 0 ? 0 : -1) : active ? 0 : -1}
ref={(el) => {
faceRefs.current[i] = el;
}}
onClick={() => pick(r.level)}
onKeyDown={(e) => handleKey(e, i)}
onMouseEnter={() => setHover(r.level)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(r.level)}
onBlur={() => setHover(null)}
className={`flex flex-1 items-center justify-center rounded-2xl p-2 outline-none ring-1 transition-all duration-200 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
active
? `${LEVEL_GLOW[r.level]} ${LEVEL_TEXT[r.level]}`
: "bg-slate-100/60 text-slate-400 ring-transparent hover:bg-slate-100 hover:text-slate-500 dark:bg-slate-800/50 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-400"
}`}
>
<motion.span
className="block h-9 w-9 sm:h-11 sm:w-11"
animate={{ scale: active ? 1.12 : isHover ? 1.06 : 1, y: active ? -2 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 17 }}
>
<EmojiFace level={r.level} />
</motion.span>
</button>
);
})}
</div>
<div className="mt-3 flex min-h-[1.5rem] items-center justify-center" aria-live="polite">
{current ? (
<p className="text-sm">
<span className={`font-semibold ${LEVEL_TEXT[current.level]}`}>{current.label}</span>
<span className="text-slate-500 dark:text-slate-400"> — {current.hint}</span>
</p>
) : (
<p className="text-sm text-slate-400 dark:text-slate-500">Tap a face to rate.</p>
)}
</div>
</div>
{/* Topic */}
<fieldset className="mt-7">
<legend className="mb-2.5 text-sm font-medium text-slate-700 dark:text-slate-300">
What is this about? <span className="font-normal text-slate-400 dark:text-slate-500">(optional)</span>
</legend>
<div className="flex flex-wrap gap-2">
{TOPICS.map((t) => {
const on = topic === t;
return (
<button
key={t}
type="button"
aria-pressed={on}
onClick={() => setTopic(on ? null : t)}
className={`rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
on
? "bg-indigo-600 text-white shadow-sm shadow-indigo-600/30"
: "border border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800/40 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
}`}
>
{t}
</button>
);
})}
</div>
</fieldset>
{/* Message */}
<div className="mt-7">
<div className="mb-2 flex items-baseline justify-between">
<label htmlFor={messageId} className="text-sm font-medium text-slate-700 dark:text-slate-300">
Tell us more <span className="font-normal text-slate-400 dark:text-slate-500">(optional)</span>
</label>
<span
className={`text-xs tabular-nums ${
message.length > MAX - 50 ? "text-amber-500" : "text-slate-400 dark:text-slate-500"
}`}
>
{message.length}/{MAX}
</span>
</div>
<textarea
id={messageId}
value={message}
maxLength={MAX}
rows={4}
onChange={(e) => setMessage(e.target.value)}
placeholder="What stood out — good or bad? Specifics help us the most."
className="w-full resize-y rounded-xl border border-slate-200 bg-white px-3.5 py-3 text-sm text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:bg-slate-800/40 dark:text-white dark:placeholder:text-slate-500 dark:focus:border-indigo-500"
/>
</div>
{/* Email */}
<div className="mt-5">
<label htmlFor={emailId} className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
Email <span className="font-normal text-slate-400 dark:text-slate-500">(if you want a reply)</span>
</label>
<input
id={emailId}
type="email"
inputMode="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!emailValid}
placeholder="you@company.com"
className={`w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-slate-900 placeholder:text-slate-400 outline-none transition-colors focus-visible:ring-2 dark:bg-slate-800/40 dark:text-white dark:placeholder:text-slate-500 ${
emailValid
? "border-slate-200 focus:border-indigo-400 focus-visible:ring-indigo-500/40 dark:border-slate-700 dark:focus:border-indigo-500"
: "border-rose-400 focus-visible:ring-rose-500/40 dark:border-rose-500"
}`}
/>
</div>
{error ? (
<p id={errorId} role="alert" className="mt-4 flex items-center gap-2 text-sm font-medium text-rose-600 dark:text-rose-400">
<svg viewBox="0 0 20 20" className="h-4 w-4 shrink-0" fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9 7a1 1 0 112 0v4a1 1 0 11-2 0V7zm1 8a1.25 1.25 0 100-2.5 1.25 1.25 0 000 2.5z"
clipRule="evenodd"
/>
</svg>
{error}
</p>
) : null}
<button
type="submit"
disabled={status === "submitting"}
aria-describedby={error ? errorId : undefined}
className="mt-7 flex w-full items-center justify-center gap-2 rounded-xl bg-slate-900 px-5 py-3 text-sm font-semibold text-white transition-all hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-70 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100 dark:focus-visible:ring-offset-slate-900"
>
{status === "submitting" ? (
<>
<svg viewBox="0 0 24 24" className="h-4 w-4 animate-spin" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity={0.25} strokeWidth={3} />
<path d="M21 12a9 9 0 00-9-9" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" />
</svg>
Sending…
</>
) : (
"Submit feedback"
)}
</button>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
No account needed. We never share what you write here.
</p>
</motion.form>
)}
</AnimatePresence>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Upload Dropzone Form
Originaldrag-and-drop file upload with preview

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

