All componentsform
Rating
Interactive
Value: 3
Read-only
Out of 10
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/rating.jsonnpm dependencies: lucide-react
Props
RatingProps
| Prop | Type | Required |
|---|---|---|
| value | number | Yes |
| onChange | (value: number) => void | No |
| max | number | No |
| readOnly | boolean | No |
| className | string | No |
| aria-label | string | No |
Code
rating.tsx
"use client";
import * as React from "react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RatingProps {
value: number;
onChange?: (value: number) => void;
max?: number;
readOnly?: boolean;
className?: string;
"aria-label"?: string;
}
function StarIcon({ filled }: { filled: boolean }) {
return (
<Star
aria-hidden="true"
className={cn("size-5", filled ? "fill-primary text-primary" : "fill-none text-muted-foreground")}
/>
);
}
/** ARIA radiogroup pattern: one star selected among discrete options, roving tabindex. */
export function Rating({ value, onChange, max = 5, readOnly = false, className, "aria-label": ariaLabel = "Rating" }: RatingProps) {
const interactive = !readOnly && Boolean(onChange);
const [hovered, setHovered] = React.useState<number | null>(null);
const display = hovered ?? value;
if (!interactive) {
return (
<div role="img" aria-label={`${value} out of ${max} stars`} className={cn("flex gap-0.5", className)}>
{Array.from({ length: max }, (_, index) => (
<StarIcon key={index} filled={index < value} />
))}
</div>
);
}
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn("flex gap-0.5", className)}
onMouseLeave={() => setHovered(null)}
>
{Array.from({ length: max }, (_, index) => {
const starValue = index + 1;
const isTabStop = value === starValue || (value === 0 && starValue === 1);
return (
<button
key={index}
type="button"
role="radio"
aria-checked={value === starValue}
aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
tabIndex={isTabStop ? 0 : -1}
onClick={() => onChange?.(starValue)}
onMouseEnter={() => setHovered(starValue)}
onKeyDown={(event) => {
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowUp") next = Math.min(max, (value || 0) + 1);
else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = Math.max(1, (value || 1) - 1);
if (next === null) return;
event.preventDefault();
onChange?.(next);
const group = event.currentTarget.parentElement;
(group?.children[next - 1] as HTMLElement | undefined)?.focus();
}}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<StarIcon filled={starValue <= display} />
</button>
);
})}
</div>
);
}