Survey Form
Original · freeshort survey with radios and scale
byWeb InnoventixReact + Tailwind
formsurveyforms
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-survey.jsonform-survey.tsx
"use client";
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Option = { value: string; label: string; hint?: string };
const ROLE_OPTIONS: readonly Option[] = [
{ value: "engineer", label: "Engineer", hint: "Frontend, backend, or infra" },
{ value: "designer", label: "Designer", hint: "Product, brand, or UX" },
{ value: "pm", label: "Product manager", hint: "Roadmap, specs, discovery" },
{ value: "founder", label: "Founder or exec", hint: "Running the show" },
{ value: "other", label: "Something else" },
];
const SETUP_OPTIONS: readonly Option[] = [
{ value: "confusing", label: "Confusing" },
{ value: "rough", label: "Rough" },
{ value: "okay", label: "Okay" },
{ value: "smooth", label: "Smooth" },
{ value: "effortless", label: "Effortless" },
];
const SCALE: readonly number[] = Array.from({ length: 11 }, (_, i) => i);
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" className="h-8 w-8" fill="none" stroke="currentColor" strokeWidth={2.4} aria-hidden="true">
<path d="M4.5 12.5l5 5 10-11" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function WarnIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path d="M12 8.5v4.5m0 3h.01M10.3 3.9L2.6 17.2a2 2 0 001.7 3h15.4a2 2 0 001.7-3L13.7 3.9a2 2 0 00-3.4 0z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ChoiceRow({
name,
option,
checked,
onSelect,
}: {
name: string;
option: Option;
checked: boolean;
onSelect: (value: string) => void;
}) {
return (
<label className="relative block cursor-pointer">
<input
type="radio"
name={name}
value={option.value}
checked={checked}
onChange={() => onSelect(option.value)}
className="peer sr-only"
/>
<span
className={[
"flex items-center gap-3 rounded-xl border px-4 py-3 transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900",
checked
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-800/40 dark:hover:border-zinc-600 dark:hover:bg-zinc-800",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
checked ? "border-indigo-500 dark:border-indigo-400" : "border-slate-300 dark:border-zinc-600",
].join(" ")}
>
<span
className={[
"h-2.5 w-2.5 rounded-full bg-indigo-500 transition-transform duration-200 dark:bg-indigo-400",
checked ? "scale-100" : "scale-0",
].join(" ")}
/>
</span>
<span className="min-w-0">
<span
className={[
"block text-sm font-medium",
checked ? "text-indigo-900 dark:text-indigo-100" : "text-slate-800 dark:text-zinc-100",
].join(" ")}
>
{option.label}
</span>
{option.hint ? <span className="block text-xs text-slate-500 dark:text-zinc-400">{option.hint}</span> : null}
</span>
</span>
</label>
);
}
function ScaleCell({
name,
value,
checked,
onSelect,
}: {
name: string;
value: number;
checked: boolean;
onSelect: (value: number) => void;
}) {
return (
<label className="relative block cursor-pointer">
<input
type="radio"
name={name}
value={value}
checked={checked}
onChange={() => onSelect(value)}
aria-label={`${value} out of 10`}
className="peer sr-only"
/>
<span
className={[
"flex h-10 w-full items-center justify-center rounded-lg border text-sm font-semibold tabular-nums transition-colors sm:h-11",
"peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900",
checked
? "border-indigo-600 bg-indigo-600 text-white shadow-sm dark:border-indigo-400 dark:bg-indigo-500"
: "border-slate-200 bg-white text-slate-600 hover:border-indigo-300 hover:text-indigo-700 dark:border-zinc-700 dark:bg-zinc-800/40 dark:text-zinc-300 dark:hover:border-indigo-500/60 dark:hover:text-indigo-200",
].join(" ")}
>
{value}
</span>
</label>
);
}
export default function FormSurvey() {
const reduce = useReducedMotion();
const uid = useId();
const [nps, setNps] = useState<number | null>(null);
const [role, setRole] = useState<string | null>(null);
const [setup, setSetup] = useState<string | null>(null);
const [showErrors, setShowErrors] = useState(false);
const [submitted, setSubmitted] = useState(false);
const groupRefs = useRef<Record<string, HTMLFieldSetElement | null>>({});
const total = 3;
const answered = [nps !== null, role !== null, setup !== null].filter(Boolean).length;
const progress = answered / total;
const npsInvalid = showErrors && nps === null;
const roleInvalid = showErrors && role === null;
const setupInvalid = showErrors && setup === null;
const roleLabel = ROLE_OPTIONS.find((o) => o.value === role)?.label ?? "";
const setupLabel = SETUP_OPTIONS.find((o) => o.value === setup)?.label ?? "";
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const missing = nps === null ? "nps" : role === null ? "role" : setup === null ? "setup" : null;
if (missing) {
setShowErrors(true);
const el = groupRefs.current[missing];
if (el) {
el.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "center" });
el.querySelector<HTMLInputElement>("input")?.focus();
}
return;
}
setSubmitted(true);
}
const enter = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: { initial: { opacity: 0, y: 14 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -14 } };
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-20 dark:from-zinc-950 dark:to-zinc-950 sm:py-28">
<style>{`
@keyframes fsv-pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes fsv-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(8px, -16px, 0); }
}
.fsv-pop { animation: fsv-pop 560ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.fsv-float { animation: fsv-float 11s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.fsv-pop, .fsv-float { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="fsv-float pointer-events-none absolute -left-24 top-8 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/10"
/>
<div
aria-hidden="true"
style={{ animationDelay: "-5s" }}
className="fsv-float pointer-events-none absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/10"
/>
<div className="relative mx-auto w-full max-w-2xl rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-sm dark:border-zinc-800 dark:bg-zinc-900/70 dark:shadow-black/20 sm:p-10">
<p aria-live="polite" className="sr-only">
{answered} of {total} questions answered.
</p>
<AnimatePresence mode="wait" initial={false}>
{submitted ? (
<motion.div
key="done"
initial={enter.initial}
animate={enter.animate}
exit={enter.exit}
transition={{ duration: reduce ? 0 : 0.35, ease: "easeOut" }}
className="py-2 text-center"
>
<span className="fsv-pop mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<CheckIcon />
</span>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">
Thanks — that’s genuinely useful.
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600 dark:text-zinc-400">
Your answers just landed in the product channel. We read every single one, and the four of us actually
talk them through on Fridays.
</p>
<dl className="mx-auto mt-8 grid max-w-sm gap-2 text-left">
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-800/30">
<dt className="text-sm text-slate-500 dark:text-zinc-400">Recommendation</dt>
<dd className="text-sm font-semibold tabular-nums text-slate-900 dark:text-white">{nps} / 10</dd>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-800/30">
<dt className="text-sm text-slate-500 dark:text-zinc-400">Your role</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">{roleLabel}</dd>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-800/30">
<dt className="text-sm text-slate-500 dark:text-zinc-400">Setup felt</dt>
<dd className="text-sm font-semibold text-slate-900 dark:text-white">{setupLabel}</dd>
</div>
</dl>
<button
type="button"
onClick={() => setSubmitted(false)}
className="mt-8 inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 transition-colors 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-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-900"
>
Edit my answers
</button>
</motion.div>
) : (
<motion.div
key="form"
initial={enter.initial}
animate={enter.animate}
exit={enter.exit}
transition={{ duration: reduce ? 0 : 0.35, ease: "easeOut" }}
>
<header className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Takes about 2 minutes · Anonymous
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
Tell us how the beta is treating you
</h2>
<p className="mt-2 text-sm text-slate-600 dark:text-zinc-400 sm:text-base">
Three quick questions, no wrong answers. This goes straight to the people building the product — not a
mailing list.
</p>
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs font-medium text-slate-500 dark:text-zinc-400">
<span>
{answered} of {total} answered
</span>
<span className="tabular-nums">{Math.round(progress * 100)}%</span>
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={answered}
aria-label="Survey progress"
className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-zinc-800"
>
<motion.div
className="h-full w-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
style={{ transformOrigin: "left center" }}
initial={false}
animate={{ scaleX: progress }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 180, damping: 26 }}
/>
</div>
</div>
</header>
<form onSubmit={handleSubmit} noValidate className="space-y-5">
{/* Question 1 — scale */}
<fieldset
ref={(el) => {
groupRefs.current.nps = el;
}}
aria-invalid={npsInvalid || undefined}
aria-describedby={npsInvalid ? `${uid}-nps-err` : undefined}
className={[
"rounded-2xl border p-5 transition-colors sm:p-6",
npsInvalid
? "border-rose-300 bg-rose-50/40 dark:border-rose-500/40 dark:bg-rose-500/5"
: "border-slate-200 bg-slate-50/50 dark:border-zinc-800 dark:bg-zinc-800/20",
].join(" ")}
>
<legend className="mb-4 w-full">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
Question 1 · Recommendation
</span>
<span className="block text-lg font-semibold text-slate-900 dark:text-white">
How likely are you to recommend us to a teammate?
</span>
</legend>
<div className="grid grid-cols-11 gap-1 sm:gap-1.5">
{SCALE.map((n) => (
<ScaleCell key={n} name={`${uid}-nps`} value={n} checked={nps === n} onSelect={setNps} />
))}
</div>
<div className="mt-2 flex justify-between text-xs font-medium text-slate-500 dark:text-zinc-400">
<span>0 · Not likely at all</span>
<span>Extremely likely · 10</span>
</div>
{npsInvalid ? (
<p id={`${uid}-nps-err`} className="mt-3 flex items-center gap-1.5 text-sm text-rose-600 dark:text-rose-400">
<WarnIcon />
Pick a number from 0 to 10 to continue.
</p>
) : null}
</fieldset>
{/* Question 2 — radios */}
<fieldset
ref={(el) => {
groupRefs.current.role = el;
}}
aria-invalid={roleInvalid || undefined}
aria-describedby={roleInvalid ? `${uid}-role-err` : undefined}
className={[
"rounded-2xl border p-5 transition-colors sm:p-6",
roleInvalid
? "border-rose-300 bg-rose-50/40 dark:border-rose-500/40 dark:bg-rose-500/5"
: "border-slate-200 bg-slate-50/50 dark:border-zinc-800 dark:bg-zinc-800/20",
].join(" ")}
>
<legend className="mb-4 w-full">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
Question 2 · About you
</span>
<span className="block text-lg font-semibold text-slate-900 dark:text-white">
Which of these sounds most like you?
</span>
</legend>
<div className="grid gap-2.5 sm:grid-cols-2">
{ROLE_OPTIONS.map((o) => (
<ChoiceRow key={o.value} name={`${uid}-role`} option={o} checked={role === o.value} onSelect={setRole} />
))}
</div>
{roleInvalid ? (
<p id={`${uid}-role-err`} className="mt-3 flex items-center gap-1.5 text-sm text-rose-600 dark:text-rose-400">
<WarnIcon />
Choose the option that fits best.
</p>
) : null}
</fieldset>
{/* Question 3 — likert scale */}
<fieldset
ref={(el) => {
groupRefs.current.setup = el;
}}
aria-invalid={setupInvalid || undefined}
aria-describedby={setupInvalid ? `${uid}-setup-err` : undefined}
className={[
"rounded-2xl border p-5 transition-colors sm:p-6",
setupInvalid
? "border-rose-300 bg-rose-50/40 dark:border-rose-500/40 dark:bg-rose-500/5"
: "border-slate-200 bg-slate-50/50 dark:border-zinc-800 dark:bg-zinc-800/20",
].join(" ")}
>
<legend className="mb-4 w-full">
<span className="mb-1 block text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
Question 3 · Onboarding
</span>
<span className="block text-lg font-semibold text-slate-900 dark:text-white">
How did setting up your workspace feel?
</span>
</legend>
<div className="grid grid-cols-5 gap-1.5">
{SETUP_OPTIONS.map((o, i) => (
<label key={o.value} className="relative block cursor-pointer">
<input
type="radio"
name={`${uid}-setup`}
value={o.value}
checked={setup === o.value}
onChange={() => setSetup(o.value)}
aria-label={`${i + 1} — ${o.label}`}
className="peer sr-only"
/>
<span
className={[
"flex flex-col items-center gap-1.5 rounded-xl border px-1 py-3 text-center transition-colors",
"peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-zinc-900",
setup === o.value
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-200 bg-white hover:border-slate-300 dark:border-zinc-700 dark:bg-zinc-800/40 dark:hover:border-zinc-600",
].join(" ")}
>
<span
aria-hidden="true"
className={[
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold tabular-nums transition-colors",
setup === o.value
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-100 text-slate-500 dark:bg-zinc-700 dark:text-zinc-300",
].join(" ")}
>
{i + 1}
</span>
<span
className={[
"text-xs font-medium leading-tight",
setup === o.value ? "text-indigo-900 dark:text-indigo-100" : "text-slate-600 dark:text-zinc-300",
].join(" ")}
>
{o.label}
</span>
</span>
</label>
))}
</div>
{setupInvalid ? (
<p id={`${uid}-setup-err`} className="mt-3 flex items-center gap-1.5 text-sm text-rose-600 dark:text-rose-400">
<WarnIcon />
Let us know how setup went.
</p>
) : null}
</fieldset>
{showErrors && answered < total ? (
<p
role="alert"
className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
>
<WarnIcon />
Please answer all three questions before submitting.
</p>
) : null}
<div className="flex flex-col gap-3 pt-1 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-500 dark:text-zinc-400">
You can change any answer before you send.
</p>
<button
type="submit"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
Send my feedback
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path d="M5 12h14m-6-6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</form>
</motion.div>
)}
</AnimatePresence>
</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

Feedback Form
Originalfeedback form with emoji rating

Booking Form
Originalbooking form with date and slots

