Rating
Interactive star rating primitive with hover preview and keyboard support.
components/ui/rating.tsx
import * as React from 'react'
import { Star } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface RatingProps {
value?: number
defaultValue?: number
count?: number
onChange?: (value: number) => void
readOnly?: boolean
size?: 'sm' | 'md' | 'lg'
className?: string
ariaLabel?: string
}
export function Rating({
value,
defaultValue = 0,
count = 5,
onChange,
readOnly = false,
size = 'md',
className,
ariaLabel = 'Rating',
}: RatingProps) {
const isControlled = value !== undefined
const [internal, setInternal] = React.useState(defaultValue)
const [hovered, setHovered] = React.useState(0)
const current = isControlled ? (value as number) : internal
const displayed = hovered || current
const handleSelect = (index: number) => {
if (readOnly) return
const next = index + 1
if (!isControlled) setInternal(next)
onChange?.(next)
}
const sizeClass = {
sm: 'h-5 w-5',
md: 'h-7 w-7',
lg: 'h-9 w-9',
}[size]
return (
<div
role="radiogroup"
aria-label={ariaLabel}
aria-readonly={readOnly || undefined}
className={cn('inline-flex items-center gap-1', className)}
>
{Array.from({ length: count }).map((_, index) => {
const filled = index < displayed
return (
<button
key={index}
type="button"
role="radio"
aria-checked={index < current}
aria-label={`${index + 1} star${index === 0 ? '' : 's'}${readOnly ? '' : ` out of ${count}`}`}
disabled={readOnly}
onClick={() => handleSelect(index)}
onMouseEnter={() => !readOnly && setHovered(index + 1)}
onMouseLeave={() => setHovered(0)}
className={cn(
'transition-transform cursor-pointer disabled:cursor-default',
!readOnly && 'hover:scale-125 active:scale-95',
'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2'
)}
>
<Star
className={cn(
sizeClass,
'transition-colors',
filled ? 'fill-rk-primary stroke-rk-ink stroke-2' : 'fill-rk-surface stroke-rk-ink stroke-2'
)}
aria-hidden="true"
/>
</button>
)
})}
</div>
)
}
- Where it goes
- testimonialkanban
DependenciesBundle weight · light
- icons
- lucide-react