Skip to content
Uxelith
Sign in
Freev1.0.0Forms

Rating

Stars that light up in a wave as you hover; the one you choose pops and throws off sparks. Optional words per value, and a read-only mode with partly filled stars for scores like 4.6.

pnpm dlx shadcn add …/r/rating.json
Licence
MIT
Packages
3
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/rating.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as rating.tsx. It's yours: change anything.

Usage

example.tsx
import { Rating } from "@/components/rating"

export function Review() {
  return <Rating label="Rate this template" showLabel defaultValue={4} name="rating" />
}

Source

One file, 187 lines, nothing hidden. Read it before you install it.

rating.tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface RatingProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  /** Accessible name, e.g. "Rate this template". Shown as a visible legend when `showLabel` is set. */
  label: string;
  showLabel?: boolean;
  /** Number of stars. */
  max?: number;
  /** Controlled value, 0 for none. With `readOnly` it may be fractional, e.g. 4.6. */
  value?: number;
  /** Initial value when uncontrolled. */
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  /** Words for each value, e.g. `["Poor", "Fair", "Good", "Great", "Loved it"]`, shown beside the stars. */
  labels?: string[];
  /** Show the value without letting anyone change it, e.g. a review's score. */
  readOnly?: boolean;
  disabled?: boolean;
  size?: "sm" | "md" | "lg";
  /** Submit with forms under this name. Defaults to a generated one. */
  name?: string;
}

const sizes = { sm: "size-4", md: "size-6", lg: "size-8" } as const;
const ON = "text-[var(--rating-on,var(--chart-3))]";
const STAR = "M12 2.8l2.84 5.75 6.35.92-4.6 4.48 1.09 6.33L12 17.3l-5.68 2.98 1.09-6.33-4.6-4.48 6.35-.92z";
const SPARKS = 6;

/**
 * A star in outline, filled up to `fill` (0–1) from the left. Unlit stars
 * stay outlines, so the score never depends on colour alone.
 */
function Star({ fill, className }: { fill: number; className?: string }) {
  return (
    <span className={cn("relative block", className)}>
      <svg viewBox="0 0 24 24" aria-hidden="true" className="absolute inset-0 size-full text-muted-foreground/60">
        <path d={STAR} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
      </svg>
      <svg
        viewBox="0 0 24 24"
        aria-hidden="true"
        className={cn("absolute inset-0 size-full transition-[clip-path] duration-200 motion-reduce:transition-none", ON)}
        style={{ clipPath: `inset(0 ${100 - fill * 100}% 0 0)` }}
      >
        <path d={STAR} fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
      </svg>
    </span>
  );
}

/** A small ring of sparks thrown off a star as it is chosen. */
function Sparks() {
  return (
    <span aria-hidden="true" className="pointer-events-none absolute inset-0">
      {Array.from({ length: SPARKS }, (_, i) => {
        const angle = (i / SPARKS) * Math.PI * 2 - Math.PI / 2;
        return (
          <motion.span
            key={i}
            className={cn("absolute left-1/2 top-1/2 -ml-0.5 -mt-0.5 size-1 rounded-full bg-current", ON)}
            initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
            animate={{ x: Math.cos(angle) * 18, y: Math.sin(angle) * 18, opacity: 0, scale: 0.4 }}
            transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
          />
        );
      })}
    </span>
  );
}

/**
 * A star rating: stars light up in a quick wave as you hover, and the one
 * you choose pops and throws off sparks.
 *
 * - Built from native radio buttons in a fieldset, so arrow keys, forms and
 *   screen readers ("4 stars, Great") behave as they would for any radio group.
 * - `readOnly` renders a single image labelled "4.6 out of 5 stars", with
 *   partly filled stars for fractions.
 * - Colours: `--rating-on` (default `--chart-3`, amber). No movement under
 *   reduced motion.
 */
