Web InnoventixFreeCode

Thumbs Rating

Original · free

thumbs up/down rating

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

import { useId, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Vote = "up" | "down" | null;

const REASONS: Record<"up" | "down", string[]> = {
  up: ["Clear and easy to follow", "Solved my problem", "Great code examples", "Well organized"],
  down: ["Hard to follow", "Missing key details", "Inaccurate or outdated", "Not what I needed", "Too much jargon"],
};

const PROMPT: Record<"up" | "down", string> = {
  up: "Nice — what worked well for you?",
  down: "Sorry about that. What could be better?",
};

const BASE_UP = 1284;
const BASE_DOWN = 96;

function ThumbUpIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M7 10v11" />
      <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
    </svg>
  );
}

function ThumbDownIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M17 14V3" />
      <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
    </svg>
  );
}

export default function RateThumbs() {
  const reduce = useReducedMotion();
  const uid = useId();
  const commentId = `${uid}-comment`;
  const questionId = `${uid}-question`;

  const [vote, setVote] = useState<Vote>(null);
  const [reasons, setReasons] = useState<string[]>([]);
  const [comment, setComment] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [pulseKey, setPulseKey] = useState(0);

  const up = BASE_UP + (vote === "up" ? 1 : 0);
  const down = BASE_DOWN + (vote === "down" ? 1 : 0);
  const total = up + down;
  const pct = Math.round((up / total) * 100);
  const totalLabel = useMemo(() => total.toLocaleString("en-US"), [total]);

  function choose(next: "up" | "down") {
    if (submitted) return;
    if (vote !== next) {
      setReasons([]);
      setComment("");
    }
    setVote(next);
    setPulseKey((k) => k + 1);
  }

  function toggleReason(r: string) {
    setReasons((prev) => (prev.includes(r) ? prev.filter((x) => x !== r) : [...prev, r]));
  }

  function reset() {
    setVote(null);
    setReasons([]);
    setComment("");
    setSubmitted(false);
  }

  const ringBase =
    "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-offset-slate-900";

  const activeVote = vote === "up" || vote === "down" ? vote : null;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes rtthmb-pop {
          0% { transform: scale(1) rotate(0deg); }
          38% { transform: scale(1.32) rotate(-8deg); }
          68% { transform: scale(0.9) rotate(3deg); }
          100% { transform: scale(1) rotate(0deg); }
        }
        @keyframes rtthmb-ring {
          0% { transform: scale(0.55); opacity: 0.5; }
          100% { transform: scale(2); opacity: 0; }
        }
        @keyframes rtthmb-drift {
          0% { transform: translateY(0) scale(0.9); opacity: 0; }
          22% { opacity: 0.8; }
          100% { transform: translateY(-34px) scale(1.05); opacity: 0; }
        }
        .rtthmb-pop { animation: rtthmb-pop 480ms cubic-bezier(.34,1.56,.64,1); }
        .rtthmb-ring { animation: rtthmb-ring 640ms ease-out forwards; }
        .rtthmb-drift { animation: rtthmb-drift 900ms ease-out forwards; }
        @media (prefers-reduced-motion: reduce) {
          .rtthmb-pop, .rtthmb-ring, .rtthmb-drift { animation: none !important; }
        }
      `}</style>

      <div className="pointer-events-none absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-indigo-500/10 to-transparent dark:from-indigo-500/10" />

      <div className="relative mx-auto max-w-xl">
        <div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
          <div className="h-1 w-full bg-gradient-to-r from-indigo-500 via-violet-500 to-sky-500" />

          <div className="p-6 sm:p-8">
            {/* Header */}
            <div className="flex items-center gap-2.5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
                  <path d="M4 4v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8.5L15.5 2H6a2 2 0 0 0-2 2Z" />
                  <path d="M14 2v6h6" />
                  <path d="M8 13h8" />
                  <path d="M8 17h5" />
                </svg>
              </span>
              <span>Docs feedback</span>
              <span className="text-slate-300 dark:text-slate-600">/</span>
              <span className="font-medium normal-case tracking-normal text-slate-400 dark:text-slate-500">Guides · Authentication</span>
            </div>

            <h2 id={questionId} className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-[1.6rem] dark:text-white">
              Was this page helpful?
            </h2>
            <p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400">
              Your rating tunes which guides we rewrite next. Takes one tap.
            </p>

            {/* Thumb buttons */}
            <div role="group" aria-labelledby={questionId} className="mt-6 grid grid-cols-2 gap-3">
              {(["up", "down"] as const).map((dir) => {
                const selected = vote === dir;
                const isUp = dir === "up";
                const accentSelected = isUp
                  ? "border-emerald-500 bg-emerald-50 text-emerald-700 dark:border-emerald-400/70 dark:bg-emerald-500/10 dark:text-emerald-300"
                  : "border-rose-500 bg-rose-50 text-rose-700 dark:border-rose-400/70 dark:bg-rose-500/10 dark:text-rose-300";
                const accentIdle =
                  "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800";
                const dimmed = activeVote && !selected ? "opacity-55" : "";
                return (
                  <button
                    key={dir}
                    type="button"
                    onClick={() => choose(dir)}
                    aria-pressed={selected}
                    aria-label={isUp ? "Yes, this page was helpful" : "No, this page was not helpful"}
                    disabled={submitted}
                    className={`group relative flex flex-col items-center gap-2 rounded-2xl border-2 px-4 py-5 text-sm font-semibold transition-all duration-200 disabled:cursor-default ${selected ? accentSelected : accentIdle} ${dimmed} ${ringBase}`}
                  >
                    <span className="relative flex h-11 w-11 items-center justify-center">
                      {selected && !reduce && (
                        <span
                          key={pulseKey}
                          aria-hidden="true"
                          className={`rtthmb-ring absolute inset-0 rounded-full ${isUp ? "bg-emerald-400/40" : "bg-rose-400/40"}`}
                        />
                      )}
                      <span key={`${pulseKey}-${dir}-${selected}`} className={selected ? "rtthmb-pop relative" : "relative"}>
                        {isUp ? <ThumbUpIcon className="h-7 w-7" /> : <ThumbDownIcon className="h-7 w-7" />}
                      </span>
                    </span>
                    <span>{isUp ? "Helpful" : "Not helpful"}</span>
                  </button>
                );
              })}
            </div>

            {/* Follow-up */}
            <AnimatePresence initial={false} mode="wait">
              {activeVote && !submitted && (
                <motion.div
                  key={activeVote}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
                  animate={reduce ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
                  transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 280, damping: 30 }}
                  className="overflow-hidden"
                >
                  <div className="mt-6 border-t border-slate-100 pt-5 dark:border-slate-800">
                    <p className="text-sm font-medium text-slate-700 dark:text-slate-200">{PROMPT[activeVote]}</p>

                    <div role="group" aria-label="Reasons" className="mt-3 flex flex-wrap gap-2">
                      {REASONS[activeVote].map((r) => {
                        const on = reasons.includes(r);
                        return (
                          <button
                            key={r}
                            type="button"
                            onClick={() => toggleReason(r)}
                            aria-pressed={on}
                            className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
                              on
                                ? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-500"
                                : "border-slate-200 bg-white text-slate-600 hover:border-indigo-300 hover:text-indigo-600 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-indigo-400/60 dark:hover:text-indigo-300"
                            } ${ringBase}`}
                          >
                            {r}
                          </button>
                        );
                      })}
                    </div>

                    <div className="mt-4">
                      <label htmlFor={commentId} className="mb-1.5 block text-xs font-medium text-slate-500 dark:text-slate-400">
                        Anything else? <span className="font-normal text-slate-400 dark:text-slate-500">(optional)</span>
                      </label>
                      <textarea
                        id={commentId}
                        value={comment}
                        onChange={(e) => setComment(e.target.value)}
                        rows={3}
                        maxLength={280}
                        placeholder={activeVote === "up" ? "The SAML walkthrough saved me an hour…" : "Step 4 references a setting I couldn't find…"}
                        className={`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 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-100 dark:placeholder:text-slate-500 ${ringBase}`}
                      />
                      <div className="mt-1 text-right text-[0.7rem] tabular-nums text-slate-400 dark:text-slate-500">{comment.length}/280</div>
                    </div>

                    <div className="mt-2 flex items-center justify-end gap-2">
                      <button
                        type="button"
                        onClick={reset}
                        className={`rounded-lg px-3 py-2 text-sm font-medium text-slate-500 transition-colors hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100 ${ringBase}`}
                      >
                        Clear
                      </button>
                      <button
                        type="button"
                        onClick={() => setSubmitted(true)}
                        className={`inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 active:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400 ${ringBase}`}
                      >
                        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="m13 6 6 6-6 6" />
                        </svg>
                      </button>
                    </div>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            {/* Success */}
            <AnimatePresence initial={false}>
              {submitted && (
                <motion.div
                  initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -6 }}
                  animate={reduce ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
                  transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 280, damping: 30 }}
                  className="overflow-hidden"
                >
                  <div className="mt-6 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="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4" aria-hidden="true">
                        <motion.path
                          d="M20 6 9 17l-5-5"
                          initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
                          animate={{ pathLength: 1 }}
                          transition={reduce ? { duration: 0 } : { duration: 0.45, ease: "easeInOut" }}
                        />
                      </svg>
                    </span>
                    <div className="min-w-0">
                      <p className="text-sm font-semibold text-emerald-800 dark:text-emerald-200">Thanks — that helps us improve this guide.</p>
                      <p className="mt-0.5 text-xs text-emerald-700/80 dark:text-emerald-300/80">
                        You rated it {vote === "up" ? "helpful" : "not helpful"}
                        {reasons.length > 0 ? ` · ${reasons.length} reason${reasons.length > 1 ? "s" : ""} noted` : ""}.
                      </p>
                      <button
                        type="button"
                        onClick={reset}
                        className={`mt-2 rounded-md text-xs font-semibold text-emerald-700 underline underline-offset-2 hover:text-emerald-900 dark:text-emerald-300 dark:hover:text-emerald-100 ${ringBase}`}
                      >
                        Change my answer
                      </button>
                    </div>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            {/* Tally */}
            <div className="mt-6 border-t border-slate-100 pt-5 dark:border-slate-800">
              <div className="flex items-center justify-between text-xs font-medium text-slate-500 dark:text-slate-400">
                <span>
                  <span className="font-semibold text-slate-700 dark:text-slate-200">{pct}%</span> of {totalLabel} readers found this helpful
                </span>
                <span className="flex items-center gap-3 tabular-nums">
                  <span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
                    <ThumbUpIcon className="h-3.5 w-3.5" />
                    {up.toLocaleString("en-US")}
                  </span>
                  <span className="inline-flex items-center gap-1 text-rose-500 dark:text-rose-400">
                    <ThumbDownIcon className="h-3.5 w-3.5" />
                    {down.toLocaleString("en-US")}
                  </span>
                </span>
              </div>
              <div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
                <motion.div
                  className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-500"
                  initial={false}
                  animate={{ width: `${pct}%` }}
                  transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 200, damping: 28 }}
                />
              </div>
            </div>
          </div>
        </div>

        {/* SR-only live announcement */}
        <p aria-live="polite" className="sr-only">
          {submitted
            ? "Feedback submitted. Thank you."
            : vote === "up"
            ? "You rated this page helpful."
            : vote === "down"
            ? "You rated this page not helpful."
            : ""}
        </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 →