Browse the docs
ComponentsNo. 44 of 109
Rating
Stars for giving a score as a radio group, or for showing one read-only with fractional fills.
Installation
pnpm dlx shadcn@latest add @corsair-ui/ratingNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utils
- npm
@radix-ui/react-radio-groupclass-variance-authorityclsxlucide-reacttailwind-merge
Examples
Read-only
Fractions fill part of a star, and the whole reads as one image.
Interactive
Not rated yet. Arrow keys move between stars.
API
| Prop | Type | Default |
|---|---|---|
value / defaultValue / onValueChangeThe score. | number / number / (value) => void | — |
maxHow many stars. | number | 5 |
readOnlyShows a score, fractions included, as one image. | boolean | false |
getValueLabel / starLabelWhat screen readers hear: "4.5 out of 5", "3 stars". | (value, max) => string / (n) => string | — |
nameSubmits the score with a form. | string | — |
Built on Radix Radio Group; every prop it takes is passed through.
Accessibility
- Interactive, it is a radio group: arrow keys pick a score and each star has a name.
- Read-only, it is role="img" with the score in words.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
Source
1 file, installed as source you own
rating.tsxView on GitHub"use client";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { cva } from "class-variance-authority";
import { StarIcon } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { cn } from "@/registry/default/lib/utils";
const ratingVariants = cva("inline-flex w-fit items-center", {
variants: {
size: {
sm: "gap-0.5 [&_[data-slot=rating-star]]:size-4",
default: "gap-0.5 [&_[data-slot=rating-star]]:size-5",
lg: "gap-1 [&_[data-slot=rating-star]]:size-6",
},
},
defaultVariants: {
size: "default",
},
});
type RatingSize = "sm" | "default" | "lg";
const defaultValueLabel = (value: number, max: number) => `${value} out of ${max}`;
const defaultStarLabel = (stars: number) => `${stars} ${stars === 1 ? "star" : "stars"}`;
/**
* One star. `fill` goes from 0 to 1: the filled copy is clipped to that
* share of the width, over an empty star. Both copies carry `data-filled`
* so their colours can be changed from the Rating's className.
*/
function Star({ fill, className }: { fill: number; className?: string }) {
return (
<span
data-slot="rating-star"
data-fill={fill >= 1 ? "full" : fill > 0 ? "partial" : "empty"}
className={cn("relative inline-flex shrink-0", className)}
>
<StarIcon
aria-hidden="true"
data-filled="false"
className="text-muted-foreground/40 size-full fill-current"
/>
{fill > 0 ? (
<StarIcon
aria-hidden="true"
data-filled="true"
className="text-warning absolute inset-0 size-full fill-current"
style={fill < 1 ? { clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` } : undefined}
/>
) : null}
</span>
);
}
interface RatingProps extends Omit<
ComponentProps<"div">,
"defaultValue" | "onChange" | "dir" | "children"
> {
/** Number of stars picked (controlled). Read-only ratings can be fractional, like 4.5. */
value?: number;
/** Number of stars picked at first (uncontrolled). */
defaultValue?: number;
onValueChange?: (value: number) => void;
/** Number of stars. */
max?: number;
/** Show the value without letting it change. */
readOnly?: boolean;
disabled?: boolean;
required?: boolean;
size?: RatingSize;
/** Submits the value with a form under this name. */
name?: string;
dir?: "ltr" | "rtl";
/** Accessible name of the rating. */
"aria-label"?: string;
/** Read-only: how the value is read to screen readers. */
getValueLabel?: (value: number, max: number) => string;
/** Interactive: the accessible name of each star. */
starLabel?: (stars: number) => string;
}
/**
* Stars for giving or showing a score. Interactive, it is a radio group
* with one radio per star: Tab reaches it, arrow keys change the score,
* and each star is read as "3 stars"; pointing at a star previews it
* (`data-hovered`). With `readOnly` it is a single image read as
* "Rating: 4.5 out of 5", and fractional values fill part of a star.
* Filled stars use `text-warning`, empty ones `text-muted-foreground/40`;
* restyle them with `[&_[data-filled=true]]:text-primary` on the Rating.
* Nothing moves, so there is nothing to reduce for reduced motion.
*
* @example
* <Rating defaultValue={3} onValueChange={setScore} aria-label="Your rating" />
* <Rating readOnly value={4.5} />
*/
function Rating({
value: valueProp,
defaultValue = 0,
onValueChange,
max = 5,
readOnly = false,
disabled,
required,
size = "default",
name,
dir,
"aria-label": label = "Rating",
getValueLabel = defaultValueLabel,
starLabel = defaultStarLabel,
className,
...props
}: RatingProps) {
const [internal, setInternal] = useState(defaultValue);
const [hovered, setHovered] = useState<number | null>(null);
const count = Math.max(1, Math.floor(max));
const value = Math.min(Math.max(valueProp ?? internal, 0), count);
const stars = Array.from({ length: count }, (_, index) => index + 1);
if (readOnly) {
return (
<div
role="img"
aria-label={`${label}: ${getValueLabel(value, count)}`}
data-slot="rating"
data-readonly=""
data-size={size}
data-value={value}
dir={dir}
className={cn(ratingVariants({ size }), className)}
{...props}
>
{stars.map((star) => (
<Star key={star} fill={Math.min(Math.max(value - (star - 1), 0), 1)} />
))}
</div>
);
}
const picked = Math.round(value);
const shown = hovered ?? picked;
const change = (next: string) => {
const number = Number(next);
if (valueProp === undefined) setInternal(number);
onValueChange?.(number);
};
return (
<RadioGroupPrimitive.Root
data-slot="rating"
data-size={size}
data-value={picked}
data-hovered={hovered !== null ? "" : undefined}
aria-label={label}
value={picked > 0 ? String(picked) : ""}
onValueChange={change}
orientation="horizontal"
loop={false}
disabled={disabled}
required={required}
name={name}
dir={dir}
className={cn(ratingVariants({ size }), className)}
onPointerLeave={() => setHovered(null)}
{...props}
>
{stars.map((star) => (
<RadioGroupPrimitive.Item
key={star}
value={String(star)}
aria-label={starLabel(star)}
data-slot="rating-item"
data-hovered={hovered !== null && star <= hovered ? "" : undefined}
className={cn(
"inline-flex cursor-pointer rounded-[4px] outline-none",
"focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
onPointerEnter={(event) => {
if (event.pointerType === "mouse" && !disabled) setHovered(star);
}}
>
<Star fill={star <= shown ? 1 : 0} />
</RadioGroupPrimitive.Item>
))}
</RadioGroupPrimitive.Root>
);
}
export { Rating, ratingVariants, type RatingProps, type RatingSize };