All patterns

Rating Stars Fill

Stars light left to right under the pointer, each settling a fraction after the one before it.

feedbackfriendlyenergeticinteraction · finite · intermediate · ~0.6s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

209 lines · react + motion only
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Rating Stars Fill
 *
 * Stars fill left to right as the pointer sweeps across them, each one
 * settling a fraction after the one before it, and the whole row locks
 * when a rating is chosen.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `count`, `labels`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RatingStarsFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** How many stars. */
  count?: number;
  /** Rating chosen up front, from 0 (none). */
  defaultValue?: number;
  /** One word per rating, lowest first. */
  labels?: string[];
  /** Line shown before anything is chosen. */
  prompt?: string;
  /** Star size in px. */
  size?: number;
  /** Fires with the locked rating. */
  onChange?: (value: number) => void;
};

type VariantConfig = {
  /** Gap between one star lighting and the next, in seconds. */
  stagger: number;
  /** How small an unlit star sits. */
  restScale: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// The sweep is the point, so the stagger carries the energy rather than
// the springs — every ratio here is at or above 0.8, and no star
// overshoots twice. Variants change tempo and travel only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost simultaneous, barely any size change.
  subtle: {
    stagger: 0.018,
    restScale: 0.94,
    spring: { type: "spring", stiffness: 560, damping: 44 },
  },
  default: {
    stagger: 0.035,
    restScale: 0.86,
    spring: { type: "spring", stiffness: 440, damping: 34 },
  },
  // A visible run across the row, from further down.
  playful: {
    stagger: 0.055,
    restScale: 0.76,
    spring: { type: "spring", stiffness: 340, damping: 30 },
  },
};

const STAR = "#F5A524";

const STAR_PATH =
  "M12 2.6l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.4l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.94z";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_LABELS = ["Poor", "Fair", "Good", "Great", "Excellent"];

export default function RatingStarsFill({
  variant = "default",
  count = 5,
  defaultValue = 0,
  labels = DEFAULT_LABELS,
  prompt = "How did we do?",
  size = 30,
  onChange,
}: RatingStarsFillProps) {
  const [value, setValue] = useState(defaultValue);
  const [hover, setHover] = useState(0);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // The pointer wins while it is over the row; the locked rating is what
  // the row falls back to the moment it leaves.
  const shown = hover || value;
  const word = shown > 0 ? (labels[shown - 1] ?? "") : prompt;

  const commit = (next: number) => {
    setValue(next);
    onChange?.(next);
  };

  return (
    <div style={{ display: "inline-flex", flexDirection: "column", alignItems: "center", gap: 10 }}>
      <div
        role="radiogroup"
        aria-label="Rating"
        onPointerLeave={() => setHover(0)}
        style={{ display: "flex", alignItems: "center", gap: 6 }}
      >
        {Array.from({ length: count }, (_, index) => {
          const position = index + 1;
          const lit = position <= shown;
          return (
            <button
              key={position}
              type="button"
              role="radio"
              aria-checked={value === position}
              aria-label={`${position} of ${count}`}
              onPointerEnter={() => setHover(position)}
              onFocus={() => setHover(position)}
              onBlur={() => setHover(0)}
              onClick={() => commit(position)}
              style={{
                padding: 0,
                border: 0,
                background: "none",
                lineHeight: 0,
                cursor: "pointer",
                color: "inherit",
              }}
            >
              <motion.span
                // Icons may scale; the word underneath may not. The
                // settle lives entirely in this 30px mark.
                initial={false}
                animate={{ scale: reduceMotion ? 1 : lit ? 1 : cfg.restScale }}
                transition={{
                  ...cfg.spring,
                  // The run reads left to right because each star waits
                  // its turn — the springs themselves are identical.
                  delay: lit && !reduceMotion ? index * cfg.stagger : 0,
                }}
                style={{
                  position: "relative",
                  display: "block",
                  width: size,
                  height: size,
                }}
              >
                <svg
                  width={size}
                  height={size}
                  viewBox="0 0 24 24"
                  fill="none"
                  aria-hidden
                  style={{ display: "block" }}
                >
                  <path d={STAR_PATH} fill={tone(13)} />
                </svg>
                {/* The lit star is a second copy crossfaded over the
                    resting one: a fill cannot be tweened from a mixed
                    color, but an opacity can, and it stays composited. */}
                <motion.svg
                  width={size}
                  height={size}
                  viewBox="0 0 24 24"
                  fill="none"
                  aria-hidden
                  initial={false}
                  animate={{ opacity: lit ? 1 : 0 }}
                  transition={{
                    duration: reduceMotion ? 0.14 : 0.18,
                    ease: "easeOut",
                    delay: lit && !reduceMotion ? index * cfg.stagger : 0,
                  }}
                  style={{ position: "absolute", inset: 0, display: "block" }}
                >
                  <path d={STAR_PATH} fill={STAR} />
                </motion.svg>
              </motion.span>
            </button>
          );
        })}
      </div>

      {/* The word is text, so it crossfades in place at a constant size:
          no scaling, no travel, no bounce. */}
      <div
        style={{
          display: "grid",
          minHeight: 17,
          fontSize: 12.5,
          textAlign: "center",
        }}
      >
        <motion.span
          key={word}
          initial={{ opacity: 0 }}
          animate={{ opacity: shown > 0 ? 1 : 0.55 }}
          transition={{ duration: 0.16, ease: "easeOut" }}
          // One constant weight and one constant size: a word that
          // thickens as it changes is a word that jumps.
          style={{ gridArea: "1 / 1", fontWeight: 550, whiteSpace: "nowrap" }}
        >
          {word}
        </motion.span>
      </div>
    </div>
  );
}

About this pattern

A rating control is a preview before it is a commitment, so the row has to answer the pointer as it travels. Each star waits its turn by a few hundredths of a second, which is what makes the fill read as a run across the row rather than five things changing at once — the springs themselves are identical. The lit star is a second copy crossfaded over the resting one, because a fill cannot be tweened out of a mixed color while an opacity can and stays composited. The pointer wins while it is over the row and the locked rating is what the row falls back to when it leaves. The word underneath crossfades at one constant size and weight.

Rate a support conversationReview a delivered orderScore a help articleRate a completed booking

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Files
    Recent
    Shared
    Starred
    Trash
    FilesNew
    Brand refresh12 files
    Contracts6 files
    Photography48 files
    Q3 planning9 files
    Reports21 files
    Shared4 files
    Delete this project?Its 21 files and every share link stop working. This cannot be undone.
    CancelDelete
    Modal sheet

    Stars settle individually as the choice is made.

Related patterns