Star Rating
A five-star input with half-star precision on hover, a word for each score and a spring when you set it.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/star-rating.json'use client'
import { useState } from 'react'
import './star-rating.css'
export interface StarRatingProps {
value?: number
defaultValue?: number
onValueChange?: (n: number) => void
half?: boolean
size?: 'md' | 'lg'
name?: string
fill?: 'dark' | 'light'
className?: string
}
const WORDS = ['Not rated', 'Awful', 'Poor', 'Okay', 'Good', 'Great']
const D = "M8 1.8l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z"
/** Five-star input with half-star hover precision. */
export function StarRating({ value, defaultValue = 0, onValueChange, half = true, size = 'lg', name, fill = 'dark', className }: StarRatingProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const [hover, setHover] = useState<number | null>(null)
const [pop, setPop] = useState(0)
const step = half ? 0.5 : 1
const set = (n: number) => { const c = Math.max(0, Math.min(5, Math.round(n / step) * step)); setInner(c); setPop((p) => p + 1); onValueChange?.(c) }
const shown = hover ?? v
const at = (e: React.MouseEvent<HTMLSpanElement>, i: number) => {
const r = e.currentTarget.getBoundingClientRect()
return i + (half && e.clientX - r.left < r.width / 2 ? 0.5 : 1)
}
return (
<div className={['sd-str', className].filter(Boolean).join(' ')} data-fill={fill} data-size={size}>
<div
className="sd-str__row"
role="slider"
tabIndex={0}
aria-label="Rating"
aria-valuemin={0}
aria-valuemax={5}
aria-valuenow={v}
aria-valuetext={`${v} stars, ${WORDS[Math.ceil(v)]}`}
onPointerLeave={() => setHover(null)}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowRight: v + step, ArrowUp: v + step, ArrowLeft: v - step, ArrowDown: v - step, Home: 0, End: 5 }
if (e.key in m) { e.preventDefault(); set(m[e.key]) }
}}
>
{[0, 1, 2, 3, 4].map((i) => {
const f = Math.max(0, Math.min(1, shown - i))
return (
<span key={`${i}-${pop}`} className="sd-str__s" data-pop={(pop > 0 && i < v) || undefined} style={{ animationDelay: `${i * 40}ms` }} onPointerMove={(e) => setHover(at(e, i))} onClick={(e) => set(at(e, i))}>
<svg viewBox="0 0 16 16" aria-hidden><path className="e" d={D} /></svg>
<svg viewBox="0 0 16 16" aria-hidden style={{ clipPath: `inset(0 ${100 - f * 100}% 0 0)` }}><path className="f" d={D} /></svg>
</span>
)
})}
</div>
<div className="sd-str__w" aria-hidden>{WORDS[Math.ceil(shown)]}{shown > 0 && <span>{shown.toFixed(half ? 1 : 0)}</span>}</div>
{name && <input type="hidden" name={name} value={v} />}
</div>
)
}.sd-str { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-str[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-str button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-str :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-str .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-str { display: grid; justify-items: center; gap: 10px; }
.sd-str__row { display: flex; gap: 4px; outline: 0; border-radius: 8px; padding: 4px; cursor: pointer; touch-action: none; }
.sd-str__row:focus-visible { box-shadow: 0 0 0 2px var(--fg); }
.sd-str__s { position: relative; width: 30px; height: 30px; transition: transform 160ms; }
.sd-str[data-size='lg'] .sd-str__s { width: 38px; height: 38px; }
.sd-str__s svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.sd-str__s .e { fill: none; stroke: var(--mute); stroke-width: 1; stroke-linejoin: round; }
.sd-str__s .f { fill: var(--fg); }
.sd-str__s[data-pop] { animation: sd-str-pop 420ms cubic-bezier(0.3, 1.8, 0.4, 1) both; }
@keyframes sd-str-pop { 40% { transform: scale(1.25) rotate(-8deg); } }
.sd-str__w { min-height: 20px; font-size: 13.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.sd-str__w span { color: var(--mute); font-weight: 400; margin-left: 6px; }
@media (prefers-reduced-motion: reduce) { .sd-str *, .sd-str *::before, .sd-str *::after { animation: none !important; transition: none !important; } }Overview
Hovering previews the score in half steps, following the pointer within each star; clicking sets it and the chosen stars give a small spring. A word (“Great”, “Okay”) tracks the value.
It is a slider under the hood, so arrow keys step by a half and the value posts with forms.
When to use
- Reviews, post-purchase feedback, rating content.
When not to use
- Single-question satisfaction surveys: a labelled scale is clearer.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/star-rating.json$
npx shadcn@latest add https://sundaeui.dev/r/star-rating.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/star-rating.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/star-rating.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy star-rating.tsx and star-rating.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { StarRating } from '@/components/sundae/star-rating'
<StarRating />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValue0–5 in steps of 0.5. | number | — |
onValueChangeCalled on change. | (n: number) => void | — |
halfAllow half stars. | boolean | true |
nameHidden input name for forms. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- role="slider" with aria-valuetext such as “3.5 stars, Good”.
- Arrow keys step by a half (or one), Home/End clear or max.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-str; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Decrease / increase
- Home / End
- 0 / 5
FAQ
- Is Star Rating free?
- Yes. Star Rating is free for personal and commercial projects, with full source on this page.
- How do I install Star Rating?
- Run npx shadcn@latest add https://sundaeui.dev/r/star-rating.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Star Rating support keyboard and screen readers?
- role="slider" with aria-valuetext such as “3.5 stars, Good”. Arrow keys step by a half (or one), Home/End clear or max.
- Does Star Rating respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Rating Scale
Forms · RefinedOTP Field
Forms · Refined- Pro
Wheel Picker
Forms · Refined Password Strength
Forms · RefinedWas This Helpful
Forms · RefinedSearch Field
Forms · Refined