# Rating (/ui/components/react/rating)




## Usage [#usage]

```tsx
import { Rating } from '@appica/ui-react/rating'
```

```tsx
<Rating defaultValue={3} aria-label="Rate this product" />
```

`Rating` renders a `radiogroup` of icon buttons: one per item, filling from the start of the row up to the current value. Moving the pointer across the row sweeps the fill up to the rating a click would set, and lifts the icon under the cursor. The preview always lands on a `step` boundary, so it never promises a value the click won't deliver; what stays continuous is the mask animating there, not the value. Holding the pointer (or **Space**) presses the icon down for as long as you hold it, and it springs back on release. The accent color comes from the inherited text color, so any `text-*` utility recolors the whole control. Every animation is skipped when the user prefers reduced motion.

## Examples [#examples]

### Default [#default]

Set the initial rating with `defaultValue` (uncontrolled), or drive it yourself with `value` + `onValueChange`. `0` means unrated.

```tsx
import { Rating } from '@appica/ui-react/rating'

export default function RatingDefault() {
  return <Rating defaultValue={3} aria-label="Rate this product" />
}
```

### Icons [#icons]

The star is built in, so `@appica/ui-react` carries no icon dependency. For any other shape pass `icon={{ empty, filled }}` - two 24x24 `currentColor` SVGs, typically a matching outline and solid pair from [`@appica/icons-react`](/ui/icons). `filled` doubles as the muted base in the `filled` variant, so both layers stay in register.

```tsx
import { Rating } from '@appica/ui-react/rating'
import { Bolt, BoltFilled, Crown2, Crown2Filled, Heart, HeartFilled, ThumbUp, ThumbUpFilled } from '@appica/icons-react'

const ICONS = [
  { label: 'Heart', icon: { empty: <Heart />, filled: <HeartFilled /> }, className: 'text-error-emphasis' },
  { label: 'Thumb up', icon: { empty: <ThumbUp />, filled: <ThumbUpFilled /> }, className: 'text-success-emphasis' },
  { label: 'Bolt', icon: { empty: <Bolt />, filled: <BoltFilled /> }, className: 'text-warning-emphasis' },
  { label: 'Crown', icon: { empty: <Crown2 />, filled: <Crown2Filled /> }, className: 'text-secondary-emphasis' },
]

export default function RatingIcons() {
  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-4">
        <span className="w-20 text-sm">Star</span>
        <Rating defaultValue={4} aria-label="Star rating" />
      </div>
      {ICONS.map(({ label, icon, className }) => (
        <div key={label} className="flex items-center gap-4">
          <span className="w-20 text-sm">{label}</span>
          <Rating className={className} defaultValue={4} icon={icon} aria-label={`${label} rating`} />
        </div>
      ))}
    </div>
  )
}
```

### Variants [#variants]

`variant="filled"` (the default) draws unrated items as muted solid icons, which keeps the row's visual weight steady as it fills. `variant="outline"` draws them as line icons in the accent color for a lighter look.

```tsx
import { Rating } from '@appica/ui-react/rating'

export default function RatingVariants() {
  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-4">
        <span className="w-16 text-sm">Filled</span>
        <Rating defaultValue={3} aria-label="Filled rating" />
      </div>
      <div className="flex items-center gap-4">
        <span className="w-16 text-sm">Outline</span>
        <Rating defaultValue={3} variant="outline" aria-label="Outline rating" />
      </div>
    </div>
  )
}
```

### Orientation [#orientation]

`orientation="vertical"&#x60; stacks the items in a column, filling from the top down. The pointer preview follows the cursor's vertical position, and &#x2A;*↓*&#x2A;/&#x2A;*↑** step through the items either way.

```tsx
import { Rating } from '@appica/ui-react/rating'

export default function RatingOrientation() {
  return (
    <div className="flex items-start gap-10">
      <Rating defaultValue={3} aria-label="Horizontal rating" />
      <Rating defaultValue={3} orientation="vertical" aria-label="Vertical rating" />
      <Rating defaultValue={3.5} step={0.5} orientation="vertical" variant="outline" aria-label="Vertical half-step" />
    </div>
  )
}
```

### Sizes [#sizes]

`size` takes any icon size rather than a fixed set: a number is read as pixels, and a string is used verbatim, so `size="1em"` makes the row follow the surrounding font size. The button padding is a sixth of `size` on every side (4px at the 24px default), so the hit area and the gap between icons grow with the icon rather than staying fixed.

```tsx
import { Rating } from '@appica/ui-react/rating'

export default function RatingSizes() {
  return (
    <div className="flex flex-col items-start gap-3">
      <Rating defaultValue={4} size={16} aria-label="16px rating" />
      <Rating defaultValue={4} aria-label="Default rating" />
      <Rating defaultValue={4} size={40} aria-label="40px rating" />
      <Rating defaultValue={4} size="1em" className="text-lg" aria-label="Text-relative rating" />
    </div>
  )
}
```

### Fractions [#fractions]

Lower `step` to allow partial items. `step` is the granularity of both the click and the hover preview, so `step={0.5}` previews and selects half icons; the mask still sweeps smoothly between those stops rather than jumping. A `value` finer than `step` renders exactly as given, which is what read-only averages need.

```tsx
import { Rating } from '@appica/ui-react/rating'

