Web InnoventixFreeCode

Scale Rating

Original · free

1-10 numeric scale rating

byWeb InnoventixReact + Tailwind
ratescaleratings
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/rate-scale.json
rate-scale.tsx
"use client";

import {
  useId,
  useRef,
  useState,
  type ChangeEvent,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

const SCALE_MIN = 1;
const SCALE_MAX = 10;
const SCORES: number[] = Array.from({ length: SCALE_MAX }, (_, i) => i + 1);
const MAX_COMMENT = 280;

type BandId = "low" | "mid" | "good" | "top";

interface Band {
  id: BandId;
  title: string;
  prompt: string;
  placeholder: string;
  chip: string;
  fill: string;
  text: string;
  softBg: string;
  softBorder: string;
}

const BANDS: Record<BandId, Band> = {
  low: {
    id: "low",
    title: "Not great",
    prompt: "We're sorry we let you down. What went wrong on this one?",
    placeholder: "Tell us what we could have handled better...",
    chip: "border-rose-500 bg-rose-500 text-white dark:border-rose-400 dark:bg-rose-500",
    fill: "from-rose-500 to-rose-400",
    text: "text-rose-600 dark:text-rose-400",
    softBg: "bg-rose-50 dark:bg-rose-950/40",
    softBorder: "border-rose-200 dark:border-rose-900/60",
  },
  mid: {
    id: "mid",
    title: "Could be better",
    prompt: "We can do better than this. What would have made it a great experience?",
    placeholder: "What would have improved things for you...",
    chip: "border-amber-500 bg-amber-500 text-white dark:border-amber-400 dark:bg-amber-500",
    fill: "from-amber-500 to-amber-400",
    text: "text-amber-600 dark:text-amber-400",
    softBg: "bg-amber-50 dark:bg-amber-950/40",
    softBorder: "border-amber-200 dark:border-amber-900/60",
  },
  good: {
    id: "good",
    title: "Pretty good",
    prompt: "Thanks — we're close. What would it take to earn a perfect score?",
    placeholder: "What would push this to a 10...",
    chip: "border-sky-500 bg-sky-500 text-white dark:border-sky-400 dark:bg-sky-500",
    fill: "from-sky-500 to-sky-400",
    text: "text-sky-600 dark:text-sky-400",
    softBg: "bg-sky-50 dark:bg-sky-950/40",
    softBorder: "border-sky-200 dark:border-sky-900/60",
  },
  top: {
    id: "top",
    title: "Excellent",
    prompt: "Wonderful, thank you! What stood out most about the experience?",
    placeholder: "What did you love most...",
    chip: "border-emerald-500 bg-emerald-500 text-white dark:border-emerald-400 dark:bg-emerald-500",
    fill: "from-emerald-500 to-emerald-400",
    text: "text-emerald-600 dark:text-emerald-400",
    softBg: "bg-emerald-50 dark:bg-emerald-950/40",
    softBorder: "border-emerald-200 dark:border-emerald-900/60",
  },
};

function getBand(score: number): Band {
  if (score <= 3) return BANDS.low;
  if (score <= 6) return BANDS.mid;
  if (score <= 8) return BANDS.good;
  return BANDS.top;
}

const KEYFRAMES = `
@keyframes ratescale-pop {
  0% { transform: scale(0.82); }
  55% { transform: scale(1.08); }
  100% { transform: scale(1); }
}
@keyframes ratescale-badge {
  0% { transform: scale(0.4); opacity: 0; }
  60% { transform: scale(1.12); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
.ratescale-pop { animation: ratescale-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1); }
.ratescale-badge { animation: ratescale-badge 420ms cubic-bezier(0.34, 1.56, 0.64, 1); }
@media (prefers-reduced-motion: reduce) {
  .ratescale-pop, .ratescale-badge { animation: none; }
}
`;

export default function RateScale() {
  const reduce = useReducedMotion();
  const titleId = useId();
  const hintId = useId();
  const promptId = useId();
  const commentId = useId();

  const [value, setValue] = useState<number | null>(null);
  const [hovered, setHovered] = useState<number | null>(null);
  const [comment, setComment] = useState("");
  const [submitted, setSubmitted] = useState(false);

  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const editBtnRef = useRef<HTMLButtonElement | null>(null);

  const displayScore = hovered ?? value ?? 0;
  const activeBand = displayScore > 0 ? getBand(displayScore) : null;
  const selectedBand = value !== null ? getBand(value) : null;
  const focusableScore = value ?? SCALE_MIN;

  function selectAndFocus(next: number) {
    setValue(next);
    optionRefs.current[next - 1]?.focus();
  }

  function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, score: number) {
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = Math.min(SCALE_MAX, score + 1);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = Math.max(SCALE_MIN, score - 1);
        break;
      case "Home":
        next = SCALE_MIN;
        break;
      case "End":
        next = SCALE_MAX;
        break;
      case " ":
      case "Enter":
        next = score;
        break;
      default:
        return;
    }
    e.preventDefault();
    selectAndFocus(next);
  }

  function handleComment(e: ChangeEvent<HTMLTextAreaElement>) {
    setComment(e.target.value.slice(0, MAX_COMMENT));
  }

  function handleSubmit(e: { preventDefault: () => void }) {
    e.preventDefault();
    if (value === null) return;
    setSubmitted(true);
  }

  function handleReset() {
    setSubmitted(false);
    requestAnimationFrame(() => {
      optionRefs.current[(value ?? SCALE_MIN) - 1]?.focus();
    });
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-zinc-950">
      <style>{KEYFRAMES}</style>

      <div className="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900">
        <div
          aria-hidden="true"
          className={`h-1 w-full bg-gradient-to-r transition-colors duration-300 ${
            activeBand
              ? activeBand.fill
              : "from-slate-200 to-slate-200 dark:from-zinc-800 dark:to-zinc-800"
          }`}
        />

        <div className="p-6 sm:p-8">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Feedback
          </p>
          <h2
            id={titleId}
            className="mt-2 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white"
          >
            How was your experience with our support team?
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-zinc-400">
            Rate it from 1 to 10. Your answer helps us tune response times and
            quality — it takes about ten seconds.
          </p>

          <AnimatePresence
            mode="wait"
            initial={false}
            onExitComplete={() => {
              if (submitted) {
                requestAnimationFrame(() => editBtnRef.current?.focus());
              }
            }}
          >
            {submitted && selectedBand && value !== null ? (
              <motion.div
                key="success"
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={reduce ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }}
                className="mt-8"
              >
                <div
                  className={`flex flex-col items-center rounded-xl border px-6 py-8 text-center ${selectedBand.softBg} ${selectedBand.softBorder}`}
                >
                  <span
                    className={`ratescale-badge flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br text-white ${selectedBand.fill}`}
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-7 w-7"
                      aria-hidden="true"
                    >
                      <path d="M20 6 9 17l-5-5" />
                    </svg>
                  </span>
                  <h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-white">
                    Thanks for the feedback
                  </h3>
                  <p className="mt-1 text-sm text-slate-600 dark:text-zinc-300">
                    You rated your support experience{" "}
                    <span className={`font-bold tabular-nums ${selectedBand.text}`}>
                      {value}/10
                    </span>{" "}
                    — {selectedBand.title.toLowerCase()}.
                  </p>
                  {comment.trim().length > 0 && (
                    <blockquote className="mt-4 max-w-sm rounded-lg border border-slate-200 bg-white/70 px-4 py-3 text-left text-sm italic text-slate-600 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
                      &ldquo;{comment.trim()}&rdquo;
                    </blockquote>
                  )}
                  <button
                    type="button"
                    ref={editBtnRef}
                    onClick={handleReset}
                    className="mt-6 inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold 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 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-4 w-4"
                      aria-hidden="true"
                    >
                      <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
                      <path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z" />
                    </svg>
                    Edit response
                  </button>
                </div>
              </motion.div>
            ) : (
              <motion.form
                key="form"
                onSubmit={handleSubmit}
                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={reduce ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }}
                className="mt-8"
              >
                <div className="flex items-end justify-between gap-4">
                  <div className="flex items-baseline gap-1.5">
                    <span
                      className={`text-4xl font-bold tabular-nums transition-colors ${
                        displayScore > 0 && activeBand
                          ? activeBand.text
                          : "text-slate-300 dark:text-zinc-600"
                      }`}
                    >
                      {displayScore > 0 ? displayScore : "—"}
                    </span>
                    <span className="text-lg font-medium text-slate-400 dark:text-zinc-500">
                      / 10
                    </span>
                  </div>
                  <span
                    className={`rounded-full px-3 py-1 text-xs font-semibold transition-colors ${
                      displayScore > 0 && activeBand
                        ? `${activeBand.softBg} ${activeBand.text}`
                        : "bg-slate-100 text-slate-400 dark:bg-zinc-800 dark:text-zinc-500"
                    }`}
                  >
                    {activeBand ? activeBand.title : "No rating yet"}
                  </span>
                </div>

                <div
                  role="radiogroup"
                  aria-labelledby={titleId}
                  aria-describedby={hintId}
                  className="mt-4 grid grid-cols-5 gap-2 sm:grid-cols-10"
                >
                  {SCORES.map((score) => {
                    const isSelected = value === score;
                    return (
                      <button
                        key={score}
                        type="button"
                        role="radio"
                        aria-checked={isSelected}
                        aria-label={`${score} out of 10`}
                        tabIndex={score === focusableScore ? 0 : -1}
                        ref={(el) => {
                          optionRefs.current[score - 1] = el;
                        }}
                        onClick={() => setValue(score)}
                        onKeyDown={(e) => handleKeyDown(e, score)}
                        onMouseEnter={() => setHovered(score)}
                        onMouseLeave={() => setHovered(null)}
                        onFocus={() => setHovered(score)}
                        onBlur={() => setHovered(null)}
                        className={`relative flex h-11 w-full items-center justify-center rounded-xl border text-sm font-semibold tabular-nums transition-colors select-none focus-visible: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-indigo-400 dark:focus-visible:ring-offset-zinc-900 ${
                          isSelected
                            ? `${getBand(score).chip} shadow-sm ratescale-pop`
                            : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-700/60"
                        }`}
                      >
                        {score}
                      </button>
                    );
                  })}
                </div>

                <span id={hintId} className="sr-only">
                  Use the left and right arrow keys to choose a rating from 1
                  (poor) to 10 (excellent), then press Enter to submit.
                </span>

                <div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-zinc-800">
                  <motion.div
                    className={`h-full rounded-full bg-gradient-to-r ${
                      activeBand ? activeBand.fill : "from-slate-200 to-slate-200"
                    }`}
                    initial={false}
                    animate={{ width: `${(displayScore / SCALE_MAX) * 100}%` }}
                    transition={
                      reduce
                        ? { duration: 0 }
                        : { type: "spring", stiffness: 260, damping: 32 }
                    }
                  />
                </div>

                <div className="mt-2 flex items-center justify-between text-xs font-medium text-slate-400 dark:text-zinc-500">
                  <span>1 — Poor</span>
                  <span>10 — Excellent</span>
                </div>

                <AnimatePresence initial={false}>
                  {selectedBand && (
                    <motion.div
                      key="followup"
                      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
                      animate={reduce ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
                      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
                      transition={reduce ? { duration: 0 } : { duration: 0.28, ease: "easeOut" }}
                      className="overflow-hidden"
                    >
                      <div className="pt-5">
                        <p
                          id={promptId}
                          aria-live="polite"
                          className={`text-sm font-medium ${selectedBand.text}`}
                        >
                          {selectedBand.prompt}
                        </p>
                        <label
                          htmlFor={commentId}
                          className="mt-3 block text-xs font-semibold text-slate-500 dark:text-zinc-400"
                        >
                          Add a comment (optional)
                        </label>
                        <textarea
                          id={commentId}
                          value={comment}
                          onChange={handleComment}
                          aria-describedby={promptId}
                          rows={3}
                          placeholder={selectedBand.placeholder}
                          className="mt-1.5 w-full resize-none rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-800 placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/30 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/30"
                        />
                        <div className="mt-1 text-right text-xs tabular-nums text-slate-400 dark:text-zinc-500">
                          {comment.length}/{MAX_COMMENT}
                        </div>
                      </div>
                    </motion.div>
                  )}
                </AnimatePresence>

                <div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-5 dark:border-zinc-800">
                  <span className="text-xs text-slate-400 dark:text-zinc-500">
                    Anonymous · never shared with third parties
                  </span>
                  <button
                    type="submit"
                    disabled={value === null}
                    className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 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 disabled:cursor-not-allowed disabled:opacity-40 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                  >
                    Send feedback
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      className="h-4 w-4"
                      aria-hidden="true"
                    >
                      <path d="M5 12h14" />
                      <path d="m12 5 7 7-7 7" />
                    </svg>
                  </button>
                </div>
              </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 quote

Similar components

Browse all →