Web InnoventixFreeCode

Stars Rating

Original · free

interactive star rating

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

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

type RatingMeta = {
  label: string;
  blurb: string;
};

const RATINGS: readonly RatingMeta[] = [
  { label: "Terrible", blurb: "Missed the mark completely." },
  { label: "Poor", blurb: "A few things worked, most didn't." },
  { label: "Average", blurb: "Did the job, nothing memorable." },
  { label: "Great", blurb: "Solid work, I'd come back." },
  { label: "Exceptional", blurb: "Genuinely delighted — worth telling others." },
];

type Bucket = { stars: number; pct: number; count: number };

const DISTRIBUTION: readonly Bucket[] = [
  { stars: 5, pct: 61, count: 783 },
  { stars: 4, pct: 24, count: 308 },
  { stars: 3, pct: 9, count: 116 },
  { stars: 2, pct: 4, count: 51 },
  { stars: 1, pct: 2, count: 26 },
];

const TOTAL_REVIEWS = 1284;
const AVERAGE = 4.6;

function StarShape({ filled }: { filled: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden="true"
      className="h-9 w-9 sm:h-11 sm:w-11"
    >
      <path
        d="M12 2.4l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.22l-5.81 3.05 1.11-6.46-4.7-4.58 6.49-.95z"
        className={
          filled
            ? "fill-amber-400 stroke-amber-500 dark:fill-amber-400 dark:stroke-amber-300"
            : "fill-transparent stroke-slate-300 dark:stroke-slate-600"
        }
        strokeWidth={1.5}
        strokeLinejoin="round"
      />
    </svg>
  );
}

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

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

  function focusStar(next: number) {
    const clamped = Math.min(5, Math.max(1, next));
    setValue(clamped);
    setSubmitted(false);
    const el = starRefs.current[clamped - 1];
    if (el) el.focus();
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const base = value || 0;
    switch (event.key) {
      case "ArrowRight":
      case "ArrowUp":
        event.preventDefault();
        focusStar(base + 1);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        event.preventDefault();
        focusStar(base === 0 ? 1 : base - 1);
        break;
      case "Home":
        event.preventDefault();
        focusStar(1);
        break;
      case "End":
        event.preventDefault();
        focusStar(5);
        break;
      default:
        if (/^[1-5]$/.test(event.key)) {
          event.preventDefault();
          focusStar(Number(event.key));
        }
    }
  }

  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 ratestars-pop {
          0% { transform: scale(1); }
          40% { transform: scale(1.28) rotate(-6deg); }
          70% { transform: scale(0.94); }
          100% { transform: scale(1); }
        }
        @keyframes ratestars-sheen {
          0% { transform: translateX(-120%); opacity: 0; }
          40% { opacity: 0.9; }
          100% { transform: translateX(220%); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .ratestars-pop, .ratestars-sheen { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto grid w-full max-w-4xl gap-10 rounded-3xl border border-slate-200 bg-white p-7 shadow-xl shadow-slate-200/50 sm:p-10 md:grid-cols-[1.15fr_1fr] dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
        {/* Interactive rating */}
        <div className="flex flex-col">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Your review
          </p>
          <h2 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
            How was the Northline dev tools onboarding?
          </h2>
          <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
            Tap a star or use the arrow keys. Your feedback goes straight to the
            product team.
          </p>

          <div
            role="radiogroup"
            aria-label="Rate your onboarding experience from 1 to 5 stars"
            aria-describedby={`${groupId}-hint`}
            onKeyDown={onKeyDown}
            onMouseLeave={() => setHover(0)}
            className="mt-6 flex items-center gap-1.5"
          >
            {RATINGS.map((meta, index) => {
              const starNumber = index + 1;
              const isFilled = display >= starNumber;
              const isChecked = value === starNumber;
              const justSet = !reduced && value === starNumber && hover === 0;
              return (
                <button
                  key={meta.label}
                  ref={(node) => {
                    starRefs.current[index] = node;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={isChecked}
                  aria-label={`${starNumber} star${starNumber > 1 ? "s" : ""} — ${meta.label}`}
                  tabIndex={value === 0 ? (index === 0 ? 0 : -1) : isChecked ? 0 : -1}
                  onClick={() => {
                    setValue(starNumber);
                    setSubmitted(false);
                  }}
                  onMouseEnter={() => setHover(starNumber)}
                  onFocus={() => setHover(starNumber)}
                  onBlur={() => setHover(0)}
                  className="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-indigo-400 dark:focus-visible:ring-offset-slate-900"
                >
                  <span
                    className="ratestars-pop block"
                    style={
                      justSet
                        ? { animation: "ratestars-pop 480ms ease-out" }
                        : undefined
                    }
                  >
                    <StarShape filled={isFilled} />
                  </span>
                </button>
              );
            })}
          </div>

          <div
            id={`${groupId}-hint`}
            aria-live="polite"
            className="mt-4 min-h-[2.75rem]"
          >
            {active ? (
              <motion.div
                key={active.label}
                initial={reduced ? false : { opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                transition={{ duration: 0.25 }}
                className="flex flex-wrap items-baseline gap-x-2"
              >
                <span className="text-lg font-semibold text-amber-600 dark:text-amber-400">
                  {display}.0 · {active.label}
                </span>
                <span className="text-sm text-slate-500 dark:text-slate-400">
                  {active.blurb}
                </span>
              </motion.div>
            ) : (
              <span className="text-sm text-slate-400 dark:text-slate-500">
                No rating yet — pick a star to begin.
              </span>
            )}
          </div>

          <div className="mt-6 flex flex-wrap items-center gap-3">
            <button
              type="button"
              disabled={value === 0 || submitted}
              onClick={() => setSubmitted(true)}
              className="relative overflow-hidden rounded-full bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white 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:bg-slate-300 disabled:text-slate-500 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
            >
              {submitted ? "Rating submitted" : "Submit rating"}
              {!reduced && value > 0 && !submitted && (
                <span
                  aria-hidden="true"
                  className="ratestars-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/40 to-transparent"
                  style={{ animation: "ratestars-sheen 1.6s ease-in-out infinite" }}
                />
              )}
            </button>
            {value > 0 && (
              <button
                type="button"
                onClick={() => {
                  setValue(0);
                  setHover(0);
                  setSubmitted(false);
                }}
                className="rounded-full px-3 py-2 text-sm font-medium text-slate-500 underline-offset-4 hover:text-slate-800 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-400 dark:hover:text-slate-100"
              >
                Clear
              </button>
            )}
          </div>

          <div className="mt-3 min-h-[1.25rem]" aria-live="assertive">
            {submitted && (
              <p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
                Thanks — your {value}-star rating is on its way to the team.
              </p>
            )}
          </div>
        </div>

        {/* Community distribution */}
        <div className="rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-950/40">
          <div className="flex items-end gap-3">
            <span className="text-4xl font-bold tabular-nums">
              {AVERAGE.toFixed(1)}
            </span>
            <div className="pb-1">
              <div className="flex" aria-hidden="true">
                {[1, 2, 3, 4, 5].map((n) => (
                  <StarShapeSmall key={n} filled={n <= Math.round(AVERAGE)} />
                ))}
              </div>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                {TOTAL_REVIEWS.toLocaleString("en-US")} verified reviews
              </p>
            </div>
          </div>

          <ul className="mt-6 space-y-2.5">
            {DISTRIBUTION.map((bucket) => (
              <li key={bucket.stars} className="flex items-center gap-3">
                <span className="w-10 shrink-0 text-xs font-medium tabular-nums text-slate-500 dark:text-slate-400">
                  {bucket.stars} star
                </span>
                <div className="h-2 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
                  <motion.div
                    className="h-full rounded-full bg-amber-400"
                    initial={reduced ? false : { width: 0 }}
                    whileInView={{ width: `${bucket.pct}%` }}
                    viewport={{ once: true, margin: "-40px" }}
                    transition={{ duration: 0.7, ease: "easeOut" }}
                    style={reduced ? { width: `${bucket.pct}%` } : undefined}
                  />
                </div>
                <span className="w-9 shrink-0 text-right text-xs tabular-nums text-slate-400 dark:text-slate-500">
                  {bucket.pct}%
                </span>
              </li>
            ))}
          </ul>

          <p className="mt-6 border-t border-slate-200 pt-4 text-xs leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-400">
            Ratings are collected after the first completed setup and refreshed
            weekly. Only teams that shipped a live integration can leave one.
          </p>
        </div>
      </div>
    </section>
  );
}

function StarShapeSmall({ filled }: { filled: boolean }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
      <path
        d="M12 2.4l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.22l-5.81 3.05 1.11-6.46-4.7-4.58 6.49-.95z"
        className={
          filled
            ? "fill-amber-400 stroke-amber-500 dark:stroke-amber-300"
            : "fill-transparent stroke-slate-300 dark:stroke-slate-600"
        }
        strokeWidth={1.5}
        strokeLinejoin="round"
      />
    </svg>
  );
}

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 →