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
Customize
12 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add star-ratingWrites 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.
1import { StarRating } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <StarRating />6 );7}Props
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
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}