UIPackage

Rating

React form
Edit on GitHub

Star (or custom icon) rating control — pick a value from 1 to N. Read-only mode for displaying review averages, with half-step support.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/rating.json

Or with the named registry: npx shadcn@latest add @uipkge-react/rating

Examples

Props

Name Type / Values Default Required
value

Currently selected value

number optional
defaultValue

Default value when uncontrolled

number optional
onValueChange

Emitted when the value changes

(value: number) => void optional
max

Maximum rating value

number optional
readonly

If true, prevents user interaction

boolean optional
disabled

If true, disables the rating

boolean optional
density

Density of the component

'compact' | 'default' | 'comfortable' optional
color

Color of the selected stars

string optional
clearable

If true, clicking the same value clears the rating

boolean optional
hover

If true, stars grow on hover

boolean optional
itemAriaLabel

ARIA label for each rating item

string optional
size

Size of the stars

'x-small' | 'small' | 'medium' | 'large' | 'x-large' optional
className

Custom class for the component

string optional
showValue

Show rating count

boolean optional
variant

Card variant styling

'outlined' | 'filled' | 'soft' optional
halfIncrements

If true, creates a half star at 0.5

boolean optional

Files (2)

  • components/ui/rating/Rating.tsx 6 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    
    export interface RatingProps {
      /** Currently selected value */
      value?: number
      /** Default value when uncontrolled */
      defaultValue?: number
      /** Emitted when the value changes */
      onValueChange?: (value: number) => void
      /** Maximum rating value */
      max?: number
      /** If true, prevents user interaction */
      readonly?: boolean
      /** If true, disables the rating */
      disabled?: boolean
      /** Density of the component */
      density?: 'compact' | 'default' | 'comfortable'
      /** Color of the selected stars */
      color?: string
      /** If true, clicking the same value clears the rating */
      clearable?: boolean
      /** If true, stars grow on hover */
      hover?: boolean
      /** ARIA label for each rating item */
      itemAriaLabel?: string
      /** Size of the stars */
      size?: 'x-small' | 'small' | 'medium' | 'large' | 'x-large'
      /** Custom class for the component */
      className?: string
      /** Show rating count */
      showValue?: boolean
      /** Card variant styling */
      variant?: 'outlined' | 'filled' | 'soft'
      /** If true, creates a half star at 0.5 */
      halfIncrements?: boolean
    }
    
    // Star path shared by all three icon states.
    const STAR_PATH =
      'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z'
    
    // Ported 1:1 from the Vue scoped stylesheet. React has no scoped styles, so the
    // per-size star dimensions and per-variant container chrome live inline here.
    const variantClasses: Record<NonNullable<RatingProps['variant']>, string> = {
      outlined: '',
      filled: 'bg-muted p-1 rounded-lg',
      soft: 'bg-accent p-1 rounded-lg',
    }
    
    const sizeIcon: Record<NonNullable<RatingProps['size']>, React.CSSProperties> = {
      'x-small': { width: '0.875rem', height: '0.875rem' },
      small: { width: '1.125rem', height: '1.125rem' },
      medium: { width: '1.375rem', height: '1.375rem' },
      large: { width: '1.625rem', height: '1.625rem' },
      'x-large': { width: '2rem', height: '2rem' },
    }
    
    const densityPad: Record<NonNullable<RatingProps['density']>, string> = {
      compact: '0',
      default: '0.0625rem',
      comfortable: '0.125rem',
    }
    
    const Rating = React.forwardRef<HTMLDivElement, RatingProps>(
      (
        {
          value,
          defaultValue = 0,
          onValueChange,
          max = 5,
          readonly = false,
          disabled = false,
          density = 'default',
          color = 'var(--warning)',
          clearable = false,
          hover = false,
          itemAriaLabel = 'rating',
          size = 'medium',
          className,
          showValue = false,
          variant = 'outlined',
          halfIncrements = false,
        },
        ref,
      ) => {
        const isControlled = value !== undefined
        const [internal, setInternal] = React.useState(defaultValue)
        const current = isControlled ? value! : internal
    
        function emit(next: number) {
          if (!isControlled) setInternal(next)
          onValueChange?.(next)
        }
    
        function handleClick(n: number) {
          if (disabled || readonly) return
          if (clearable && n === current) emit(0)
          else emit(n)
        }
    
        function handleKeydown(e: React.KeyboardEvent, n: number) {
          if (e.key === 'Enter' || e.key === ' ') {
            e.preventDefault()
            handleClick(n)
          } else if (e.key === 'ArrowRight' && n < max) {
            emit(n + 1)
          } else if (e.key === 'ArrowLeft' && n > 1) {
            emit(n - 1)
          }
        }
    
        const iconStyle = sizeIcon[size]
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="rating"
            className={cn(
              'inline-flex items-center gap-0.5',
              variantClasses[variant],
              disabled && 'cursor-not-allowed opacity-50',
              readonly && 'cursor-default',
              showValue && 'flex items-center gap-1',
              className,
            )}
            role="radiogroup"
            aria-valuenow={current}
            aria-valuemin={0}
            aria-valuemax={max}
            aria-label={`Rating: ${current} of ${max}`}
          >
            {Array.from({ length: max }, (_, i) => i + 1).map((n) => (
              <button
                key={n}
                type="button"
                disabled={disabled || readonly}
                aria-label={`${itemAriaLabel} ${n} of ${max}`}
                tabIndex={readonly || disabled ? -1 : 0}
                onClick={() => handleClick(n)}
                onKeyDown={(e) => handleKeydown(e, n)}
                style={{ padding: densityPad[density], outlineColor: color }}
                className={cn(
                  'inline-flex items-center justify-center border-none bg-none leading-none',
                  'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
                  clearable && 'cursor-pointer',
                  hover && 'transition-transform duration-150 ease-in-out hover:scale-115',
                )}
              >
                {current >= n ? (
                  // Full star
                  <svg viewBox="0 0 24 24" fill="currentColor" style={{ ...iconStyle, color }}>
                    <path d={STAR_PATH} />
                  </svg>
                ) : current >= n - 0.5 && halfIncrements ? (
                  // Half star
                  <svg viewBox="0 0 24 24" style={{ ...iconStyle, color }}>
                    <defs>
                      <linearGradient id={`half-${n}`}>
                        <stop offset="50%" stopColor="currentColor" />
                        <stop offset="50%" stopColor="transparent" />
                      </linearGradient>
                    </defs>
                    <path d={STAR_PATH} fill={`url(#half-${n})`} stroke="currentColor" strokeWidth="1" />
                  </svg>
                ) : (
                  // Empty star
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    style={{ ...iconStyle, color: 'var(--muted-foreground)' }}
                  >
                    <path d={STAR_PATH} />
                  </svg>
                )}
              </button>
            ))}
            {showValue && (
              <span className="ml-2 font-semibold text-foreground">{current}</span>
            )}
          </div>
        )
      },
    )
    Rating.displayName = 'Rating'
    
    export { Rating }
  • components/ui/rating/index.ts 0.1 kB
    export { Rating, type RatingProps } from './Rating'

Raw manifest: https://react.uipkge.dev/r/react/rating.json