Web InnoventixFreeCode

Interactive Rating

Original · free

hoverable star rating with label

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

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

type Rating = {
  value: number;
  label: string;
  blurb: string;
  tone: string;
  dot: string;
};

const RATINGS: Rating[] = [
  {
    value: 1,
    label: "Terrible",
    blurb: "That's below the standard we hold ourselves to — tell us what broke.",
    tone: "text-rose-600 dark:text-rose-400",
    dot: "bg-rose-500",
  },
  {
    value: 2,
    label: "Poor",
    blurb: "We missed the mark here and we'd like a second shot at getting it right.",
    tone: "text-rose-500 dark:text-rose-400",
    dot: "bg-rose-400",
  },
  {
    value: 3,
    label: "Okay",
    blurb: "Solid work, but there's real room to make the result exceptional.",
    tone: "text-amber-600 dark:text-amber-400",
    dot: "bg-amber-500",
  },
  {
    value: 4,
    label: "Great",
    blurb: "Glad it landed well — thanks for building this alongside us.",
    tone: "text-emerald-600 dark:text-emerald-400",
    dot: "bg-emerald-500",
  },
  {
    value: 5,
    label: "Excellent",
    blurb: "You made our week. A short public review would mean a lot to the team.",
    tone: "text-emerald-600 dark:text-emerald-400",
    dot: "bg-emerald-500",
  },
];

const STARS = [1, 2, 3, 4, 5];

function clampToRange(n: number): number {
  if (n > 5) return 1;
  if (n < 1) return 5;
  return n;
}

