UIForms and Inputsmotion/react

StarRating

A star rating input that previews the score as you hover (down to half stars), springs the hovered star up, and pops the stars in sequence when a rating is committed. Works with arrow keys and has a read-only display mode.

Preview

Rate this template

Customize

12 props
3.5
5
32
6

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add star-rating

Writes components/ui/star-rating.tsx and components/styles/star-rating.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { StarRating } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <StarRating />6  );7}

Props

PropTypeDefault
value

Controlled rating.

numberundefined
defaultValue

Initial rating when uncontrolled (snapped to the step and clamped to count).

number3.5
onValueChange

Called with the newly committed rating.

(value: number) => voidundefined
count

Number of stars.

number5
allowHalf

Allow half-star ratings (left half of a star = .5).

booleantrue
readOnly

Display-only mode exposed as an image with a descriptive label.

booleanfalse
allowClear

Clicking the current rating again resets it to 0.

booleantrue
size

Star size in pixels.

number32
gap

Gap between stars in pixels.

number6
activeColor

Fill and glow color of active stars.

string'#f59e0b'
label

Accessible name, shown as a caption when showLabel is true.

string'Rate this template'
showLabel

Show the label above the stars.

booleantrue
showValue

Show the numeric value (and a word like 'Good' for 5-star scales).

booleantrue
disabled

