Rating
Interactive star rating component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring keyboard navigation, hover effects, and form integration.
Powered by
'use client';import { Rating, RatingButton } from '@/components/ui/shadcn-io/rating';const Example = () => ( <Rating defaultValue={3}> {Array.from({ length: 5 }).map((_, index) => ( <RatingButton key={index} /> ))} </Rating>);export default Example;
'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-default', 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> );};
Installation
npx shadcn@latest add https://www.shadcn.io/registry/rating.json
npx shadcn@latest add https://www.shadcn.io/registry/rating.json
pnpm dlx shadcn@latest add https://www.shadcn.io/registry/rating.json
bunx shadcn@latest add https://www.shadcn.io/registry/rating.json
Features
- Interactive stars - Click and hover rating selection with visual feedback using JavaScript events
- Keyboard navigation - Arrow key support and accessible focus management for React applications
- Form integration - Hidden input fields for seamless form library compatibility using TypeScript
- Customizable appearance - Star count, size, colors, and icons with Tailwind CSS styling
- Read-only mode - Display-only ratings with proper ARIA attributes for Next.js projects
- Accessible design - Complete ARIA support and screen reader compatibility using shadcn/ui patterns
- Open source - Free rating component with Lucide icon integration
Examples
Custom colors
'use client';import { Rating, RatingButton } from '@/components/ui/shadcn-io/rating';const Example = () => ( <Rating defaultValue={3}> {Array.from({ length: 5 }).map((_, index) => ( <RatingButton className="text-yellow-500" key={index} /> ))} </Rating>);export default Example;
'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-default', 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> );};
Custom size
'use client';import { Rating, RatingButton } from '@/components/ui/shadcn-io/rating';const Example = () => ( <Rating defaultValue={3}> {Array.from({ length: 5 }).map((_, index) => ( <RatingButton key={index} size={30} /> ))} </Rating>);export default Example;
'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-default', 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> );};
Custom icon
'use client';import { Rating, RatingButton } from '@/components/ui/shadcn-io/rating';import { HeartIcon } from 'lucide-react';const Example = () => ( <Rating defaultValue={3}> {Array.from({ length: 5 }).map((_, index) => ( <RatingButton icon={<HeartIcon />} key={index} /> ))} </Rating>);export default Example;
'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-default', 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> );};
Controlled
'use client';import { Rating, RatingButton } from '@/components/ui/shadcn-io/rating';import { Input } from '@/components/ui/input';import { useState } from 'react';const Example = () => { const [value, setValue] = useState(3); return ( <> <Rating onValueChange={setValue} value={value}> {Array.from({ length: 5 }).map((_, index) => ( <RatingButton key={index} /> ))} </Rating> <Input className="w-32 bg-background" max={5} min={0} onChange={(e) => setValue(Number(e.target.value))} type="number" value={value} /> </> );};export default Example;
'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-default', 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> );};
Use Cases
- Product reviews - Customer feedback and rating collection systems
- Content rating - Article, video, and media quality assessments
- Skill assessment - User proficiency and experience level indicators
- Satisfaction surveys - Service quality and experience feedback forms
Implementation
Built with Lucide Star icons and native button elements. Works with React Hook Form and other form libraries. Supports controlled/uncontrolled modes with proper state management.
Pin List
Interactive list component with pin/unpin functionality and smooth layout animations. Perfect for React applications requiring organized content management with Next.js integration and TypeScript support.
Spinner
Loading spinner component for React and Next.js applications. Built with TypeScript support, Tailwind CSS styling, and shadcn/ui design featuring smooth animations, multiple variants, and accessibility features.