# Rating

> A star rating on Vercel's UI SDK with whole or half stars, a hover preview, read-only averages and form support.

Canonical: https://hextaui.com/docs/ui-sdk-rating
Last updated: 2026-11-13

> **Beta.** Built on Vercel’s UI SDK 0.1.0-beta.1 (`@ui-sdk/react`), a public beta. Its API may change.

```tsx title="components/examples/ui-sdk-rating/demo.tsx"
"use client"

import * as React from "react"

import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

const labels = ["Terrible", "Poor", "Okay", "Good", "Great"]

function UiSdkRatingDemo() {
  const [value, setValue] = React.useState<number | null>(4)

  return (
    <div className="flex flex-col items-center gap-2">
      <Rating
        aria-label="How was your stay?"
        size="lg"
        value={value}
        onValueChange={setValue}
        getValueLabel={(score, max) =>
          labels[score - 1] + ", " + score + " of " + max
        }
      >
        <RatingItem />
      </Rating>
      <p
        aria-hidden="true"
        className="min-h-5 text-sm text-muted-foreground tabular-nums"
      >
        {value ? labels[value - 1] : "Not rated yet"}
      </p>
    </div>
  )
}

export { UiSdkRatingDemo }
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/ui-sdk-rating.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @ui-sdk/react@0.1.0-beta.1 @ui-sdk/core@0.1.0-beta.1 @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/ui-sdk-rating.tsx"
"use client"

import * as React from "react"
import { IconStarFilled } from "@tabler/icons-react"
import * as RatingPrimitive from "@ui-sdk/react/rating"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const ratingVariants = cva(
  "group/rating inline-flex w-fit items-center text-(--rating-color) forced-colors:[--rating-color:CanvasText] data-disabled:opacity-50",
  {
    variants: {
      tone: {
        default:
          "[--rating-color:var(--color-amber-400)] dark:[--rating-color:var(--color-amber-300)]",
        neutral: "[--rating-color:var(--color-foreground)]",
        destructive: "[--rating-color:var(--color-destructive)]",
      },
      size: {
        sm: "gap-0 [--rating-star:--spacing(4)]",
        default: "gap-0.5 [--rating-star:--spacing(5)]",
        lg: "gap-0.5 [--rating-star:--spacing(7)]",
      },
    },
    defaultVariants: {
      tone: "default",
      size: "default",
    },
  }
)

type RatingProps = React.ComponentProps<typeof RatingPrimitive.Rating> &
  VariantProps<typeof ratingVariants>

function Rating({
  className,
  size = "default",
  tone = "default",
  ...props
}: RatingProps) {
  return (
    <RatingPrimitive.Rating
      data-size={size}
      data-tone={tone}
      className={mergeClassName(ratingVariants({ size, tone }), className)}
      {...props}
    />
  )
}

function RatingStar() {
  return (
    <>
      <IconStarFilled
        aria-hidden="true"
        className="size-(--rating-star) text-muted-foreground/25 dark:text-muted-foreground/35 forced-colors:text-[GrayText]"
      />
      <IconStarFilled
        aria-hidden="true"
        className="absolute inset-0 m-auto size-(--rating-star) [clip-path:inset(0_calc((1-var(--rating-fill,0))*100%)_0_0)] rtl:[clip-path:inset(0_0_0_calc((1-var(--rating-fill,0))*100%))]"
      />
    </>
  )
}

function RatingItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof RatingPrimitive.RatingItem>) {
  return (
    <RatingPrimitive.RatingItem
      className={mergeClassName(
        "group/rating-item relative flex shrink-0 items-center justify-center rounded-sm transition-[scale] duration-150 ease-out-quint outline-none group-data-disabled/rating:cursor-not-allowed has-focus-visible:ring-3 has-focus-visible:ring-focus-ring motion-safe:not-data-readonly:not-data-disabled:active:scale-90 motion-reduce:transition-none pointer-coarse:p-1 [&_[role=radio]]:outline-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [@media(hover:hover)]:motion-safe:not-data-readonly:not-data-disabled:hover:scale-115",
        className
      )}
      {...props}
    >
      {children ?? <RatingStar />}
    </RatingPrimitive.RatingItem>
  )
}

const createRatingController = RatingPrimitive.createRatingController

export { Rating, RatingItem, createRatingController, ratingVariants }
export type { RatingProps }
```

Update the import paths to match your project setup.

## Usage

UI SDK adds a little CSS of its own in a cascade layer named `ui-sdk`. Declare that layer at the top of your global stylesheet, above the Tailwind import, so your classes win over it.

```css
@layer ui-sdk;

@import "tailwindcss";
```

```tsx
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"
```

```tsx
<Rating aria-label="Rating" defaultValue={3}>
  <RatingItem />
</Rating>
```

