3 out of 5
"use client"
import * as React from "react"
import { Rating } from "@/components/ui/rating"
export default function RatingDemo() {
const [value, setValue] = React.useState(3)
return (
<div className="flex flex-col items-center gap-2">
<Rating value={value} onValueChange={setValue} aria-label="Rate this" />
<p className="text-muted-foreground text-sm">
{value > 0 ? `${value} out of 5` : "Not rated yet"}
</p>
</div>
)
}Installation
npx logic2b@latest add ratingWorking with an AI assistant? Copy this prompt into Claude Code, Cursor or Copilot — it carries the install steps, a no-CLI fallback and a verification checklist.
Add the "rating" item from the logic2b ui registry (https://ui.logic2b.com) to this project. Follow every step and verify at the end. ## Steps 1. If the project has no `components.json` yet, set up logic2b ui first: ```bash npx logic2b@latest init ``` 2. Install the item (the CLI resolves registry dependencies automatically): ```bash npx logic2b@latest add rating ``` 3. Install any npm dependencies the command prints. ## If the CLI is not available Do it manually from the registry payload: 1. Fetch https://ui.logic2b.com/r/rating.json. 2. Write every entry in `files[]` to its mapped project path (see below). 3. Repeat (recursively) for each name in `registryDependencies`. 4. Install every package listed in `dependencies` across those payloads. ## Registry reference - Index of every item: https://ui.logic2b.com/r/index.json - Item payload (full source): https://ui.logic2b.com/r/<name>.json - Usage examples per item: https://ui.logic2b.com/r/demos/index.json → https://ui.logic2b.com/r/demos/<demo>.json - Docs index for agents: https://ui.logic2b.com/llms.txt (full docs: https://ui.logic2b.com/llms-full.txt) - Every docs page is Markdown when you append `.md` to its URL. Registry file paths map to project paths like this: - `ui/*` → `@/components/ui/*` - `blocks/<name>/*` → `@/components/*` - `charts/*` → `@/components/charts/*` - `hooks/*` → `@/hooks/*` - `lib/utils.ts` → `@/lib/utils.ts` - `theme.css` → next to the project's Tailwind entry stylesheet ## Conventions (do not break these) - Tailwind CSS v4 (CSS-first config; no tailwind.config.js needed) and React 19. - All colors go through semantic tokens (`bg-primary`, `text-muted-foreground`, `border-border`, …). Never hardcode hex/oklch values in components. - Dark mode is class-based: toggle `.dark` on `<html>`. - Icons come from lucide-react. ## Verify before finishing 1. The dev server starts and the app renders without console errors. 2. TypeScript compiles (`tsc --noEmit` or the framework's check). 3. Components use the theme: a `<Button>` shows the primary token color, and toggling `.dark` on `<html>` switches the palette.
Install the dependencies:
npm install lucide-reactCopy into ui/rating.tsx:
"use client"
import * as React from "react"
import { StarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
/**
* Rating — a star (or custom icon) rating following the radio-group
* pattern: each star is a radio, arrows move the value, hover previews it.
* `readOnly` renders a static, labelled image instead.
*/
function Rating({
className,
value: valueProp,
defaultValue,
onValueChange,
max = 5,
readOnly = false,
disabled = false,
icon,
"aria-label": ariaLabel = "Rating",
...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
max?: number
readOnly?: boolean
disabled?: boolean
icon?: React.ReactNode
}) {
const [internal, setInternal] = React.useState(defaultValue ?? 0)
const [hovered, setHovered] = React.useState(0)
const isControlled = valueProp !== undefined
const value = isControlled ? valueProp : internal
const setValue = (next: number) => {
if (!isControlled) setInternal(next)
onValueChange?.(next)
}
const shown = hovered || value
const interactive = !readOnly && !disabled
const star = (n: number, active: boolean) => (
<span
data-slot="rating-icon"
data-state={active ? "active" : "inactive"}
className={cn(
"transition-colors [&_svg]:size-5",
active
? "text-primary [&_svg]:fill-current"
: "text-muted-foreground/40"
)}
>
{icon ?? <StarIcon />}
</span>
)
if (readOnly) {
return (
<div
role="img"
data-slot="rating"
aria-label={`${ariaLabel}: ${value} out of ${max}`}
className={cn("flex items-center gap-0.5", className)}
{...props}
>
{Array.from({ length: max }, (_, i) => (
<React.Fragment key={i}>{star(i + 1, i + 1 <= value)}</React.Fragment>
))}
</div>
)
}
return (
<div
role="radiogroup"
data-slot="rating"
aria-label={ariaLabel}
data-disabled={disabled || undefined}
className={cn(
"flex items-center gap-0.5",
disabled && "pointer-events-none opacity-50",
className
)}
onMouseLeave={() => setHovered(0)}
onKeyDown={(event) => {
if (!interactive) return
let next: number | null = null
if (event.key === "ArrowRight" || event.key === "ArrowUp")
next = Math.min(value + 1, max)
if (event.key === "ArrowLeft" || event.key === "ArrowDown")
next = Math.max(value - 1, 0)
if (event.key === "Home") next = 1
if (event.key === "End") next = max
if (next === null) return
event.preventDefault()
setValue(next)
const radios = event.currentTarget.querySelectorAll<HTMLElement>('[role="radio"]')
radios[Math.max(next - 1, 0)]?.focus()
}}
{...props}
>
{Array.from({ length: max }, (_, i) => {
const n = i + 1
return (
<button
key={n}
type="button"
role="radio"
data-slot="rating-item"
aria-checked={value === n}
aria-label={`${n} out of ${max}`}
disabled={disabled}
tabIndex={value === n || (value === 0 && n === 1) ? 0 : -1}
className="focus-visible:ring-ring/50 rounded-sm outline-none focus-visible:ring-[3px]"
onClick={() => setValue(value === n ? 0 : n)}
onMouseEnter={() => setHovered(n)}
>
{star(n, n <= shown)}
</button>
)
})}
</div>
)
}
export { Rating }Usage
import { Rating } from "@/components/ui/rating"
const [value, setValue] = React.useState(0)
<Rating value={value} onValueChange={setValue} aria-label="Rate this" />
Clicking the current value again clears the rating to 0.
API
| Prop | Type | Default |
|---|---|---|
value / defaultValue |
number — controlled/uncontrolled rating |
0 |
onValueChange |
(value: number) => void |
— |
max |
number — number of steps |
5 |
readOnly |
boolean — static display (role="img" with a text label) |
false |
disabled |
boolean |
false |
icon |
ReactNode — swap the star for any icon |
— |
aria-label |
string — group label |
"Rating" |
Examples
Read-only
For displaying scores: the group becomes role="img" labelled
“label: n out of max”, and any icon works.
4.0 · 128 reviews
import { HeartIcon } from "lucide-react"
import { Rating } from "@/components/ui/rating"
export default function RatingReadonlyDemo() {
return (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-3">
<Rating value={4} readOnly aria-label="Average rating" />
<span className="text-muted-foreground text-sm">4.0 · 128 reviews</span>
</div>
<Rating
value={3}
readOnly
max={5}
icon={<HeartIcon />}
aria-label="Health"
className="text-rose-500"
/>
</div>
)
}Accessibility
- Follows the radio-group pattern: each step is a
role="radio"button witharia-checkedand ann out of maxlabel. - Arrow keys move the value (selection follows focus); Home / End jump to 1 / max.
- Read-only ratings are announced as a single image with the score in the label, not as a disabled control.