Web InnoventixFreeCode

Rating Slider

Original · free

satisfaction rating slider

byWeb InnoventixReact + Tailwind
sldratingsliders
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/sld-rating.json
sld-rating.tsx
"use client";

import {
  useState,
  useRef,
  useCallback,
  type PointerEvent as ReactPointerEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

type Level = {
  label: string;
  blurb: string;
  labelText: string;
  faceText: string;
  faceBg: string;
  ring: string;
  badge: string;
  bar: string;
};

const STEP = 5;
const THUMB = 30;

const LEVELS: Level[] = [
  {
    label: "Very dissatisfied",
    blurb: "We clearly missed the mark. Tell us exactly what went wrong.",
    labelText: "text-rose-600 dark:text-rose-400",
    faceText: "text-rose-500 dark:text-rose-400",
    faceBg: "bg-rose-50 dark:bg-rose-500/10",
    ring: "ring-rose-200 dark:ring-rose-500/30",
    badge:
      "bg-rose-500/10 text-rose-600 ring-rose-500/25 dark:text-rose-300 dark:ring-rose-400/30",
    bar: "bg-rose-500",
  },
  {
    label: "Dissatisfied",
    blurb: "Not our best work. There is real room for us to improve here.",
    labelText: "text-amber-600 dark:text-amber-400",
    faceText: "text-amber-500 dark:text-amber-400",
    faceBg: "bg-amber-50 dark:bg-amber-500/10",
    ring: "ring-amber-200 dark:ring-amber-500/30",
    badge:
      "bg-amber-500/10 text-amber-600 ring-amber-500/25 dark:text-amber-300 dark:ring-amber-400/30",
    bar: "bg-amber-500",
  },
  {
    label: "Neutral",
    blurb: "Middle of the road. Solid enough, but nothing memorable yet.",
    labelText: "text-amber-600 dark:text-amber-300",
    faceText: "text-amber-500 dark:text-amber-300",
    faceBg: "bg-amber-50 dark:bg-amber-400/10",
    ring: "ring-amber-200 dark:ring-amber-400/30",
    badge:
      "bg-amber-400/15 text-amber-600 ring-amber-400/30 dark:text-amber-200 dark:ring-amber-300/30",
    bar: "bg-amber-400",
  },
  {
    label: "Satisfied",
    blurb: "Glad it landed well. We would love to make it even better.",
    labelText: "text-emerald-600 dark:text-emerald-400",
    faceText: "text-emerald-500 dark:text-emerald-400",
    faceBg: "bg-emerald-50 dark:bg-emerald-500/10",
    ring: "ring-emerald-200 dark:ring-emerald-500/30",
    badge:
      "bg-emerald-500/10 text-emerald-600 ring-emerald-500/25 dark:text-emerald-300 dark:ring-emerald-400/30",
    bar: "bg-emerald-500",
  },
  {
    label: "Very satisfied",
    blurb: "This made our day. Thank you for the generous feedback.",
    labelText: "text-emerald-600 dark:text-emerald-400",
    faceText: "text-emerald-500 dark:text-emerald-400",
    faceBg: "bg-emerald-50 dark:bg-emerald-500/10",
    ring: "ring-emerald-200 dark:ring-emerald-500/30",
    badge:
      "bg-emerald-500/15 text-emerald-700 ring-emerald-500/30 dark:text-emerald-200 dark:ring-emerald-300/30",
    bar: "bg-emerald-500",
  },
];

const SEGMENTS: { label: string; value: number }[] = [
  { label: "Awful", value: 10 },
  { label: "Poor", value: 30 },
  { label: "Okay", value: 50 },
  { label: "Good", value: 70 },
  { label: "Great", value: 90 },
];

function clamp(n: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, n));
}

function levelFor(value: number): number {
  return Math.min(LEVELS.length - 1, Math.floor(value / 20));
}

function mouthPath(value: number): string {
  const curve = ((value - 50) / 50) * 20;
  const corner = 62 - curve * 0.35;
  const mid = 62 + curve;
  return `M 34 ${corner.toFixed(1)} Q 50 ${mid.toFixed(1)} 66 ${corner.toFixed(1)}`;
}