`Rating` renders its child once per star, so put one `RatingItem` inside it. Each star draws an empty star with a filled one clipped to `--rating-fill`, so half stars and averages like 4.3 show exactly.

## Composition

```text
Rating
└── RatingItem
```

## Examples

### Half stars

With `step={0.5}`, each half of a star is its own radio, and the arrow keys move by half a star.

```tsx title="components/examples/ui-sdk-rating/half.tsx"
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingHalf() {
  return (
    <Rating aria-label="Rating" step={0.5} defaultValue={3.5}>
      <RatingItem />
    </Rating>
  )
}

export { UiSdkRatingHalf }
```

### Read-only

A read-only rating is one image named by `getValueLabel`, and it shows any score, like an average.

```tsx title="components/examples/ui-sdk-rating/read-only.tsx"
"use client"

import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingReadOnly() {
  return (
    <div className="flex items-center gap-2 text-sm">
      <Rating
        readOnly
        size="sm"
        value={4.3}
        getValueLabel={(value, max) =>
          "Rated " + value + " out of " + max + " stars"
        }
      >
        <RatingItem />
      </Rating>
      <span className="font-medium tabular-nums">4.3</span>
      <span className="text-muted-foreground">(1,284 reviews)</span>
    </div>
  )
}

export { UiSdkRatingReadOnly }
```

### Sizes

`size` is `sm`, `default` or `lg`.

```tsx title="components/examples/ui-sdk-rating/sizes.tsx"
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingSizes() {
  return (
    <div className="flex flex-col items-center gap-4">
      <Rating aria-label="Small rating" size="sm" defaultValue={3}>
        <RatingItem />
      </Rating>
      <Rating aria-label="Default rating" defaultValue={3}>
        <RatingItem />
      </Rating>
      <Rating aria-label="Large rating" size="lg" defaultValue={3}>
        <RatingItem />
      </Rating>
    </div>
  )
}

export { UiSdkRatingSizes }
```

### Deselectable

With `deselectable`, pressing the checked star again clears the score.

```tsx title="components/examples/ui-sdk-rating/deselectable.tsx"
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingDeselectable() {
  return (
    <Rating aria-label="Rating" deselectable defaultValue={2}>
      <RatingItem />
    </Rating>
  )
}

export { UiSdkRatingDeselectable }
```

### Controlled

Pass `value` and `onValueChange`, where `null` means no score yet. A controller from `createRatingController()` sets the score from anywhere.

```tsx title="components/examples/ui-sdk-rating/controlled.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createRatingController,
  Rating,
  RatingItem,
} from "@/components/ui/ui-sdk-rating"

function UiSdkRatingControlled() {
  const [rating] = React.useState(createRatingController)
  const [value, setValue] = React.useState<number | null>(null)

  return (
    <div className="flex flex-col items-center gap-3">
      <Rating
        aria-label="Rating"
        controller={rating}
        value={value}
        onValueChange={setValue}
      >
        <RatingItem />
      </Rating>
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => rating.setValue(5)}>
          Rate 5
        </Button>
        <Button
          variant="ghost"
          size="sm"
          disabled={value === null}
          onClick={() => setValue(null)}
        >
          Clear
        </Button>
      </div>
      <p className="text-sm text-muted-foreground tabular-nums">
        {value === null ? "No score" : "Score: " + value}
      </p>
    </div>
  )
}

export { UiSdkRatingControlled }
```

### Form

With a `name`, the score submits with the form. `required` blocks submission until there’s a score, with the browser’s own message.

```tsx title="components/examples/ui-sdk-rating/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingForm() {
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <form
      className="flex flex-col items-start gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("score")))
      }}
      onReset={() => setSubmitted(null)}
    >
      <div className="flex flex-col gap-1.5">
        <span id="rating-form-label" className="text-sm font-medium">
          Rate this answer
        </span>
        <Rating aria-labelledby="rating-form-label" name="score" required>
          <RatingItem />
        </Rating>
      </div>
      <div className="flex gap-2">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" variant="outline" size="sm">
          Reset
        </Button>
      </div>
      <p role="status" className="text-sm text-muted-foreground">
        {submitted ? "Submitted score=" + submitted : "Nothing submitted yet."}
      </p>
    </form>
  )
}

export { UiSdkRatingForm }
```

### Custom icon

Pass your own icon as the child of `RatingItem` and switch it with `data-filled`. `tone` sets the color.

