EmpireUI
Get Pro
← Blog7 min read#star-rating#react#tailwind

Star Rating Component in React: Accessible, Animated, Themeable

Build a star rating component in React that's fully accessible, smoothly animated, and works with any Tailwind theme — from e-commerce reviews to SaaS feedback forms.

Five golden stars arranged in a row against a dark background, representing a star rating UI component

Why Star Ratings Are Harder Than They Look

Honestly, a star rating widget seems like a two-hour job until you actually sit down to build it properly. Then the scope creep starts: half-star support, keyboard navigation, screen reader announcements, hover previews that don't flicker, and a controlled vs. uncontrolled API that plays nicely with React Hook Form.

Most open-source star rating packages solve one or two of those concerns and punt on the rest. You end up patching accessibility after the fact, fighting CSS specificity to restyle the default yellow, and realising the animation runs on every render instead of only on user interaction. That's the gap this article fills.

We'll build a component you can drop into any project running React 18+ and Tailwind v4.0.2. No extra dependencies. The finished component is also the foundation for the Empire UI animated button pattern — same principle of coupling visual feedback tightly to state transitions.

Component API Design: Controlled and Uncontrolled Modes

Before writing a single line of JSX, settle the API surface. Star ratings appear in two distinct contexts: read-only display (product listings, testimonial cards) and interactive input (review forms, feedback widgets). Trying to serve both from a single prop set gets messy fast.

A clean split: when you pass value without onChange, the component renders in read-only mode. Pass both and it becomes a controlled input. Pass neither and it runs as an uncontrolled component with an internal useState. This mirrors what native <input> elements do and what React Hook Form expects.

Keep the prop list minimal. max defaults to 5. size accepts 'sm' | 'md' | 'lg' and maps to 16px / 24px / 32px star icons. allowHalf is a boolean that unlocks 0.5-step granularity. color is a Tailwind arbitrary value string like 'text-amber-400' so you don't have to fork the component to match a brand palette.

Building the Star SVG and Hover State Logic

Don't reach for a third-party icon library just for a star. A plain inline SVG path keeps the bundle tiny and lets you control fill directly from React state — which is critical for partial-fill half-star rendering.

const StarIcon = ({
  filled,
  half,
  size = 24,
  color = 'text-amber-400',
}: {
  filled: boolean;
  half?: boolean;
  size?: number;
  color?: string;
}) => {
  const id = React.useId();
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      aria-hidden="true"
      className={color}
    >
      {half && (
        <defs>
          <linearGradient id={id}>
            <stop offset="50%" stopColor="currentColor" />
            <stop offset="50%" stopColor="transparent" />
          </linearGradient>
        </defs>
      )}
      <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={half ? `url(#${id})` : filled ? 'currentColor' : 'none'}
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
};

The linearGradient trick for half-stars is underused. It fills the left 50% of the polygon with currentColor and the right 50% with transparent, so a single SVG handles all three fill states — empty, half, full — without any clip-path gymnastics. Each star gets a unique gradient ID via React.useId() so multiple rating components on the same page don't collide.

Hover Preview, Click Handler, and Controlled State

The hover preview is where most implementations introduce a subtle bug: they update the displayed value during hover and then have to reconcile it with the committed value on mouse-leave. The fix is two separate pieces of state — hoverIndex and committedValue — where hoverIndex is null when the pointer isn't over the widget.

export function StarRating({
  value,
  onChange,
  max = 5,
  size = 24,
  allowHalf = false,
  color = 'text-amber-400',
  readOnly = false,
}: StarRatingProps) {
  const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);
  const [internal, setInternal] = React.useState(value ?? 0);
  const committed = value !== undefined ? value : internal;
  const displayed = hoverIndex !== null ? hoverIndex : committed;

  const handleClick = (index: number) => {
    if (readOnly) return;
    if (onChange) onChange(index);
    else setInternal(index);
  };

  return (
    <div
      className="inline-flex items-center gap-[4px]"
      role="group"
      aria-label="Star rating"
      onMouseLeave={() => setHoverIndex(null)}
    >
      {Array.from({ length: max }, (_, i) => {
        const starValue = i + 1;
        const isHalf = allowHalf && displayed === i + 0.5;
        const isFilled = displayed >= starValue;
        return (
          <button
            key={i}
            type="button"
            aria-label={`Rate ${starValue} out of ${max}`}
            aria-pressed={committed === starValue}
            disabled={readOnly}
            className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 rounded transition-transform duration-150 hover:scale-110 active:scale-95 disabled:cursor-default"
            onClick={() => handleClick(starValue)}
            onMouseEnter={() => !readOnly && setHoverIndex(starValue)}
          >
            <StarIcon filled={isFilled} half={isHalf} size={size} color={color} />
          </button>
        );
      })}
    </div>
  );
}

A few things worth noting in that snippet. The gap-[4px] uses Tailwind's arbitrary value syntax — 4px spacing felt tighter than the default gap-1 (which is actually 4px in Tailwind v4.0.2's 4px base scale, but explicit is clearer for reviewers). The transition-transform duration-150 on each button gives the hover scale a snappy feel without feeling sluggish. And aria-pressed correctly communicates the selected state to screen readers.

Keyboard Navigation and Screen Reader Accessibility

Is your star rating actually usable without a mouse? That question trips up more teams than you'd think. The <button> element gives you Tab navigation for free, but arrow-key support inside the group — moving between stars without Tab-hopping across the whole page — requires a bit more work.

