A five-star rating where hover previews the fill, click commits with a single subtle pop, arrow keys adjust, and a readonly mode renders fractional averages.
npx shadcn@latest add @paragon/rating-input"use client";
import * as React from "react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RatingInputProps
extends Omit<
React.ComponentProps<"div">,
"defaultValue" | "onChange" | "role"
> {
/** Controlled rating, 1–max (0 = unrated). Fractions allowed in readonly mode. */
value?: number;
/** Initial rating when uncontrolled. */
defaultValue?: number;
onValueChange?: (value: number) => void;
max?: number;
/** Static display mode; supports fractional values. */
readonly?: boolean;
/** Form field name; renders a hidden input. */
name?: string;
/** Disables the commit pop. */
static?: boolean;
}
/**
* A star rating input. Hover previews the fill up to the cursor, click
* commits with a single subtle pop on the selected star, and arrow keys
* adjust the value. `readonly` renders a static (fraction-capable) display.
*/
export function RatingInput({
value: valueProp,
defaultValue = 0,
onValueChange,
max = 5,
readonly = false,
name,
static: isStatic = false,
className,
"aria-label": ariaLabel = "Rating",
...props
}: RatingInputProps) {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const value = valueProp ?? uncontrolled;
const [preview, setPreview] = React.useState<number | null>(null);
// Bumping `n` re-keys the popped star so the animation restarts cleanly.
const [pop, setPop] = React.useState<{ index: number; n: number } | null>(
null,
);
const buttonRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const commit = (next: number, withPop: boolean) => {
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
if (withPop && !isStatic) {
setPop((prev) => ({ index: next - 1, n: (prev?.n ?? 0) + 1 }));
}
};
if (readonly) {
return (
<div
role="img"
aria-label={`Rated ${value} out of ${max}`}
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
>
{Array.from({ length: max }, (_, i) => {
const fill = Math.max(0, Math.min(1, value - i));
return (
<span key={i} className="relative inline-flex" aria-hidden>
<Star className="size-5 fill-transparent text-muted-foreground/40" />
{fill > 0 && (
<Star
className="absolute inset-0 size-5 fill-amber-500 text-amber-500"
style={
fill < 1
? { clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` }
: undefined
}
/>
)}
</span>
);
})}
</div>
);
}
const shown = preview ?? value;
return (
<div
role="radiogroup"
aria-label={ariaLabel}
onMouseLeave={() => setPreview(null)}
onKeyDown={(e) => {
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowUp") {
next = Math.min(max, value + 1);
} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
next = Math.max(1, value - 1);
} else if (e.key === "Home") {
next = 1;
} else if (e.key === "End") {
next = max;
}
if (next !== null) {
e.preventDefault();
// Keyboard-initiated: adjust without the pop.
commit(next, false);
buttonRefs.current[next - 1]?.focus();
}
}}
className={cn("inline-flex items-center", className)}
{...props}
>
<style href="paragon-rating-input" precedence="paragon">{`
@keyframes pg-rating-pop {
0% { scale: 1; }
50% { scale: 1.15; }
100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
@keyframes pg-rating-pop {
0%, 100% { scale: 1; }
}
}
`}</style>
{Array.from({ length: max }, (_, i) => {
const starValue = i + 1;
const filled = starValue <= shown;
const popped = pop?.index === i;
return (
<button
key={i}
ref={(node) => {
buttonRefs.current[i] = node;
}}
type="button"
role="radio"
aria-checked={value === starValue}
aria-label={`${starValue} star${starValue === 1 ? "" : "s"}`}
tabIndex={
value === starValue || (value === 0 && i === 0) ? 0 : -1
}
onMouseEnter={() => setPreview(starValue)}
onClick={() => {
setPreview(null);
commit(starValue, true);
}}
className="relative flex items-center justify-center p-0.5 after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2"
>
<span
key={popped ? pop.n : "idle"}
className="inline-flex"
style={
popped
? {
animation: `pg-rating-pop 250ms var(--ease-bounce)`,
}
: undefined
}
>
<Star
aria-hidden
className={cn(
"size-5 transition-colors duration-100",
filled
? "fill-amber-500 text-amber-500"
: "fill-transparent text-muted-foreground/40",
)}
/>
</span>
</button>
);
})}
{name && <input type="hidden" name={name} value={value} />}
</div>
);
}