```tsx title="components/examples/ui-sdk-rating/custom-icon.tsx"
import { IconHeart, IconHeartFilled } from "@tabler/icons-react"

import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingCustomIcon() {
  return (
    <Rating
      aria-label="How much did you like it?"
      defaultValue={3}
      tone="destructive"
    >
      <RatingItem>
        <IconHeart
          aria-hidden="true"
          className="size-6 group-data-[filled]/rating-item:hidden group-data-[partial]/rating-item:hidden"
        />
        <IconHeartFilled
          aria-hidden="true"
          className="hidden size-6 group-data-[filled]/rating-item:block group-data-[partial]/rating-item:block"
        />
      </RatingItem>
    </Rating>
  )
}

export { UiSdkRatingCustomIcon }
```

### Disabled

A disabled rating can’t be focused or changed, and submits nothing.

```tsx title="components/examples/ui-sdk-rating/disabled.tsx"
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingDisabled() {
  return (
    <Rating aria-label="Rating" disabled defaultValue={3}>
      <RatingItem />
    </Rating>
  )
}

export { UiSdkRatingDisabled }
```

### Right to left

Stars run from the right, and a half star fills from its right edge.

```tsx title="components/examples/ui-sdk-rating/rtl.tsx"
import { Rating, RatingItem } from "@/components/ui/ui-sdk-rating"

function UiSdkRatingRtl() {
  return (
    <div dir="rtl">
      <Rating aria-label="التقييم" step={0.5} defaultValue={2.5}>
        <RatingItem />
      </Rating>
    </div>
  )
}

export { UiSdkRatingRtl }
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves into the rating, onto the checked star or the first one. |
| `←` `→` `↑` `↓` | Moves to the next or previous score and checks it, stopping at the ends unless loop is set. |
| `Space` | Checks the focused star, or clears it when it’s checked and the rating is deselectable. |
| `Enter` | Submits the form. |

## Accessibility

A rating is a radio group with one tab stop. Each star holds one radio per score it can pick, named by `getValueLabel`, so name scores in words people use, like “Great, 5 of 5”. The star icons are hidden from screen readers. A read-only rating is a single image instead.

Stars only grow on hover and shrink on press when motion is allowed. On touch screens each star gets extra padding, and in forced colors mode filled stars use the text color and empty ones use the system’s gray.

## API reference

Each part wraps the UI SDK part of the same name and accepts all of its props, including `render` and a `className` that can be a function of the part’s state.

### Rating

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number \| null` | – |  |
| `defaultValue` | `number \| null` | – |  |
| `onValueChange` | `(value: number \| null, details) => void` | – |  |
| `max` | `number` | `5` |  |
| `step` | `number` | `1` |  |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` |  |
| `tone` | `"default" \| "neutral" \| "destructive"` | `"default"` |  |
| `deselectable` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |
| `disabled` | `boolean` | `false` |  |
| `required` | `boolean` | `false` |  |
| `name` | `string` | – |  |
| `loop` | `boolean` | `false` |  |
| `getValueLabel` | `(value: number, max: number) => string` | – | Names each score for its radio, and a read-only rating. |
| `controller` | `RatingController` | – | From createRatingController(). |

| Attribute | Description |
| --- | --- |
| `data-slot="rating"` | Target the rating in CSS. |
| `data-size` | The size: sm, default or lg. |
| `data-tone` | The tone: default, neutral or destructive. |
| `data-readonly` | Present when read-only. |
| `data-disabled` | Present when disabled. |
| `--rating-color` | The color of filled stars, set by tone. |

### RatingItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | – | The star’s icon. Defaults to a Tabler star that fills by --rating-fill. |
| `render` | `ReactElement \| (props, state) => ReactElement` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="rating-item"` | Target a star in CSS. |
| `data-filled` | Present on stars the shown score covers whole. |
| `data-partial` | Present on the star the shown score covers part of. |
| `data-checked` | Present on the star the score ends in. |
| `data-highlighted` | Present on the stars up to the score a pointer previews. |
| `--rating-fill` | How much of the star the shown score covers, from 0 to 1. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt

## Related

- [Calendar](https://hextaui.com/docs/calendar) - A date grid for single, range and multiple selection, with sliding months, range previews and touch-sized days.
- [Checkbox](https://hextaui.com/docs/checkbox) - A checkbox whose check draws in, with indeterminate parents, groups and labels that share its hover.
- [Combobox](https://hextaui.com/docs/combobox) - A filterable select with chips, groups and async results, in a popup that resizes as you type.
- [Date picker](https://hextaui.com/docs/date-picker) - A button that opens a calendar in a popover, or a bottom sheet on phones, for single dates and ranges.
- [Field](https://hextaui.com/docs/field) - Labels, descriptions and errors wired to their control, with validation states and layouts for forms.
- [Input](https://hextaui.com/docs/input) - A text input with three sizes, invalid and read-only states, native validation styling and a 16px touch font so phones never zoom in.
