Web InnoventixFreeCode

Survey Form

Original · free

short survey with radios and scale

byWeb InnoventixReact + Tailwind
formsurveyforms
Open

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.json
form-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&rsquo;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 &middot; 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 &middot; 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 &middot; Not likely at all</span>
                    <span>Extremely likely &middot; 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 &middot; 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 &middot; 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 quote

Similar components

Browse all →