Code
"use client";
import * as React from "react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RatingStarsProps {
/** 0–max, supports halves. */
value: number;
max?: number;
/** Make it an input. */
onChange?: (value: number) => void;
count?: number;
size?: number;
className?: string;
}
export function RatingStars({ value, max = 5, onChange, count, size = 18, className }: RatingStarsProps) {
const [hover, setHover] = React.useState<number | null>(null);
const shown = hover ?? value;
const interactive = Boolean(onChange);
return (
<div className={cn("inline-flex items-center gap-2", className)}>
<div
className="flex items-center"
role={interactive ? "slider" : "img"}
aria-label={`Rated ${value} out of ${max}`}
aria-valuenow={interactive ? value : undefined}
aria-valuemin={interactive ? 0 : undefined}
aria-valuemax={interactive ? max : undefined}
tabIndex={interactive ? 0 : undefined}
onKeyDown={(e) => {
if (!onChange) return;
if (e.key === "ArrowRight") onChange(Math.min(max, value + 0.5));
if (e.key === "ArrowLeft") onChange(Math.max(0, value - 0.5));
}}
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: max }, (_, i) => {
const fill = Math.max(0, Math.min(1, shown - i));
return (
<span
key={i}
className={cn("relative", interactive && "cursor-pointer")}
style={{ width: size, height: size }}
onMouseMove={(e) => {
if (!interactive) return;
const r = e.currentTarget.getBoundingClientRect();
setHover(i + (e.clientX - r.left < r.width / 2 ? 0.5 : 1));
}}
onClick={() => hover != null && onChange?.(hover)}
>
<Star className="absolute inset-0 text-muted-foreground/30" style={{ width: size, height: size }} fill="currentColor" strokeWidth={0} />
<span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
<Star className="text-amber-400" style={{ width: size, height: size }} fill="currentColor" strokeWidth={0} />
</span>
</span>
);
})}
</div>
<span className="text-sm font-medium tabular-nums">{shown.toFixed(1)}</span>
{count !== undefined && <span className="text-sm text-muted-foreground">({count.toLocaleString()} reviews)</span>}
</div>
);
}