Inputs & Forms

Rating Input

A five-star rating where hover previews the fill, click commits with a single subtle pop, arrow keys adjust, and a readonly mode renders fractional averages.

Install

npx shadcn@latest add @paragon/rating-input

rating-input.tsx

"use client";

import * as React from "react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";

export interface RatingInputProps
  extends Omit<
    React.ComponentProps<"div">,
    "defaultValue" | "onChange" | "role"
  > {
  /** Controlled rating, 1–max (0 = unrated). Fractions allowed in readonly mode. */
  value?: number;
  /** Initial rating when uncontrolled. */
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  max?: number;
  /** Static display mode; supports fractional values. */
  readonly?: boolean;
  /** Form field name; renders a hidden input. */
  name?: string;
  /** Disables the commit pop. */
  static?: boolean;
}

/**
 * A star rating input. Hover previews the fill up to the cursor, click
 * commits with a single subtle pop on the selected star, and arrow keys
 * adjust the value. `readonly` renders a static (fraction-capable) display.
 */
export function RatingInput({
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  max = 5,
  readonly = false,
  name,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel = "Rating",
  ...props
}: RatingInputProps) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = valueProp ?? uncontrolled;
  const [preview, setPreview] = React.useState<number | null>(null);
  // Bumping `n` re-keys the popped star so the animation restarts cleanly.
  const [pop, setPop] = React.useState<{ index: number; n: number } | null>(
    null,
  );
  const buttonRefs = React.useRef<(HTMLButtonElement | null)[]>([]);

  const commit = (next: number, withPop: boolean) => {
    if (valueProp === undefined) setUncontrolled(next);
    onValueChange?.(next);
    if (withPop && !isStatic) {
      setPop((prev) => ({ index: next - 1, n: (prev?.n ?? 0) + 1 }));
    }
  };

  if (readonly) {
    return (
      <div
        role="img"
        aria-label={`Rated ${value} out of ${max}`}
        className={cn("inline-flex items-center gap-0.5", className)}
        {...props}
      >
        {Array.from({ length: max }, (_, i) => {
          const fill = Math.max(0, Math.min(1, value - i));
          return (
            <span key={i} className="relative inline-flex" aria-hidden>
              <Star className="size-5 fill-transparent text-muted-foreground/40" />
              {fill > 0 && (
                <Star
                  className="absolute inset-0 size-5 fill-amber-500 text-amber-500"
                  style={
                    fill < 1
                      ? { clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` }
                      : undefined
                  }
                />
              )}
            </span>
          );
        })}
      </div>
    );
  }

  const shown = preview ?? value;

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      onMouseLeave={() => setPreview(null)}
      onKeyDown={(e) => {
        let next: number | null = null;
        if (e.key === "ArrowRight" || e.key === "ArrowUp") {
          next = Math.min(max, value + 1);
        } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
          next = Math.max(1, value - 1);
        } else if (e.key === "Home") {
          next = 1;
        } else if (e.key === "End") {
          next = max;
        }
        if (next !== null) {
          e.preventDefault();
          // Keyboard-initiated: adjust without the pop.
          commit(next, false);
          buttonRefs.current[next - 1]?.focus();
        }
      }}
      className={cn("inline-flex items-center", className)}
      {...props}
    >
      <style href="paragon-rating-input" precedence="paragon">{`
        @keyframes pg-rating-pop {
          0% { scale: 1; }
          50% { scale: 1.15; }
          100% { scale: 1; }
        }
        @media (prefers-reduced-motion: reduce) {
          @keyframes pg-rating-pop {
            0%, 100% { scale: 1; }
          }
        }
      `}</style>
      {Array.from({ length: max }, (_, i) => {
        const starValue = i + 1;
        const filled = starValue <= shown;
        const popped = pop?.index === i;
        return (
          <button
            key={i}
            ref={(node) => {
              buttonRefs.current[i] = node;
            }}
            type="button"
            role="radio"
            aria-checked={value === starValue}
            aria-label={`${starValue} star${starValue === 1 ? "" : "s"}`}
            tabIndex={
              value === starValue || (value === 0 && i === 0) ? 0 : -1
            }
            onMouseEnter={() => setPreview(starValue)}
            onClick={() => {
              setPreview(null);
              commit(starValue, true);
            }}
            className="relative flex items-center justify-center p-0.5 after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2"
          >
            <span
              key={popped ? pop.n : "idle"}
              className="inline-flex"
              style={
                popped
                  ? {
                      animation: `pg-rating-pop 250ms var(--ease-bounce)`,
                    }
                  : undefined
              }
            >
              <Star
                aria-hidden
                className={cn(
                  "size-5 transition-colors duration-100",
                  filled
                    ? "fill-amber-500 text-amber-500"
                    : "fill-transparent text-muted-foreground/40",
                )}
              />
            </span>
          </button>
        );
      })}
      {name && <input type="hidden" name={name} value={value} />}
    </div>
  );
}