export default function RateInteractive() {
  const reduced = useReducedMotion();
  const [value, setValue] = useState(0);
  const [hover, setHover] = useState(0);
  const [focusIndex, setFocusIndex] = useState(1);
  const [submitted, setSubmitted] = useState(false);
  const starRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const groupId = useId();

  const active = hover || value;
  const current = active > 0 ? RATINGS[active - 1] : null;
  const selected = value > 0 ? RATINGS[value - 1] : null;

  function select(next: number) {
    setValue(next);
    setFocusIndex(next);
    setSubmitted(false);
  }

  function reset() {
    setValue(0);
    setHover(0);
    setFocusIndex(1);
    setSubmitted(false);
    starRefs.current[1]?.focus();
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    let next = focusIndex;
    switch (event.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = clampToRange(focusIndex + 1);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = clampToRange(focusIndex - 1);
        break;
      case "Home":
        next = 1;
        break;
      case "End":
        next = 5;
        break;
      case " ":
      case "Enter":
        event.preventDefault();
        select(focusIndex);
        return;
      default:
        return;
    }
    event.preventDefault();
    setFocusIndex(next);
    select(next);
    starRefs.current[next]?.focus();
  }

  return (
    <section className="relative w-full bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes rateint-pop {
          0% { transform: scale(0.6); opacity: 0; }
          60% { transform: scale(1.08); opacity: 1; }
          100% { transform: scale(1); opacity: 1; }
        }
        @keyframes rateint-rise {
          from { transform: translateY(6px); opacity: 0; }
          to { transform: translateY(0); opacity: 1; }
        }
        @keyframes rateint-glow {
          0%, 100% { opacity: 0.35; }
          50% { opacity: 0.7; }
        }
        .rateint-pop { animation: rateint-pop 320ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .rateint-rise { animation: rateint-rise 300ms ease-out both; }
        .rateint-glow { animation: rateint-glow 3.2s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .rateint-pop, .rateint-rise, .rateint-glow { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-lg">
        <div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white p-8 shadow-xl shadow-slate-900/5 sm:p-10 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
          <div
            aria-hidden="true"
            className="rateint-glow pointer-events-none absolute -right-16 -top-16 h-44 w-44 rounded-full bg-gradient-to-br from-indigo-400/30 to-violet-500/30 blur-3xl"
          />

          <div className="relative">
            <span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Project feedback
            </span>

            <h2 className="mt-4 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 rating shapes how we scope, build, and hand off every
              engagement. It takes about five seconds.
            </p>

            <div className="mt-8">
              <div
                role="radiogroup"
                aria-label="Rate your experience from 1 to 5 stars"
                aria-required="true"
                id={groupId}
                onKeyDown={handleKeyDown}
                onMouseLeave={() => setHover(0)}
                className="flex items-center gap-1 sm:gap-2"
              >
                {STARS.map((n) => {
                  const isFilled = n <= active;
                  const isChecked = value === n;
                  const isTabbable = focusIndex === n;
                  return (
                    <button
                      key={n}
                      type="button"
                      role="radio"
                      aria-checked={isChecked}
                      aria-label={`${n} star${n > 1 ? "s" : ""} — ${RATINGS[n - 1].label}`}
                      tabIndex={isTabbable ? 0 : -1}
                      ref={(el) => {
                        starRefs.current[n] = el;
                      }}
                      onClick={() => select(n)}
                      onMouseEnter={() => setHover(n)}
                      onFocus={() => setFocusIndex(n)}
                      className="group rounded-lg p-1 outline-none transition-transform 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"
                    >
                      <motion.span
                        className="block"
                        animate={
                          reduced
                            ? undefined
                            : { scale: isFilled && active === n ? 1.18 : isFilled ? 1.05 : 1 }
                        }
                        transition={{ type: "spring", stiffness: 420, damping: 18 }}
                      >
                        <svg
                          viewBox="0 0 24 24"
                          className={`h-9 w-9 transition-colors duration-150 sm:h-11 sm:w-11 ${
                            isFilled
                              ? "fill-amber-400 text-amber-400 drop-shadow-sm"
                              : "fill-transparent text-slate-300 group-hover:text-amber-300 dark:text-slate-600"
                          }`}
                          stroke="currentColor"
                          strokeWidth={1.5}
                          strokeLinejoin="round"
                          aria-hidden="true"
                        >
                          <path d="M12 2.75l2.72 5.51 6.08.88-4.4 4.29 1.04 6.05L12 16.9l-5.44 2.86 1.04-6.05-4.4-4.29 6.08-.88z" />
                        </svg>
                      </motion.span>
                    </button>
                  );
                })}
              </div>

              <div className="mt-5 min-h-[3.75rem]">
                <AnimatePresence mode="wait" initial={false}>
                  {current ? (
                    <motion.div
                      key={current.value}
                      initial={reduced ? false : { opacity: 0, y: 6 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
                      transition={{ duration: 0.18 }}
                    >
                      <div className="flex items-center gap-2">
                        <span className={`text-lg font-semibold ${current.tone}`}>
                          {current.label}
                        </span>
                        <span className="text-sm font-medium text-slate-400 dark:text-slate-500">
                          {current.value} / 5
                        </span>
                        {hover > 0 && hover !== value ? (
                          <span className="ml-1 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
                            preview
                          </span>
                        ) : null}
                      </div>
                      <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                        {current.blurb}
                      </p>
                    </motion.div>
                  ) : (
                    <motion.p
                      key="placeholder"
                      initial={reduced ? false : { opacity: 0 }}
                      animate={{ opacity: 1 }}
                      exit={{ opacity: 0 }}
                      transition={{ duration: 0.18 }}
                      className="text-sm text-slate-500 dark:text-slate-400"
                    >
                      Hover the stars to preview, then click to lock in your rating.
                      Arrow keys work too.
                    </motion.p>
                  )}
                </AnimatePresence>
              </div>

              <div className="mt-6 flex flex-wrap items-center gap-3">
                <button
                  type="button"
                  disabled={value === 0 || submitted}
                  onClick={() => setSubmitted(true)}
                  className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors outline-none hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-600"
                >
                  {submitted ? "Rating submitted" : "Submit rating"}
                </button>

                {value > 0 ? (
                  <button
                    type="button"
                    onClick={reset}
                    className="inline-flex items-center rounded-xl px-3 py-2.5 text-sm font-medium text-slate-600 underline-offset-4 transition-colors outline-none hover:text-slate-900 hover:underline focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
                  >
                    Reset
                  </button>
                ) : null}
              </div>

              <AnimatePresence initial={false}>
                {submitted && selected ? (
                  <motion.div
                    key="confirm"
                    initial={reduced ? false : { opacity: 0, height: 0 }}
                    animate={{ opacity: 1, height: "auto" }}
                    exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
                    transition={{ duration: 0.24 }}
                    className="overflow-hidden"
                  >
                    <div className="rateint-rise mt-5 flex items-start gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-500/30 dark:bg-emerald-500/10">
                      <span className="rateint-pop mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
                        <svg
                          viewBox="0 0 20 20"
                          className="h-3.5 w-3.5"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth={2.5}
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          aria-hidden="true"
                        >
                          <path d="M4 10.5l3.5 3.5L16 5.5" />
                        </svg>
                      </span>
                      <div>
                        <p className="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
                          Thanks — your {selected.value}-star rating is in.
                        </p>
                        <p className="mt-0.5 text-sm text-emerald-700/80 dark:text-emerald-300/70">
                          {selected.value >= 4
                            ? "We'd love a public review — check your inbox for a link."
                            : "A project lead will reach out to make this right."}
                        </p>
                      </div>
                    </div>
                  </motion.div>
                ) : null}
              </AnimatePresence>
            </div>

            <div
              aria-live="polite"
              className="sr-only"
            >
              {selected
                ? `Selected ${selected.value} of 5 stars, ${selected.label}.`
                : "No rating selected."}
            </div>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-500 dark:text-slate-500">
          Based on 214 completed engagements this year.
        </p>
      </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 →