Disable interaction.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Interactive mode is a single focusable role="slider" (aria-valuemin 0, aria-valuemax = count, aria-valuenow, aria-valuetext like "3.5 out of 5 stars, Good") labelled by the visible caption. ArrowRight/ArrowUp and ArrowLeft/ArrowDown change by one step (0.5 with allowHalf), PageUp/PageDown by a whole star, Home/End jump to 0/max and number keys set whole ratings. Read-only mode renders role="img" with an aria-label stating the rating. Individual stars are aria-hidden. Under prefers-reduced-motion the hover lift and pop are disabled.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/star-rating.tsx
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/star-rating.module.css";6 7export interface StarRatingProps {8  /** Controlled rating */9  value?: number;10  /** Initial rating when uncontrolled */11  defaultValue?: number;12  /** Called with the newly committed rating */13  onValueChange?: (value: number) => void;14  /** Number of stars */15  count?: number;16  /** Allow half-star ratings */17  allowHalf?: boolean;18  /** Display only (no interaction) */19  readOnly?: boolean;20  /** Clicking the current rating again resets it to 0 */21  allowClear?: boolean;22  /** Star size in pixels */23  size?: number;24  /** Gap between stars in pixels */25  gap?: number;26  /** Fill color of active stars */27  activeColor?: string;28  /** Accessible name (also shown as a caption when showLabel is true) */29  label?: string;30  /** Show the label above the stars */31  showLabel?: boolean;32  /** Show the numeric value next to the stars */33  showValue?: boolean;34  /** Disable interaction */35  disabled?: boolean;36  /** Optional className merged onto the root */37  className?: string;38  /** Optional inline styles merged onto the root */39  style?: React.CSSProperties;40}41 42const STAR = "M12 2.75l2.83 5.73 6.32.92-4.57 4.46 1.08 6.3L12 17.18l-5.66 2.98 1.08-6.3L2.85 9.4l6.32-.92L12 2.75z";43const WORDS = ["No rating", "Terrible", "Poor", "Okay", "Good", "Excellent"];44 45export function StarRating({46  value: valueProp,47  defaultValue = 3.5,48  onValueChange,49  count = 5,50  allowHalf = true,51  readOnly = false,52  allowClear = true,53  size = 32,54  gap = 6,55  activeColor = "#f59e0b",56  label = "Rate this template",57  showLabel = true,58  showValue = true,59  disabled = false,60  className = "",61  style,62}: StarRatingProps) {63  const reduce = useReducedMotion();64  const id = React.useId();65  const total = Math.max(1, Math.round(count));66  const stepSize = allowHalf ? 0.5 : 1;67  const snap = (v: number) => Math.min(total, Math.max(0, Math.round(v / stepSize) * stepSize));68  const [inner, setInner] = React.useState(() => snap(defaultValue));69  const value = valueProp !== undefined ? snap(valueProp) : inner;70  const [hover, setHover] = React.useState<number | null>(null);71  const [pulse, setPulse] = React.useState(0);72  const interactive = !readOnly && !disabled;73  const shown = interactive && hover !== null ? hover : value;74 75  const commit = (v: number) => {76    const next = snap(allowClear && v === value ? 0 : v);77    if (valueProp === undefined) setInner(next);78    if (next !== value) onValueChange?.(next);79    setPulse((p) => p + 1);80  };81 82  const pointValue = (e: React.PointerEvent<HTMLElement> | React.MouseEvent<HTMLElement>, i: number) => {83    if (!allowHalf) return i + 1;84    const r = e.currentTarget.getBoundingClientRect();85    return e.clientX - r.left < r.width / 2 ? i + 0.5 : i + 1;86  };87 88  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {89    if (!interactive) return;90    const map: Record<string, number> = { ArrowRight: stepSize, ArrowUp: stepSize, ArrowLeft: -stepSize, ArrowDown: -stepSize, PageUp: 1, PageDown: -1 };91    let next: number | null = null;92    if (e.key in map) next = snap(value + map[e.key]);93    else if (e.key === "Home") next = 0;94    else if (e.key === "End") next = total;95    else if (/^[0-9]$/.test(e.key) && Number(e.key) <= total) next = Number(e.key);96    if (next === null) return;97    e.preventDefault();98    if (valueProp === undefined) setInner(next);99    if (next !== value) onValueChange?.(next);100    setPulse((p) => p + 1);101  };102 103  const word = total === 5 ? WORDS[Math.ceil(shown)] : "";104  const valueWord = total === 5 && value > 0 ? `, ${WORDS[Math.ceil(value)]}` : "";105  const labelId = `${id}-label`;106 107  return (108    <div109      className={`${styles.root} ${className}`.trim()}110      data-readonly={readOnly ? "" : undefined}111      data-disabled={disabled ? "" : undefined}112      style={{ "--mf-sr-active": activeColor, "--mf-sr-size": `${size}px`, "--mf-sr-gap": `${gap}px`, ...style } as React.CSSProperties}113    >114      {showLabel && label && (115        <span id={labelId} className={styles.label}>116          {label}117        </span>118      )}119      <div className={styles.row}>120        <div121          className={styles.stars}122          role={readOnly ? "img" : "slider"}123          aria-label={readOnly ? `${label ? `${label}: ` : ""}Rated ${value} out of ${total} stars` : showLabel && label ? undefined : label || "Rating"}124          aria-labelledby={!readOnly && showLabel && label ? labelId : undefined}125          aria-valuemin={readOnly ? undefined : 0}126          aria-valuemax={readOnly ? undefined : total}127          aria-valuenow={readOnly ? undefined : value}128          aria-valuetext={readOnly ? undefined : `${value} out of ${total} stars${valueWord}`}129          aria-disabled={disabled || undefined}130          tabIndex={interactive ? 0 : undefined}131          onKeyDown={onKeyDown}132          onPointerLeave={() => setHover(null)}133        >134          {Array.from({ length: total }, (_, i) => {135            const fill = Math.max(0, Math.min(1, shown - i));136            const hovered = hover !== null && interactive && Math.ceil(hover) === i + 1;137            return (138              <motion.span139                key={`${i}-${pulse}`}140                aria-hidden="true"141                className={styles.star}142                initial={reduce || pulse === 0 || fill === 0 ? false : { scale: 0.6 }}143                animate={{ scale: hovered && !reduce ? 1.15 : 1 }}144                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 14, delay: pulse > 0 && !hovered ? i * 0.035 : 0 }}145                onPointerMove={(e) => interactive && setHover(pointValue(e, i))}146                onClick={(e) => interactive && commit(pointValue(e, i))}147              >148                <svg className={styles.base} viewBox="0 0 24 24" width={size} height={size}>149                  <path d={STAR} />150                </svg>151                <span className={styles.fillClip} style={{ width: `${fill * 100}%` }}>152                  <svg className={styles.fill} viewBox="0 0 24 24" width={size} height={size}>153                    <path d={STAR} />154                  </svg>155                </span>156              </motion.span>157            );158          })}159        </div>160        {showValue && (161          <span className={styles.value} aria-hidden="true">162            <span className={styles.number}>{shown.toFixed(allowHalf ? 1 : 0)}</span>163            {word && <span className={styles.word}>{word}</span>}164          </span>165        )}166      </div>167    </div>168  );169}