export default function SldRating() {
  const reduce = useReducedMotion();
  const [value, setValue] = useState<number>(70);
  const [dragging, setDragging] = useState<boolean>(false);
  const [focused, setFocused] = useState<boolean>(false);
  const [submitted, setSubmitted] = useState<boolean>(false);

  const trackRef = useRef<HTMLDivElement | null>(null);
  const thumbRef = useRef<HTMLDivElement | null>(null);

  const index = levelFor(value);
  const level = LEVELS[index];
  const active = dragging || focused;
  const eyeRy = (7 - (value / 100) * 2).toFixed(2);

  const setFromClientX = useCallback((clientX: number) => {
    const el = trackRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const ratio = rect.width === 0 ? 0 : (clientX - rect.left) / rect.width;
    const next = clamp(Math.round((ratio * 100) / STEP) * STEP, 0, 100);
    setValue(next);
  }, []);

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (submitted) return;
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
    setFromClientX(e.clientX);
    thumbRef.current?.focus();
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    setFromClientX(e.clientX);
  };

  const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    setDragging(false);
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (submitted) return;
    let next = value;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = value + STEP;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = value - STEP;
        break;
      case "PageUp":
        next = value + 20;
        break;
      case "PageDown":
        next = value - 20;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = 100;
        break;
      default:
        return;
    }
    e.preventDefault();
    setValue(clamp(next, 0, 100));
  };

  const thumbLeft = `calc(${value}% + ${((0.5 - value / 100) * THUMB).toFixed(2)}px)`;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
      <style>{`
        @keyframes sldr-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-5px)} }
        @keyframes sldr-shimmer { 0%{transform:translateX(-130%)} 100%{transform:translateX(360%)} }
        @keyframes sldr-ping { 0%{transform:scale(1);opacity:.55} 100%{transform:scale(2.3);opacity:0} }
        @keyframes sldr-draw { from{stroke-dashoffset:26} to{stroke-dashoffset:0} }
        .sldr-bob{animation:sldr-bob 4.8s ease-in-out infinite}
        .sldr-shimmer{animation:sldr-shimmer 2.8s ease-in-out infinite}
        .sldr-ping{animation:sldr-ping 1.7s ease-out infinite}
        .sldr-draw{stroke-dasharray:26;animation:sldr-draw .5s ease-out forwards}
        @media (prefers-reduced-motion: reduce){
          .sldr-bob,.sldr-shimmer,.sldr-ping,.sldr-draw{animation:none!important}
          .sldr-draw{stroke-dashoffset:0}
        }
      `}</style>

      {/* atmospheric backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-300/40 via-sky-200/30 to-emerald-200/30 blur-3xl dark:from-indigo-600/20 dark:via-sky-700/10 dark:to-emerald-700/10"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-[0.4] [background-image:radial-gradient(circle_at_1px_1px,rgb(100_116_139/0.14)_1px,transparent_0)] [background-size:22px_22px] dark:opacity-[0.5] dark:[background-image:radial-gradient(circle_at_1px_1px,rgb(148_163_184/0.12)_1px,transparent_0)]"
      />

      <div className="relative mx-auto max-w-xl">
        <div className="rounded-[28px] border border-slate-200/80 bg-white/80 p-6 shadow-[0_24px_60px_-28px_rgb(15_23_42/0.4)] backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-[0_24px_60px_-28px_rgb(0_0_0/0.7)] sm:p-9">
          <div className="flex items-center justify-between gap-4">
            <span className="inline-flex items-center gap-2 rounded-full bg-slate-900/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 ring-1 ring-inset ring-slate-900/10 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10">
              <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
              Feedback
            </span>
            <span
              className={`inline-flex items-baseline gap-1 rounded-full px-3 py-1 text-sm font-semibold tabular-nums ring-1 ring-inset transition-colors duration-300 ${level.badge}`}
            >
              <span className="text-lg leading-none">{value}</span>
              <span className="text-xs opacity-70">/100</span>
            </span>
          </div>

          <h2 className="mt-5 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            How satisfied are you with Innoventix?
          </h2>
          <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
            Slide the dial to the spot that matches your experience. Drag,
            click a step, or use the arrow keys.
          </p>

          {/* face + verdict */}
          <div className="mt-8 flex flex-col items-center text-center">
            <motion.div
              key={index}
              initial={reduce ? false : { scale: 0.82, opacity: 0.5 }}
              animate={{ scale: 1, opacity: 1 }}
              transition={{ type: "spring", stiffness: 320, damping: 18 }}
              className={`relative flex h-28 w-28 items-center justify-center rounded-full ring-8 ring-inset transition-colors duration-300 ${level.faceBg} ${level.ring}`}
            >
              <div className={`sldr-bob ${level.faceText}`}>
                <svg
                  viewBox="0 0 100 100"
                  className="h-20 w-20"
                  aria-hidden="true"
                  fill="none"
                >
                  <circle
                    cx="50"
                    cy="50"
                    r="42"
                    stroke="currentColor"
                    strokeOpacity="0.18"
                    strokeWidth="4"
                  />
                  <ellipse cx="37" cy="43" rx="5.4" ry={eyeRy} fill="currentColor" />
                  <ellipse cx="63" cy="43" rx="5.4" ry={eyeRy} fill="currentColor" />
                  <path
                    d={mouthPath(value)}
                    stroke="currentColor"
                    strokeWidth="5.5"
                    strokeLinecap="round"
                    style={{ transition: reduce ? undefined : "d 0.15s ease-out" }}
                  />
                </svg>
              </div>
            </motion.div>

            <div aria-live="polite" className="mt-5">
              <p
                className={`text-lg font-semibold transition-colors duration-300 ${level.labelText}`}
              >
                {level.label}
              </p>
              <p className="mx-auto mt-1 max-w-xs text-sm text-slate-500 dark:text-slate-400">
                {level.blurb}
              </p>
            </div>
          </div>

          {/* slider */}
          <div className="mt-8">
            <div
              ref={trackRef}
              onPointerDown={onPointerDown}
              onPointerMove={onPointerMove}
              onPointerUp={onPointerUp}
              onPointerCancel={onPointerUp}
              className={`relative h-9 select-none ${
                submitted ? "cursor-default" : "cursor-pointer"
              }`}
              style={{ touchAction: "none" }}
            >
              {/* base track */}
              <div className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 rounded-full bg-slate-200 ring-1 ring-inset ring-slate-900/5 dark:bg-slate-800 dark:ring-white/5" />

              {/* filled gradient (clipped to value) */}
              <div
                className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 overflow-hidden rounded-full"
                style={{
                  clipPath: `inset(0 ${(100 - value).toFixed(2)}% 0 0 round 9999px)`,
                  transition: dragging ? "none" : "clip-path 0.18s ease-out",
                  backgroundImage:
                    "linear-gradient(90deg, rgb(244 63 94) 0%, rgb(245 158 11) 50%, rgb(16 185 129) 100%)",
                }}
              >
                <div className="sldr-shimmer absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/55 to-transparent dark:via-white/35" />
              </div>

              {/* value bubble */}
              <AnimatePresence>
                {active && !submitted ? (
                  <motion.div
                    initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6, scale: 0.9 }}
                    animate={{ opacity: 1, y: 0, scale: 1 }}
                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.9 }}
                    transition={{ duration: 0.16 }}
                    className="pointer-events-none absolute bottom-full z-10 mb-3 -translate-x-1/2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-semibold tabular-nums text-white shadow-lg dark:bg-white dark:text-slate-900"
                    style={{ left: thumbLeft }}
                  >
                    {value}
                    <span
                      aria-hidden="true"
                      className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1 rotate-45 bg-slate-900 dark:bg-white"
                    />
                  </motion.div>
                ) : null}
              </AnimatePresence>

              {/* thumb */}
              <div
                ref={thumbRef}
                role="slider"
                tabIndex={submitted ? -1 : 0}
                aria-label="Satisfaction rating"
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={value}
                aria-valuetext={`${value} out of 100, ${level.label}`}
                aria-disabled={submitted || undefined}
                onKeyDown={onKeyDown}
                onFocus={() => setFocused(true)}
                onBlur={() => setFocused(false)}
                className="absolute top-1/2 z-10 flex h-[30px] w-[30px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-[3px] border-white bg-slate-900 shadow-[0_4px_14px_-2px_rgb(15_23_42/0.5)] outline-none ring-slate-900/10 transition-transform duration-150 focus-visible:ring-4 focus-visible:ring-indigo-500/50 dark:border-slate-900 dark:bg-white dark:focus-visible:ring-indigo-400/60"
                style={{
                  left: thumbLeft,
                  transform: `translate(-50%, -50%) scale(${
                    active && !reduce ? 1.12 : 1
                  })`,
                }}
              >
                <span
                  className={`h-2 w-2 rounded-full transition-colors duration-300 ${level.bar}`}
                />
                {active && !submitted ? (
                  <span
                    aria-hidden="true"
                    className={`sldr-ping absolute inset-0 rounded-full ring-2 ${level.ring}`}
                  />
                ) : null}
              </div>
            </div>

            {/* step labels */}
            <div className="mt-3 grid grid-cols-5">
              {SEGMENTS.map((seg, i) => {
                const on = i === index;
                return (
                  <button
                    key={seg.label}
                    type="button"
                    disabled={submitted}
                    onClick={() => {
                      setValue(seg.value);
                      thumbRef.current?.focus();
                    }}
                    aria-pressed={on}
                    className={`rounded-md py-1 text-center text-xs font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-indigo-500/50 disabled:cursor-default dark:focus-visible:ring-indigo-400/60 ${
                      on
                        ? level.labelText
                        : "text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
                    }`}
                  >
                    {seg.label}
                  </button>
                );
              })}
            </div>
          </div>

          {/* footer / confirmation */}
          <div className="mt-7 min-h-[52px]">
            <AnimatePresence mode="wait" initial={false}>
              {submitted ? (
                <motion.div
                  key="done"
                  initial={reduce ? { opacity: 1 } : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                  transition={{ duration: 0.22 }}
                  className="flex flex-col items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-4 text-center ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/10 dark:ring-emerald-400/25 sm:flex-row sm:text-left"
                >
                  <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
                    <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
                      <path
                        d="M5 12.5l4 4 10-10"
                        stroke="currentColor"
                        strokeWidth="2.5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className="sldr-draw"
                      />
                    </svg>
                  </span>
                  <p
                    aria-live="assertive"
                    className="flex-1 text-sm font-medium text-emerald-800 dark:text-emerald-200"
                  >
                    Thanks for the feedback! You rated us{" "}
                    <span className="font-semibold">{level.label.toLowerCase()}</span>{" "}
                    ({value}/100).
                  </p>
                  <button
                    type="button"
                    onClick={() => {
                      setSubmitted(false);
                      thumbRef.current?.focus();
                    }}
                    className="shrink-0 rounded-lg px-3 py-1.5 text-sm font-semibold text-emerald-700 underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-emerald-500/50 dark:text-emerald-300"
                  >
                    Change response
                  </button>
                </motion.div>
              ) : (
                <motion.button
                  key="cta"
                  type="button"
                  onClick={() => setSubmitted(true)}
                  initial={reduce ? { opacity: 1 } : { opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
                  transition={{ duration: 0.22 }}
                  whileTap={reduce ? undefined : { scale: 0.98 }}
                  className="group flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-5 py-3.5 text-sm font-semibold text-white shadow-lg shadow-slate-900/10 outline-none transition-colors hover:bg-slate-800 focus-visible:ring-4 focus-visible:ring-indigo-500/40 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-indigo-400/50"
                >
                  Submit my rating
                  <svg
                    viewBox="0 0 24 24"
                    className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5"
                    fill="none"
                    aria-hidden="true"
                  >
                    <path
                      d="M4 12h15m0 0l-6-6m6 6l-6 6"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                </motion.button>
              )}
            </AnimatePresence>
          </div>
        </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 →