export function Rating({
  label,
  showLabel = false,
  max = 5,
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  labels,
  readOnly = false,
  disabled = false,
  size = "md",
  name: nameProp,
  className,
  ...props
}: RatingProps) {
  const generatedName = React.useId();
  const name = nameProp ?? generatedName;
  const reduceMotion = useReducedMotion();
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = valueProp ?? uncontrolled;
  const [hovered, setHovered] = React.useState(0);
  // Bumped on every choice, so the pop and sparks replay even for the same star.
  const [chosen, setChosen] = React.useState<{ star: number; count: number } | null>(null);
  const shown = hovered || value;
  const stars = Array.from({ length: max }, (_, i) => i + 1);

  if (readOnly) {
    const score = Math.round(value * 10) / 10;
    return (
      <div
        role="img"
        aria-label={`${label}: ${score} out of ${max} stars`}
        className={cn("inline-flex items-center gap-0.5", className)}
        {...props}
      >
        {stars.map((n) => (
          <Star key={n} fill={Math.min(1, Math.max(0, value - (n - 1)))} className={sizes[size]} />
        ))}
      </div>
    );
  }

  const choose = (next: number) => {
    if (valueProp === undefined) setUncontrolled(next);
    setChosen((c) => ({ star: next, count: (c?.count ?? 0) + 1 }));
    onValueChange?.(next);
  };
  const word = labels?.[shown - 1];

  return (
    <div className={cn("inline-grid gap-2", className)} {...props}>
      <fieldset disabled={disabled} className="m-0 min-w-0 border-0 p-0 disabled:opacity-50">
        <legend className={showLabel ? "mb-2 text-sm font-medium" : "sr-only"}>{label}</legend>
        <div className="flex items-center gap-3">
          <div className="flex items-center gap-0.5" onPointerLeave={() => setHovered(0)}>
            {stars.map((n) => (
              <label
                key={n}
                onPointerEnter={() => !disabled && setHovered(n)}
                className={cn(
                  "group/star relative grid cursor-pointer place-items-center rounded-md p-0.5",
                  "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
                  disabled && "cursor-not-allowed",
                )}
              >
                <input
                  type="radio"
                  name={name}
                  value={n}
                  checked={value === n}
                  onChange={() => choose(n)}
                  aria-label={`${n} ${n === 1 ? "star" : "stars"}${labels?.[n - 1] ? `, ${labels[n - 1]}` : ""}`}
                  className="peer sr-only"
                />
                <motion.span
                  key={chosen?.star === n ? `pop-${chosen.count}` : "rest"}
                  initial={false}
                  animate={chosen?.star === n && !reduceMotion ? { scale: [1, 1.3, 1], rotate: [0, -12, 0] } : { scale: 1 }}
                  transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
                  className="block transition-[scale] duration-150 group-hover/star:scale-110 group-active/star:scale-90 motion-reduce:transition-none"
                >
                  <Star
                    fill={n <= shown ? 1 : 0}
                    className={sizes[size]}
                  />
                </motion.span>
                {chosen?.star === n && !reduceMotion && <Sparks key={`sparks-${chosen.count}`} />}
              </label>
            ))}
          </div>
          {labels && (
            // The radios already carry these words; this is the visible copy.
            <span aria-hidden="true" className="min-w-16 text-sm font-medium text-muted-foreground">
              {word ?? ""}
            </span>
          )}
        </div>
      </fieldset>
    </div>
  );
}

Props

PropTypeDefaultDescription
labelstring—Accessible name; a visible legend with showLabel.
showLabelbooleanfalseShow the label above the stars.
maxnumber5Number of stars.
value / defaultValuenumber— / 0Controlled or initial value; 0 is none. Read-only scores may be fractional.
onValueChange(value: number) => void—Called with the chosen number of stars.
readOnlybooleanfalseShow a score that can't be changed.
size"sm" | "md" | "lg""md"Star size.
namestringgeneratedForm field name.
labelsstring[]—A word for each value, shown beside the stars and read with each one.
--rating-onCSS variable--chart-3Colour of lit stars.

Accessibility

  • Native radio buttons in a fieldset: arrow keys, forms and screen readers ("4 stars, Great") just work.
  • Unlit stars are outlines, so the score never depends on colour alone.
  • readOnly renders one image labelled "4.6 out of 5 stars".
  • No pop, sparks or hover growth under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Forms

All 10 in Forms
  • FormsFree

    Animated Input

    A text field whose label floats out of the way, with examples that take turns while it's empty, an error that shakes once, a tick for a valid value and sent text that drifts away.

  • FormsPro

    Date Picker

    Single dates and ranges in a keyboard-first calendar, with one-tap presets ("Last 7 days"), months that slide the way you go, min/max, unavailable days and ISO strings for forms.

  • FormsPro

    File Dropzone

    Drop, paste or browse for files: the zone lights up as a file crosses the page and warns before a wrong type lands; files show thumbnails and upload with live, cancellable progress.