Review and Rating Component in React: Stars, Average, Breakdown
Build a full review and rating system in React — interactive stars, score average, histogram breakdown, and accessible markup. No libraries needed.
Why Build It Yourself?
Most rating libraries are 40 KB just to render five yellow stars. That's not a trade-off — it's just waste. When you build the component yourself, you own the behavior end-to-end: hover states, half-stars, keyboard nav, the lot.
In practice, the custom route is also faster to theme. E-commerce stores change their visual language constantly, and fighting a third-party package's CSS specificity at 2 AM before a product launch is nobody's idea of a good time.
What you're going to build here: an interactive star picker, a read-only display version, a computed average badge, and a five-row histogram breakdown — the kind you see on Amazon product pages. No external deps beyond React itself.
Worth noting: every piece here pairs cleanly with whatever style system you're running. If you're using Empire UI, the glassmorphism card wrappers from the glassmorphism components section slot right in without restyling anything.
The Star Rating Core Component
Start with the primitive — a row of five stars that tracks hover and selection separately. Hover state is ephemeral; selected value persists. Conflating those two pieces of state is the classic mistake that makes stars feel laggy or jumpy.
import { useState } from 'react';
type StarRatingProps = {
value: number;
onChange?: (rating: number) => void;
readOnly?: boolean;
size?: number; // px
};
export function StarRating({
value,
onChange,
readOnly = false,
size = 24,
}: StarRatingProps) {
const [hovered, setHovered] = useState<number | null>(null);
const active = hovered !== null ? hovered : value;
return (
<div
role="group"
aria-label="Star rating"
style={{ display: 'flex', gap: 4 }}
>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
filled={star <= active}
size={size}
onClick={() => !readOnly && onChange?.(star)}
onMouseEnter={() => !readOnly && setHovered(star)}
onMouseLeave={() => !readOnly && setHovered(null)}
readOnly={readOnly}
/>
))}
</div>
);
}The Star sub-component is just an SVG. Keep it separated from the rating logic so you can swap it for any icon set later — or a custom shape if your brand calls for it. Using 24 px as the default matches most icon grids, but you'll probably want 16 px for compact list contexts and 32 px for hero sections.
function Star({
filled,
size,
onClick,
onMouseEnter,
onMouseLeave,
readOnly,
}: {
filled: boolean;
size: number;
onClick: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
readOnly: boolean;
}) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill={filled ? '#FBBF24' : 'none'}
stroke={filled ? '#FBBF24' : '#9CA3AF'}
strokeWidth={1.5}
style={{ cursor: readOnly ? 'default' : 'pointer', transition: 'fill 0.1s' }}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
aria-hidden="true"
>
<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 12 2" />
</svg>
);
}Honestly, the aria-hidden on individual stars is correct here — the parent role="group" already exposes the widget to screen readers, and having five unlabelled SVG children announced separately makes for a terrible experience. You'll handle keyboard navigation at the group level in the next section.
Keyboard Navigation and Accessibility
A star picker that only works with a mouse will fail WCAG 2.1 AA — specifically success criterion 2.1.1. That's not just a compliance checkbox; actual users with motor disabilities or keyboard-only workflows are locked out.
The pattern you want is arrow keys to move through values, Enter or Space to commit, and a visible focus ring. Make the container tabIndex={0} and handle keyDown there:
function StarRatingAccessible(props: StarRatingProps) {
const [hovered, setHovered] = useState<number | null>(null);
const active = hovered !== null ? hovered : props.value;
const handleKey = (e: React.KeyboardEvent) => {
if (props.readOnly) return;
if (e.key === 'ArrowRight' && props.value < 5) {
props.onChange?.(props.value + 1);
}
if (e.key === 'ArrowLeft' && props.value > 1) {
props.onChange?.(props.value - 1);
}
};
return (
<div
role="radiogroup"
aria-label={`Rating: ${props.value} out of 5`}
tabIndex={props.readOnly ? -1 : 0}
onKeyDown={handleKey}
style={{ display: 'flex', gap: 4, outline: 'none' }}
className="focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-amber-400 rounded"
>
{/* stars */}
</div>
);
}Quick aside: role="radiogroup" with individual role="radio" stars is the semantically cleanest approach per the ARIA 1.2 spec. But it's verbose. The simpler role="group" + aria-label with a single tabstop is widely supported and far less code. Pick your trade-off and be consistent.
If you want a deeper dive on accessible interactive patterns, the react-accessibility-guide article covers focus traps, live regions, and more component patterns with working examples.
Computing the Average and Score Badge
A single star widget is useful, but once you have a collection of user ratings the real value is in the aggregate. The math is simple — weighted average, rounded to one decimal. The display is where you earn your pay.
type Review = {
id: string;
author: string;
rating: number; // 1-5
body: string;
date: string;
};
function computeStats(reviews: Review[]) {
if (!reviews.length) return { average: 0, breakdown: [0, 0, 0, 0, 0] };
const total = reviews.reduce((acc, r) => acc + r.rating, 0);
const average = Math.round((total / reviews.length) * 10) / 10;
const breakdown = [5, 4, 3, 2, 1].map(
(star) => reviews.filter((r) => r.rating === star).length
);
return { average, breakdown };
}The breakdown array is ordered 5→1 intentionally — that's the visual order on most e-commerce UIs. Pass this into your histogram component (next section) and it just works.
For the badge itself, you probably want something like this inline:
function ScoreBadge({ average, count }: { average: number; count: number }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 48, fontWeight: 700, lineHeight: 1 }}>
{average.toFixed(1)}
</span>
<div>
<StarRating value={Math.round(average)} readOnly />
<p style={{ margin: 0, fontSize: 13, color: '#6B7280' }}>
{count} review{count !== 1 ? 's' : ''}
</p>
</div>
</div>
);
}Look, that 48px font size for the score number isn't arbitrary. It's the size that reads clearly at a glance on mobile while staying proportional to a 24 px star row beside it. Go smaller and it competes visually; go bigger and it dominates the whole card.
The Histogram Breakdown
The bar chart breakdown — five horizontal bars showing how many people gave 1 through 5 stars — is the feature that separates a real review section from a toy widget. It's also dead simple once you have your breakdown array.
function RatingHistogram({
breakdown,
total,
}: {
breakdown: number[];
total: number;
}) {
const labels = [5, 4, 3, 2, 1];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, width: '100%' }}>
{breakdown.map((count, i) => {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, textAlign: 'right', fontSize: 13 }}>
{labels[i]}
</span>
<div
style={{
flex: 1,
height: 8,
borderRadius: 4,
background: '#E5E7EB',
overflow: 'hidden',
}}
>
<div
style={{
height: '100%',
width: `${pct}%`,
background: '#FBBF24',
borderRadius: 4,
transition: 'width 0.4s ease',
}}
/>
</div>
<span style={{ width: 28, fontSize: 13, color: '#6B7280' }}>
{pct}%
</span>
</div>
);
})}
</div>
);
}That 0.4s ease transition on the bar width is worth keeping. When the component mounts with real data, the bars animate in naturally — it makes the page feel alive without any Framer Motion overhead. If you are running Framer Motion already, swap the inline style for a motion.div and animate={{ width: pct + '%' }} for spring physics.
One more thing — make sure the bar track background (#E5E7EB) has enough contrast against both light and dark backgrounds. In dark mode you'd flip it to something around #374151. If you're wiring up dark mode with CSS custom properties, this is exactly the kind of low-level token that belongs in your design token system.
That said, the yellow fill color #FBBF24 (Tailwind's amber-400) is a good default that stays legible against white, near-white, and dark cards. It's the color Amazon's stars use for a reason — it reads at tiny sizes and still looks intentional at display scale.
Composing the Full Review Section
Now wire everything together. The composition pattern here is straightforward: one parent holds the review list in state, derives the stats, and passes slices down to each child. No context needed unless you're building a library.
export function ReviewSection({ productId }: { productId: string }) {
const [reviews, setReviews] = useState<Review[]>(MOCK_REVIEWS);
const [draft, setDraft] = useState(0);
const { average, breakdown } = computeStats(reviews);
const handleSubmit = (rating: number, body: string) => {
setReviews((prev) => [
...prev,
{
id: crypto.randomUUID(),
author: 'You',
rating,
body,
date: new Date().toISOString(),
},
]);
};
return (
<section aria-label="Customer reviews">
<div style={{ display: 'flex', gap: 32, flexWrap: 'wrap' }}>
<ScoreBadge average={average} count={reviews.length} />
<RatingHistogram breakdown={breakdown} total={reviews.length} />
</div>
<WriteReview rating={draft} onRatingChange={setDraft} onSubmit={handleSubmit} />
<div style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 16 }}>
{reviews.map((r) => (
<ReviewCard key={r.id} review={r} />
))}
</div>
</section>
);
}The WriteReview component is just a form — a StarRating picker, a textarea, and a submit button. Nothing exotic. What you do want is to clear the draft rating and textarea on submit, and probably add some optimistic update logic so the histogram updates before any server round-trip confirms.
For the individual ReviewCard, structure the markup semantically: a <article> per review, <header> with author and date, and the body text in a <p>. Screen readers navigate by landmark, and having twenty unlabelled <div> blocks in a row is rough on anyone using a rotor.
If you want to take this further — filtering by star count, sorting by date or helpfulness, pagination — the pagination-react and data-table-filters-react articles have ready-made patterns you can drop directly into the review list.
Styling Options: Tailwind, CSS Modules, or Inline
The examples above used inline styles to stay dependency-free, but you probably don't want that in production. A few options worth considering depending on your stack.
If you're on Tailwind, the star SVG hover transition becomes hover:fill-amber-400 transition-colors duration-100 and you get consistent colors from your design token config. The histogram bar fills work nicely as bg-amber-400 with a transition-[width] duration-400 ease-in-out utility — Tailwind v4, released in early 2025, made arbitrary transition properties much cleaner to write.
CSS Modules give you the most isolation — no class name collisions when you embed this inside a larger design system or package it as a library. Worth noting: if you're authoring a shared component library and plan to publish this to npm, CSS Modules is genuinely the right call over Tailwind.
That said, if you're already running Empire UI's style system, you can lean on the existing box shadow generator to style the review card container and the gradient generator to add a subtle score-bar background. Everything speaks the same visual language without any custom CSS files.
FAQ
Store the value as a float (e.g. 3.5), clip individual stars with an SVG clipPath at 50% width, and adjust your onChange handler to snap to 0.5 increments. It's more SVG work but the interaction model stays the same.
role="radiogroup" with individual role="radio" children is more semantically precise, but role="group" with a single tabstop and arrow-key handlers works fine in practice and is less code. Both pass WCAG 2.1 AA if you implement keyboard nav.
Replace the local state with a useMutation call (TanStack Query works well here) and POST to your API on submit. Optimistically append the review to the list so the UI doesn't feel slow, then reconcile with the server response.
Yes — swap the inline width style for a Framer Motion animate prop or use a CSS animation with @keyframes and animation-fill-mode: both. The 0.4s ease transition shown in the article is the minimal version that works without any animation library.