Wrap the star buttons in a role="radiogroup" and convert each to a role="radio" button pattern. The keyboard contract is then: Tab to focus the group, arrow keys to move between stars, Space or Enter to commit. This matches what WCAG 2.2 Success Criterion 1.3.1 expects for grouped form controls. Combine this with a visually hidden <span aria-live="polite"> that announces the current hover value and you'll sail through most accessibility audits.

Don't forget to pair this with your app's theme toggle — make sure the amber stars have sufficient contrast against both the light and dark surface colours your design uses. In dark mode, text-amber-400 against a bg-zinc-900 surface gives roughly a 4.8:1 contrast ratio, which clears AA for non-text elements.

Animation: Scale, Glow, and Fill Transitions

The hover:scale-110 utility handles the scale pop already. But a fill-colour transition on the SVG takes more effort because Tailwind's transition utility doesn't watch SVG fill changes by default — those animate with CSS transition: fill 120ms ease.

Add a small drop-shadow glow on hover to reinforce the selection. In Tailwind v4.0.2 you can do this with hover:drop-shadow-[0_0_6px_rgba(251,191,36,0.7)] — that's the amber-400 hex in rgba form, roughly rgba(251,191,36,0.7). It reads like overkill written out long-form, but the visual result is a subtle star-glow that users notice without being able to name it.

For the committed selection, add a brief bounce: a @keyframes that scales from 1 → 1.25 → 0.95 → 1 over 300ms. Trigger it by toggling a CSS class when committedValue changes. Keep the animation off for users who have prefers-reduced-motion: reduce set — wrap the keyframe rule in @media (prefers-reduced-motion: no-preference) and you're done.

If you're building a card-based UI that surfaces ratings, this component slots naturally inside an Empire UI cards stack — the star widget's 24px default height aligns with the card's metadata row without any extra margin tuning.

Theming Across 40 Empire UI Styles

Empire UI ships 40 visual styles — glassmorphism, brutalism, neon, editorial, and more. A star rating that only works in amber-on-white is useless for most of them. The color prop accepts any Tailwind text-colour class string, so text-violet-400 for neon, text-zinc-100 for brutalism, or text-sky-300 for glass all work without touching the component internals.

For glassmorphism surfaces specifically, swap the star stroke to rgba(255,255,255,0.4) and fill to rgba(255,255,255,0.85) via CSS custom properties. If you haven't already read what glassmorphism actually is, that article explains why the semi-transparent surface makes standard currentColor fills look washed out and how to fix it with an explicit RGBA override.

You can also expose a className prop that gets applied to the root wrapper if you need per-instance overrides. That keeps the component's default styles intact while giving consumers an escape hatch — same pattern Empire UI uses on animated tabs so they compose without fighting each other's specificity.

Integration With React Hook Form and Next.js

Dropping this into a React Hook Form setup is three lines. Register a field, pass value={field.value} and onChange={field.onChange}, done. The component doesn't care about the form library — it just calls onChange with a number, same as a regular <input type="number"> would.

In a Next.js app (App Router, React Server Components) mark the file with 'use client' at the top since it uses useState and mouse event handlers. The read-only variant — no onChange, readOnly={true} — can actually be a Server Component if you strip out the hover logic, which is worth doing for product listing pages that render hundreds of ratings server-side.

Server-side rendering of the committed star count also improves Core Web Vitals. The stars render with the correct fill on first paint rather than flashing empty then hydrating to filled. That's a small but real Cumulative Layout Shift win on high-traffic product pages.

FAQ

How do I make the star rating work with React Hook Form?

Use the Controller component from React Hook Form: <Controller name="rating" control={control} render={({ field }) => <StarRating value={field.value} onChange={field.onChange} />} />. The component calls onChange with a plain number so no transformation is needed.

Can I render half-star values from a database (e.g., 3.5 out of 5)?

Yes. Pass value={3.5} and allowHalf={true}. The component calculates each star's fill state by comparing starIndex + 0.5 against the value, so 3.5 renders three full stars and one half-filled star automatically.

How do I prevent the rating from being changed by the user (read-only display)?

Pass readOnly={true}. This disables all button elements and removes hover handlers. You can also just pass value with no onChange for the same effect, though readOnly is more explicit and adds disabled to the DOM buttons for accessibility tools.

The animation plays on every render, not just on user interaction. How do I fix it?

Track a separate justSelected ref that you set to true inside handleClick and immediately reset after the animation duration via setTimeout. Apply the bounce class only when justSelected.current is true. This way re-renders caused by parent state changes don't replay the animation.

Does the SVG gradient approach for half-stars work in Safari?

Yes, as of Safari 15.4+ which shipped in early 2022. The inline <linearGradient> with React.useId() for unique IDs works correctly. The one edge case is Safari's handling of currentColor inside a gradient stop — make sure the SVG has an explicit color CSS property set, which the Tailwind text-colour utility handles for you.

How do I support keyboard arrow-key navigation between stars?

Add an onKeyDown handler to each star button. On ArrowRight/ArrowUp, call handleClick(Math.min(current + 1, max)). On ArrowLeft/ArrowDown, call handleClick(Math.max(current - 1, 0)). Also manage tabIndex so only the currently selected (or first) star is in the tab order — the rest get tabIndex={-1} — which is the roving tabindex pattern.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React UI Components Complete Reference: 60+ Patterns with CodeSide Sheet Drawer in React: Slide-In Panels with AnimationReact Animation Best Practices: Performance, Accessibility, APIsAccessibility-First Design Systems: WCAG 2.2 in Every Component