FormCn
componentratingsource: ok · refreshed
Rating
Rating component
npx shadcn add "https://formcn.dev/r/rating.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 4:10:09 AM
src/components/ui/rating.tsx
'use client'
import { useControllableState } from '@radix-ui/react-use-controllable-state'
import { type LucideProps, StarIcon } from 'lucide-react'
import type { KeyboardEvent, MouseEvent, ReactElement, ReactNode } from 'react'
import {
Children,
cloneElement,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { cn } from '@/lib/utils'
type RatingContextValue = {
value: number
readOnly: boolean
hoverValue: number | null
focusedStar: number | null
handleValueChange: (
event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,
value: number,
) => void
handleKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void
setHoverValue: (value: number | null) => void
setFocusedStar: (value: number | null) => void
}
const RatingContext = createContext<RatingContextValue | null>(null)
const useRating = () => {
const context = useContext(RatingContext)
if (!context) {
throw new Error('useRating must be used within a Rating component')
}
return context
}
export type RatingButtonProps = LucideProps & {
index?: number
icon?: ReactElement<LucideProps>
}
export const RatingButton = ({
index: providedIndex,
size = 20,
className,
icon = <StarIcon />,
}: RatingButtonProps) => {
const {
value,
readOnly,
hoverValue,
focusedStar,
handleValueChange,
handleKeyDown,
setHoverValue,
setFocusedStar,
} = useRating()
const index = providedIndex ?? 0
const isActive = index < (hoverValue ?? focusedStar ?? value ?? 0)
let tabIndex = -1
if (!readOnly) {
tabIndex = value === index + 1 ? 0 : -1
}
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
handleValueChange(event, index + 1)
},
[handleValueChange, index],
)
const handleMouseEnter = useCallback(() => {
if (!readOnly) {
setHoverValue(index + 1)
}
}, [readOnly, setHoverValue, index])
const handleFocus = useCallback(() => {
setFocusedStar(index + 1)
}, [setFocusedStar, index])
const handleBlur = useCallback(() => {
setFocusedStar(null)
}, [setFocusedStar])
return (
<button
className={cn(
'rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'p-0.5',
readOnly && 'cursor-not-allowed',
className,
)}
disabled={readOnly}
onBlur={handleBlur}
onClick={handleClick}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onMouseEnter={handleMouseEnter}
tabIndex={tabIndex}
type="button"
>
{cloneElement(icon, {
size,
className: cn(
'transition-colors duration-200',
isActive && 'fill-current',
!readOnly && 'cursor-pointer',
),
'aria-hidden': 'true',
})}
</button>
)
}
export type RatingProps = {
defaultValue?: number
value?: number
onChange?: (
event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,
value: number,
) => void
onValueChange?: (value: number) => void
readOnly?: boolean
className?: string
children?: ReactNode
}
export const Rating = ({
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultValue = 0,
onChange,
readOnly = false,
className,
children,
...props
}: RatingProps) => {
const [hoverValue, setHoverValue] = useState<number | null>(null)
const [focusedStar, setFocusedStar] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [value, onValueChange] = useControllableState({
defaultProp: defaultValue,
prop: controlledValue,
onChange: controlledOnValueChange,
})
const handleValueChange = useCallback(
(
event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,
newValue: number,
) => {
if (!readOnly) {
onChange?.(event, newValue)
onValueChange?.(newValue)
}
},
[readOnly, onChange, onValueChange],
)
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>) => {
if (readOnly) {
return
}
const total = Children.count(children)
let newValue = focusedStar !== null ? focusedStar : (value ?? 0)
switch (event.key) {
case 'ArrowRight':
if (event.shiftKey || event.metaKey) {
newValue = total
} else {
newValue = Math.min(total, newValue + 1)
}
break
case 'ArrowLeft':
if (event.shiftKey || event.metaKey) {
newValue = 1
} else {
newValue = Math.max(1, newValue - 1)
}
break
default:
return
}
event.preventDefault()
setFocusedStar(newValue)
handleValueChange(event, newValue)
},
[focusedStar, value, children, readOnly, handleValueChange],
)
useEffect(() => {
if (focusedStar !== null && containerRef.current) {
const buttons = containerRef.current.querySelectorAll('button')
buttons[focusedStar - 1]?.focus()
}
}, [focusedStar])
const contextValue: RatingContextValue = {
value: value ?? 0,
readOnly,
hoverValue,
focusedStar,
handleValueChange,
handleKeyDown,
setHoverValue,
setFocusedStar,
}
return (
<RatingContext.Provider value={contextValue}>
<div
aria-label="Rating"
className={cn('inline-flex items-center gap-0.5', className)}
onMouseLeave={() => setHoverValue(null)}
ref={containerRef}
role="radiogroup"
{...props}
>
{Children.map(children, (child, index) => {
if (!child) {
return null
}
return cloneElement(child as ReactElement<RatingButtonProps>, {
index,
})
})}
</div>
</RatingContext.Provider>
)
}