Browse the docs
ComponentsNo. 57 of 148
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.
Variations
The same component with more motion. Each takes these props and keeps this behaviour, so swapping is a change of import.
API
| Prop | Type | Default |
|---|---|---|
value / defaultValue / onValueChangeThe score. | number / number / (value) => void | — |
maxHow many stars. | number | 5 |
sizeStars of 12, 16, 20 or 24 px. | "xs" | "sm" | "default" | "lg" | "default" |
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.
Styling
Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=rating-item]]:text-primary".
- Each star is an empty icon (data-filled="false") under a filled one (data-filled="true"): [&_[data-filled=false]]:text-muted and [&_[data-filled=true]]:text-primary recolour them.
- size="xs" gives 12 px stars for dense cards; any other size goes through [&_[data-slot=rating-star]]:size-*.
- data-slot
ratingrating-itemrating-star
- State
data-fillempty · full · partialdata-filledfalse · truedata-hoveredcomputeddata-readonlycomputeddata-sizecomputeddata-valuecomputed
Radix Radio Group also sets its own attributes on the parts, such as data-state and data-disabled.
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: {
xs: "gap-0.5 [&_[data-slot=rating-star]]:size-3",
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 = "xs" | "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. Exported for
* the animated variants of the rating, which draw the same star.
*/
function RatingStar({ 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 with a mouse or a
* pen 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. For a
* finger sweeping the row, with motion, use `wave-rating`: same props.
*
* @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) => (
<RatingStar 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) => {
// Mice and pens can point without pressing; a finger cannot.
if (event.pointerType !== "touch" && !disabled) setHovered(star);
}}
>
<RatingStar fill={star <= shown ? 1 : 0} />
</RadioGroupPrimitive.Item>
))}
</RadioGroupPrimitive.Root>
);
}
export { Rating, RatingStar, ratingVariants, type RatingProps, type RatingSize };