# Star Rating

> Star rating control with hover preview and keyboard support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/star-rating
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/star-rating.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/star-rating-demo.tsx"
"use client"

import * as React from "react"

import { StarRating } from "@/components/star-rating"

export default function StarRatingDemo() {
  const [value, setValue] = React.useState(3)
  return (
    <div className="flex flex-col items-center gap-3">
      <StarRating value={value} onChange={setValue} size={32} />
      <p className="text-sm text-muted-foreground tabular-nums">{value} / 5</p>
      <StarRating defaultValue={4} size={16} readOnly className="mt-4" />
    </div>
  )
}
```

## Source

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

import * as React from "react"
import { Star } from "lucide-react"

import { cn } from "@/lib/utils"

export interface StarRatingProps extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  max?: number
  /** Star size in px */
  size?: number
  readOnly?: boolean
  label?: string
}

export function StarRating({
  value,
  defaultValue = 0,
  onChange,
  max = 5,
  size = 24,
  readOnly = false,
  label = "Rating",
  className,
  ...props
}: StarRatingProps) {
  const [internal, setInternal] = React.useState(defaultValue)
  const [hover, setHover] = React.useState<number | null>(null)
  const current = value ?? internal
  const shown = hover ?? current

  const select = (next: number) => {
    if (readOnly) return
    setInternal(next)
    onChange?.(next)
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    if (readOnly) return
    if (event.key === "ArrowRight" || event.key === "ArrowUp") {
      event.preventDefault()
      select(Math.min(max, current + 1))
    } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
      event.preventDefault()
      select(Math.max(1, current - 1))
    }
  }

  return (
    <div
      role="radiogroup"
      aria-label={label}
      aria-readonly={readOnly}
      onKeyDown={onKeyDown}
      onMouseLeave={() => setHover(null)}
      className={cn("inline-flex items-center gap-1", className)}
      {...props}
    >
      {Array.from({ length: max }, (_, i) => {
        const n = i + 1
        const active = n <= shown
        return (
          <button
            key={n}
            type="button"
            role="radio"
            aria-checked={n === current}
            aria-label={`${n} / ${max}`}
            tabIndex={n === Math.max(current, 1) ? 0 : -1}
            disabled={readOnly}
            onMouseEnter={() => !readOnly && setHover(n)}
            onClick={() => select(n)}
            className="rounded-sm outline-none transition-transform duration-150 hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:hover:scale-100"
          >
            <Star
              style={{ width: size, height: size }}
              className={cn(
                "transition-colors duration-150",
                active ? "fill-amber-400 text-amber-400" : "fill-transparent text-muted-foreground/40"
              )}
            />
          </button>
        )
      })}
    </div>
  )
}
```