export default function RatingFractions() {
  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-4">
        <span className="w-16 text-sm">Halves</span>
        <Rating defaultValue={3.5} step={0.5} aria-label="Half-step rating" />
      </div>
      <div className="flex items-center gap-4">
        <span className="w-16 text-sm">Quarters</span>
        <Rating defaultValue={3.75} step={0.25} aria-label="Quarter-step rating" />
      </div>
    </div>
  )
}
```

### Color [#color]

The accent is inherited, not a prop, so a `text-*` class on the root recolors the fill and the outline together. Unrated items in the `filled` variant keep their muted background color.

```tsx
import { Rating } from '@appica/ui-react/rating'

export default function RatingColor() {
  return (
    <div className="flex flex-col items-start gap-3">
      <Rating defaultValue={4} aria-label="Primary rating" />
      <Rating className="text-warning-emphasis" defaultValue={4} aria-label="Warning rating" />
      <Rating className="text-error-emphasis" defaultValue={4} aria-label="Error rating" />
      <Rating className="text-success-emphasis" defaultValue={4} variant="outline" aria-label="Success rating" />
    </div>
  )
}
```

### Read only [#read-only]

`readOnly` renders a non-interactive display: no buttons, no hover preview, and the whole row is exposed to assistive tech as a single labeled image (`"4.3 out of 5"` unless you pass your own `aria-label`). Pair it with a small `step` to show an average.

```tsx
import { Rating } from '@appica/ui-react/rating'

const BREAKDOWN = [
  { label: 'Value for money', score: 4.5 },
  { label: 'Build quality', score: 4 },
  { label: 'Battery life', score: 3.5 },
]

export default function RatingReadOnly() {
  return (
    <div className="flex flex-col gap-3">
      <div className="flex items-center gap-3">
        <Rating value={4.3} step={0.1} size={32} readOnly />
        <span className="text-sm font-medium">4.3</span>
        <span className="text-foreground-muted text-sm">(1,284 reviews)</span>
      </div>
      <div className="flex flex-col gap-1.5">
        {BREAKDOWN.map(({ label, score }) => (
          <div key={label} className="flex items-center gap-3">
            <span className="text-foreground-muted w-32 text-sm">{label}</span>
            <Rating value={score} step={0.5} size={16} readOnly />
          </div>
        ))}
      </div>
    </div>
  )
}
```

### Controlled with labels [#controlled-with-labels]

`onHoverChange` reports the `step`-snapped rating under the pointer (and `null` when it leaves), so a "Terrible → Excellent" caption follows what a click would select rather than the committed value. `clearable` lets a second click on the current item reset the rating to `0`.

```tsx
'use client'

import { useState } from 'react'
import { Rating } from '@appica/ui-react/rating'

const LABELS = ['Terrible', 'Poor', 'Average', 'Good', 'Excellent']

export default function RatingFeedback() {
  const [value, setValue] = useState(0)
  const [preview, setPreview] = useState<number | null>(null)
  const shown = preview ?? value

  return (
    <div className="flex flex-col items-center gap-2">
      <span id="feedback-label" className="text-foreground-intense text-sm font-medium">
        How was your experience?
      </span>
      <Rating
        value={value}
        onValueChange={setValue}
        onHoverChange={setPreview}
        clearable
        size={32}
        aria-labelledby="feedback-label"
      />
      <span className="text-foreground-muted h-5 text-sm">{shown > 0 ? LABELS[Math.ceil(shown) - 1] : ''}</span>
    </div>
  )
}
```

### In a form [#in-a-form]

`name` renders a hidden input carrying the current value, so a `Rating` submits with the rest of the form and needs no controlled state. Name it with a `<span id>` plus `aria-labelledby` rather than `FieldLabel`: that emits a `<label for>`, which doesn't associate with a `role="radiogroup"`.

```tsx
'use client'

import { useState } from 'react'
import { Form } from '@appica/ui-react/form'
import { Field, FieldDescription } from '@appica/ui-react/field'
import { Rating } from '@appica/ui-react/rating'
import { Textarea } from '@appica/ui-react/textarea'
import { Button } from '@appica/ui-react/button'

