Star Rating Component in React: Animated, Half Stars, Read-Only
Build a fully featured React star rating component with CSS animations, half-star precision, read-only mode, and full accessibility — no library needed.
Why Roll Your Own Star Rating in 2026?
The npm ecosystem has react-stars, react-rating, and half a dozen other packages — and every single one of them adds bundle weight for a component you could write in under 100 lines. Honestly, most of those libraries were last meaningfully updated in 2021 or earlier, and their APIs were designed before the React hooks era. You end up fighting the abstraction instead of owning the behavior.
Rolling your own gives you exact control over the animation curve, the half-star rendering logic, the ARIA attributes, and the color tokens. You don't have to override six layers of inline styles with !important just to match your design system. That said, 'build from scratch' doesn't mean 'build alone' — the structure below is battle-tested and handles every edge case you'll hit in a real product.
Star ratings appear in two fundamentally different contexts: interactive (user picks a value, like leaving a review) and read-only (display an average score pulled from your API, like a product card). The component you'll build today handles both with a single prop. Worth noting: the read-only variant also needs to render fractional values like 3.7 stars, which is where most off-the-shelf libraries fall apart.
The Base Component Structure
Start with the types and the simplest possible shell. Everything interesting comes from a handful of derived values, not a wall of state.
// StarRating.tsx
import { useState, useCallback } from 'react';
interface StarRatingProps {
/** Controlled value 0–5. Pass a float for read-only fractional stars. */
value: number;
/** Total number of stars. Defaults to 5. */
count?: number;
/** If true, the rating is display-only. No hover or click. */
readOnly?: boolean;
/** Allow selecting half-star values (0.5 increments) */
allowHalf?: boolean;
/** Called with the new value when user clicks a star */
onChange?: (value: number) => void;
/** Pixel size of each star. Defaults to 24 */
size?: number;
/** Active star color */
color?: string;
/** Empty star color */
emptyColor?: string;
className?: string;
}
export function StarRating({
value,
count = 5,
readOnly = false,
allowHalf = false,
onChange,
size = 24,
color = '#FBBF24',
emptyColor = '#D1D5DB',
className = '',
}: StarRatingProps) {
const [hovered, setHovered] = useState<number | null>(null);
const displayed = hovered !== null ? hovered : value;
return (
<div
className={`inline-flex items-center gap-0.5 ${className}`}
role={readOnly ? 'img' : 'radiogroup'}
aria-label={`Rating: ${value} out of ${count} stars`}
>
{Array.from({ length: count }, (_, i) => (
<Star
key={i}
index={i + 1}
displayed={displayed}
readOnly={readOnly}
allowHalf={allowHalf}
size={size}
color={color}
emptyColor={emptyColor}
onHover={setHovered}
onLeave={() => setHovered(null)}
onSelect={(v) => onChange?.(v)}
/>
))}
</div>
);
}The hovered state stores the tentative rating while the user moves their mouse across stars. When they leave, it snaps back to value. This keeps the controlled/uncontrolled boundary clean — the parent owns the truth, the component owns the hover UX. displayed = hovered ?? value is the entire merge logic.
Notice role="radiogroup" on the interactive variant. Each star will get role="radio", which maps naturally to how screen readers already understand radio button groups. You're not inventing new ARIA semantics — you're using well-supported, understood patterns. Read-only mode gets role="img" with a descriptive aria-label instead.
Rendering the Individual Star: SVG, Clipping, and Half-Star Logic
Here's the interesting part. Half-star rendering works via an SVG clipPath. You render the filled star at full width, then clip it to 50% for a half star. No pixel math, no two-star overlay trick — just a clean SVG solution that scales to any size prop.
// Star.tsx (used internally by StarRating)
interface StarProps {
index: number;
displayed: number;
readOnly: boolean;
allowHalf: boolean;
size: number;
color: string;
emptyColor: string;
onHover: (v: number) => void;
onLeave: () => void;
onSelect: (v: number) => void;
}
function Star({ index, displayed, readOnly, allowHalf, size, color, emptyColor, onHover, onLeave, onSelect }: StarProps) {
// How full is this star? 0 = empty, 0.5 = half, 1 = full
const fill = Math.min(1, Math.max(0, displayed - (index - 1)));
const clipId = `star-clip-${index}`;
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (readOnly) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const half = x < rect.width / 2;
onHover(allowHalf && half ? index - 0.5 : index);
};
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
if (readOnly) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const half = x < rect.width / 2;
onSelect(allowHalf && half ? index - 0.5 : index);
};
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
onMouseMove={handleMouseMove}
onMouseLeave={onLeave}
onClick={handleClick}
role={readOnly ? undefined : 'radio'}
aria-checked={!readOnly ? displayed >= index : undefined}
aria-label={`${index} star${index !== 1 ? 's' : ''}`}
style={{ cursor: readOnly ? 'default' : 'pointer', overflow: 'visible' }}
>
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width={`${fill * 100}%`} height="100%" />
</clipPath>
</defs>
{/* Empty star (always rendered) */}
<polygon
points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"
fill={emptyColor}
stroke={emptyColor}
strokeWidth="0.5"
/>
{/* Filled star clipped to the fill fraction */}
<polygon
points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"
fill={color}
stroke={color}
strokeWidth="0.5"
clipPath={`url(#${clipId})`}
/>
</svg>
);
}The fill calculation is the key insight: Math.min(1, Math.max(0, displayed - (index - 1))). For displayed = 3.5 and index = 4, that gives Math.min(1, Math.max(0, 3.5 - 3)) = 0.5 — exactly half. For index = 3, it's Math.min(1, Math.max(0, 3.5 - 2)) = 1 — full. Clean, no branches.
The half-star detection on mouse move uses the cursor's X position relative to the star's bounding box. If it's left of center (12px in a 24px star), you get a half-star hover value. This is responsive to any size value you pass because it uses rect.width / 2, not a hardcoded pixel threshold.
Quick aside: the overflow: 'visible' on the SVG matters because the star polygon points extend slightly beyond the 24×24 viewBox (the topmost point at y=2 is fine, but some browsers clip paths at the SVG boundary). Add it or you'll see cut-off star tips on certain WebKit versions.
Adding CSS Animations
A static star rating is fine. An animated one feels alive. You want two animations: a pop when a star is selected (scale up then settle), and a wave fill when the rating first mounts on read-only displays. Both are CSS-only — no framer-motion needed.
/* starRating.module.css */
@keyframes star-pop {
0% { transform: scale(1); }
40% { transform: scale(1.4); }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
@keyframes star-wave-in {
0% { opacity: 0; transform: scale(0.5) rotate(-20deg); }
60% { opacity: 1; transform: scale(1.1) rotate(4deg); }
100% { transform: scale(1) rotate(0deg); }
}
.star {
animation: star-wave-in 0.3s ease both;
}
.star-pop {
animation: star-pop 0.35s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}Apply the star-wave-in animation with a staggered delay based on the star index. Each star arrives 60ms after the previous one, giving you that satisfying cascade effect on page load. In your Star component, add style={{ animationDelay: ${(index - 1) * 60}ms }} to the SVG element.
For the pop animation on click, you need to toggle a CSS class, which means you need a tiny bit of React state local to the Star component. Add const [popping, setPopping] = useState(false) and in your click handler: setPopping(true); setTimeout(() => setPopping(false), 350); onSelect(...). The 350ms matches the animation duration exactly.
In practice, this is where having your own component pays off. With a third-party library you'd be fighting className merging, overriding inline styles, and wondering why the animation flickers on re-render. Here you control every keyframe.
Read-Only Mode and Displaying API Averages
Product review aggregates from your API look like { averageRating: 4.3, reviewCount: 128 }. You need to display 4.3 stars — not 4, not 5, but a fraction. The SVG clip approach you built handles this automatically. Pass value={4.3} with readOnly and you get exactly 4.3 stars rendered without any extra work.
// Product card usage
import { StarRating } from './StarRating';
function ProductCard({ name, averageRating, reviewCount }) {
return (
<div className="flex flex-col gap-2 p-4 rounded-xl border border-gray-200">
<h3 className="font-semibold text-gray-900">{name}</h3>
<div className="flex items-center gap-2">
<StarRating
value={averageRating}
readOnly
size={18}
color="#F59E0B"
/>
<span className="text-sm text-gray-500">
{averageRating.toFixed(1)} ({reviewCount} reviews)
</span>
</div>
</div>
);
}
// Interactive review form usage
function ReviewForm() {
const [rating, setRating] = useState(0);
return (
<form onSubmit={...}>
<label className="block text-sm font-medium mb-1">Your rating</label>
<StarRating
value={rating}
onChange={setRating}
allowHalf
size={32}
color="#EAB308"
/>
{rating === 0 && <p className="text-red-500 text-xs mt-1">Please select a rating</p>}
</form>
);
}The size difference matters — 18px for display contexts, 32px for interactive forms. Smaller targets are fine when you're just reading, but click/touch targets below 24px on an interactive element will frustrate mobile users. Apple's HIG recommends a minimum touch target of 44×44pt, which maps to roughly 44px at 1x. Wrap the SVG in a slightly larger invisible hit area if you need small decorative stars alongside a clickable version.
One more thing — you should debounce or throttle the onChange callback if it's firing an API request (auto-save, optimistic updates). A simple approach: const debouncedSave = useMemo(() => debounce(saveRating, 500), []). Don't fire a network request on every mousemove hover — only on click/tap.
Look, the read-only variant is also where you'd add microdata for SEO. Wrap your rating display in a <div itemScope itemType="https://schema.org/AggregateRating"> and annotate with itemprop="ratingValue" and itemprop="reviewCount". Google uses this to show gold stars directly in search results, which is a concrete conversion improvement for any ecommerce page.
Keyboard Navigation and Full Accessibility
The ARIA radiogroup / radio pattern gives you semantic meaning, but you need actual keyboard handlers to make it functional. Screen reader users expect arrow keys to move between stars, Space/Enter to select, and the current value to be announced.
// Add to the StarRating container div
onKeyDown={(e) => {
if (readOnly) return;
const current = value;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
onChange?.(Math.min(count, current + (allowHalf ? 0.5 : 1)));
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
e.preventDefault();
onChange?.(Math.max(0, current - (allowHalf ? 0.5 : 1)));
}
if (e.key === 'Home') { e.preventDefault(); onChange?.(0); }
if (e.key === 'End') { e.preventDefault(); onChange?.(count); }
}}
tabIndex={readOnly ? undefined : 0}Add a visible focus ring. outline: 2px solid #6366F1; outline-offset: 4px on the container when focused. Don't rely on the browser default — it's inconsistent across browsers and often invisible on dark backgrounds. Tailwind's focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 does this cleanly.
For screen reader announcement of intermediate hover values, add a visually hidden live region: <span className="sr-only" aria-live="polite">{hovered ? ${hovered} of ${count} : ''}</span>. It'll announce as the user arrows through options without polluting the visual layout.
Test with VoiceOver on macOS and NVDA on Windows — they handle radiogroup slightly differently. VoiceOver reads the aria-label on the container; NVDA reads individual radio labels. Having both the group label and individual star labels covers both. If your team hasn't set up accessibility testing yet, the Empire UI component library ships components pre-tested against these screen readers, which can save you hours of manual checking.
Styling Variants: Ecommerce, Dashboard, and Dark Mode
A star rating isn't one-size-fits-all visually. An ecommerce product page wants gold stars, prominent size, and review counts. A dashboard widget wants smaller, muted stars that don't fight the data around them. A dark-mode app wants stars that don't blow out on a near-black background.
// Preset variants via simple wrapper components
export const EcommerceRating = (props: Partial<StarRatingProps> & { value: number }) => (
<StarRating size={20} color="#F59E0B" emptyColor="#FEF3C7" {...props} />
);
export const DashboardRating = (props: Partial<StarRatingProps> & { value: number }) => (
<StarRating size={14} color="#6366F1" emptyColor="#E0E7FF" readOnly {...props} />
);
export const DarkModeRating = (props: Partial<StarRatingProps> & { value: number }) => (
<StarRating size={22} color="#FCD34D" emptyColor="#374151" {...props} />
);That pattern — small wrapper components with opinionated defaults — keeps your call sites clean. <EcommerceRating value={product.rating} /> vs <StarRating value={product.rating} size={20} color="#F59E0B" emptyColor="#FEF3C7" /> in every product card. Same output, much less noise. And when design asks you to change the gold to amber in 2027, you change it in one place.
If you're already using Empire UI's design system, the color tokens from the gradient generator translate directly to star colors. The amber-400 and yellow-400 tokens match the typical star-rating gold. For dark mode, swap to yellow-300 — it reads better against surfaces in the #111827 range than pure yellow-400 does.
Worth noting: if your product uses one of Empire UI's expressive style themes — glassmorphism, neobrutalism, or cyberpunk — you'll want to adapt the star colors and borders to match. A glassmorphism product card with amber stars looks slightly disconnected; purple or cyan stars with a subtle glow (filter: drop-shadow(0 0 4px currentColor)) integrate better.
FAQ
Pass the float directly as the value prop with readOnly set. The SVG clipPath renders exactly (value - Math.floor(value)) * 100% of the filled star for the fractional position, so 4.3 shows four full stars and one 30%-filled star automatically.
Yes. The core logic is inline SVG with inline styles for colors and size. Animations use a plain CSS module (one file, seven lines). You can also copy the keyframes into a global stylesheet if you're not using CSS modules.
Add tabIndex={0} to the container and handle ArrowLeft, ArrowRight, Home, and End keys to change the value. Set role="radiogroup" on the wrapper and role="radio" on each star. A visually hidden aria-live region announces the hovered value to screen readers.
Straight-forward to wire up. With React Hook Form, use Controller: pass field.value as value and field.onChange as onChange. With Formik, bind values.rating and setFieldValue('rating', newValue). The component is fully controlled so it drops into any form library that follows the controlled-input pattern.