Rating
Both ratings, which are different components: a radio group for choosing whole stars, and a non-interactive display with fractional fill, because an average of 4.3 drawn as 4.5 is a number the page invented.
- rating
- stars
- review
- score
- feedback
What's included
components/ui/rating.tsx- Needs lucide-react
Works with
- React
- Next.js
- Tailwind CSS
- TypeScript
npx hoverlab add rating
Or over MCP, from your editor's agent — no account needed.
License
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Live
Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.
Source
One file. Create components/ui/rating.tsx and paste.
'use client'
/**
* <Rating> — stars, in both of the two modes people forget are different.
*
* A rating widget does two unrelated jobs and the same component is usually
* shipped for both:
*
* input the user is choosing. It is a radio group, one tab stop,
* arrow keys move, and it needs whole values only — nobody
* clicks a half star on purpose.
* display the average of 1,284 ratings. It is 4.3, not 4, so it needs
* fractional fill; and it is not interactive, so it must not be
* focusable, must not have radio semantics, and should be one
* piece of text to a screen reader: "4.3 out of 5".
*
* The fractional fill is a clipped overlay rather than a half-star glyph —
* a `width: 43%` layer over the outlines. Half-star icons only do halves,
* and an average of 4.3 drawn as 4.5 is a number the page is making up.
*/
import * as React from 'react'
import { Star } from 'lucide-react'
export interface RatingProps {
value: number
max?: number
/** Omit for display mode — that is what makes it non-interactive. */
onChange?: (value: number) => void
size?: 'sm' | 'md' | 'lg'
/** Shown after the stars in display mode: "4.3 (1,284)". */
count?: number
/** Names the control when it takes input. */
label?: string
className?: string
}
const SIZES = { sm: 'h-3.5 w-3.5', md: 'h-5 w-5', lg: 'h-7 w-7' } as const
export function Rating({
value,
max = 5,
onChange,
size = 'md',
count,
label = 'Rating',
className = '',
}: RatingProps) {
const [hover, setHover] = React.useState<number | null>(null)
const interactive = typeof onChange === 'function'
const shown = hover ?? value
/* ---- Display: one label, no focus, fractional fill ---- */
if (!interactive) {
const percent = Math.max(0, Math.min(1, value / max)) * 100
return (
<span
className={`inline-flex items-center gap-1.5 ${className}`}
role="img"
aria-label={`${value.toFixed(1)} out of ${max}${count === undefined ? '' : `, ${count} ratings`}`}
>
<span className="relative inline-flex">
<span aria-hidden className="inline-flex gap-0.5 text-muted-foreground/40">
{Array.from({ length: max }, (_, i) => (
<Star key={i} className={SIZES[size]} />
))}
</span>
{/*
The filled layer, clipped to the fraction. `inset-inline-start`
so the fill grows from the first star in either direction, and
`overflow-hidden` on a sized box rather than a clip-path, which
Safari still antialiases badly at small sizes.
*/}
<span
aria-hidden
className="absolute inset-y-0 start-0 overflow-hidden"
style={{ width: `${percent}%` }}
>
<span className="inline-flex gap-0.5 text-amber-400">
{Array.from({ length: max }, (_, i) => (
<Star key={i} className={SIZES[size]} fill="currentColor" />
))}
</span>
</span>
</span>
<span className="text-sm font-medium tabular-nums text-foreground">
{value.toFixed(1)}
</span>
{count === undefined ? null : (
<span className="text-sm text-muted-foreground">
({count.toLocaleString()})
</span>
)}
</span>
)
}
/* ---- Input: a radio group over whole values ---- */
const move = (next: number) => {
const clamped = Math.max(1, Math.min(max, next))
onChange?.(clamped)
}
return (
<span
role="radiogroup"
aria-label={label}
className={`inline-flex items-center gap-0.5 ${className}`}
onPointerLeave={() => setHover(null)}
onKeyDown={(e) => {
// Up and Down rather than Left and Right: "more" and "less" are the
// axis here, and the vertical keys mean the same thing in every
// reading direction.
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
e.preventDefault()
move(value + 1)
} else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
e.preventDefault()
move(value - 1)
} else if (e.key === 'Home') {
e.preventDefault()
move(1)
} else if (e.key === 'End') {
e.preventDefault()
move(max)
}
}}
>
{Array.from({ length: max }, (_, i) => {
const star = i + 1
const filled = star <= shown
return (
<button
key={star}
type="button"
role="radio"
aria-checked={star === value}
aria-label={`${star} ${star === 1 ? 'star' : 'stars'}`}
tabIndex={star === value || (value === 0 && star === 1) ? 0 : -1}
onClick={() => onChange?.(star)}
onPointerEnter={() => setHover(star)}
className="rounded p-0.5 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transform-none"
>
<Star
className={`${SIZES[size]} ${filled ? 'text-amber-400' : 'text-muted-foreground/40'}`}
fill={filled ? 'currentColor' : 'none'}
aria-hidden
/>
</button>
)
})}
</span>
)
}
Props
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
valuerequired | number | — |
max | number | 5 |
onChangeOmit for display mode — that is what makes it non-interactive. | (value: number) => void | — |
size | 'sm' | 'md' | 'lg' | 'md' |
countShown after the stars in display mode: "4.3 (1,284)". | number | — |
labelNames the control when it takes input. | string | 'Rating' |
className | string | '' |
For AI
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
See the prompt
More selection
View allSegmented Control
Pick exactly one, built as a radio group rather than three buttons, so a screen reader says which is chosen — with a sliding indicator that animates one transform instead of repainting backgrounds.
Emoji Reactions
A reaction row where each pill is a toggle that says whether you are one of the count, plus a short picker — announced with names, since "👍 3" is read as a shrug by a screen reader.