export default function RatingForm() {
  const [submitted, setSubmitted] = useState<string | null>(null)

  return (
    <Form
      className="flex w-full max-w-70 flex-col gap-4"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        setSubmitted(`score=${data.get('score')}`)
      }}
    >
      <Field>
        <span id="score-label" className="text-foreground-intense mb-1.5 block text-sm font-medium select-none">
          Rate your stay
        </span>
        <Rating name="score" aria-labelledby="score-label" />
        <FieldDescription className="text-xs">Submits as a hidden input named score.</FieldDescription>
      </Field>
      <Textarea name="comment" rows={2} placeholder="Anything we could do better?" />
      <Button type="submit" className="self-start">
        Send review
      </Button>
      {submitted ? <output className="text-foreground-muted font-mono text-xs">{submitted}</output> : null}
    </Form>
  )
}
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your tree in `DirectionProvider` so direction-aware behavior (roving focus, popup placement, and the like) follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

In RTL the first item sits on the right, items fill right to left, and &#x2A;*←*&#x2A;/&#x2A;*→** swap roles.

For setup details and caveats, see the [RTL guide](/ui/docs/react/rtl).


## API reference [#api-reference]

`Rating` is a single element: a `<div>` wrapping one `<button role="radio">` per item. It forwards `ref`, `aria-*`, and the remaining `<div>` attributes to the root.

| Prop            | Type              | Default        | Description                                                                                                       |
| --------------- | -------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value`         | `number`                                                 | -              | Controlled rating. Pair with `onValueChange`.                                                                                                            |
| `defaultValue`  | `number`                                                 | `0`            | Uncontrolled initial rating. `0` means unrated.                                                                                                          |
| `onValueChange` | `(value: number) => void`                     | -              | Fires when the rating is committed by a click or a key press.                                                                                            |
| `onHoverChange` | `(value: number \| null) => void`             | -              | Fires with the `step`-snapped rating under the pointer, and with `null` when it leaves.                                                                  |
| `count`         | `number`                                                 | `5`            | How many items to render.                                                                                                                                |
| `step`          | `number`                                                 | `1`            | Smallest selectable fraction of an item. Use `0.5` for half icons.                                                                                       |
| `icon`          | `{'{ empty: ReactNode, filled: ReactNode }'}` | star           | Icon pair to render. Defaults to a built-in star; pass any 24x24 `currentColor` SVGs, e.g. a matching outline and solid icon from `@appica/icons-react`. |
| `variant`       | `'filled' \| 'outline'`                       | `'filled'`     | `'filled'` draws unrated items as muted solid icons, `'outline'` draws them as line icons.                                                               |
| `orientation`   | `'horizontal' \| 'vertical'`                  | `'horizontal'` | Lay the items out in a row or a column. A vertical rating fills from the top down.                                                                       |
| `size`          | `number \| string`                            | `24`           | Icon size. A number is read as pixels; a string is used verbatim, so any CSS length works (`'2rem'`, `'1em'` to follow the surrounding text).            |
| `hoverable`     | `boolean`                                                | `true`         | Track the pointer with a continuous fill before the rating is committed. Clicking still selects at `step` precision either way.                          |
| `clearable`     | `boolean`                                                | `false`        | Selecting the current rating again resets it to `0`.                                                                                                     |
| `disabled`      | `boolean`                                                | `false`        | Blocks interaction and dims the control.                                                                                                                 |
| `readOnly`      | `boolean`                                                | `false`        | Renders a non-interactive display of `value`, exposed as a single labeled image.                                                                         |
| `name`          | `string`                                                 | -              | Field name submitted with a form, via a hidden input.                                                                                                    |
| `itemAriaLabel` | `(value: number, count: number) => string`    | -              | Accessible name for each item, describing the rating it selects.                                                                                         |
| `className`     | `string`                                                 | -              | Extra classes on the root, merged via `tailwind-merge`. Set the accent with a `text-*` utility here.                                                     |

The root carries `data-slot="rating"` plus `data-disabled` / `data-readonly`; each item carries `data-slot="rating-item"` and `data-checked` when it holds the current value.

## Accessibility [#accessibility]

* An interactive `Rating` renders a `<div role="radiogroup">` with one `<button role="radio">` per item. Name it with `aria-label` or `aria-labelledby`.
* Each item is named by `itemAriaLabel`, which defaults to the rating that item selects ("3 of 5"). The item holding the current value is `aria-checked`.
* The group is a single tab stop: **Tab*&#x2A; moves focus to the rated item (or the first one), then &#x2A;*→*&#x2A;/&#x2A;*↓*&#x2A; step to the next item and &#x2A;*←*&#x2A;/&#x2A;*↑** to the previous one, by `step`. **Home** jumps to the lowest rating and **End** to the highest, and **Space** or **Enter*&#x2A; selects the focused item. Only &#x2A;*←*&#x2A;/&#x2A;*→** mirror in RTL. The root carries `aria-orientation`.
* Fractional ratings are announced at item granularity: with `step={0.5}`, arrowing to `2.5` keeps the third item checked. Use `readOnly` when the exact average matters - it exposes the row as one image labeled `"4.3 out of 5"`.
* `disabled` blocks interaction and dims the control; `readOnly` drops the buttons entirely rather than presenting inert ones.
* The hover fill sweep, the icon lift, and the press-and-release scale all honor `prefers-reduced-motion`.
