Skip to content
38

Rating

Star rating with hover preview, half-star precision, read-only mode and sm / md / lg sizes.

Example

Installation

npx shadcn@latest add https://hirael.com/r/rating.json

Usage

import { Rating, RatingItem } from "@/components/rating"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<Rating />

+ native element props
PropTypeDefault
valuenumbernone
defaultValuenumbernone
onValueChange((value: number) => void)none
maxnumber5
step1 | 0.51
readOnlybooleannone
disabledbooleannone
size"sm" | "lg" | "md"'md'
namestringnone
aria-label

Defines a string value that labels the current element.

stringnone

<RatingItem />

+ native element props
PropTypeDefault
index*numbernone

Component source

'use client';

import * as React from 'react';
import { Star } from 'lucide-react';

import { cn } from '@/lib/utils';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';

interface RatingContextValue {
  value: number;
  display: number;
  max: number;
  step: 0.5 | 1;
  interactive: boolean;
  iconSize: string;
  tabStop: number;
  setHover: (next: number | null) => void;
  commit: (next: number) => void;
}

const RatingContext = React.createContext<RatingContextValue | null>(null);

const useRating = () => {
  const ctx = React.useContext(RatingContext);
  if (!ctx) {
    throw new Error('Rating compound parts must be used inside <Rating>');
  }

  return ctx;
};

export interface RatingProps extends Omit<React.ComponentProps<'div'>, 'onChange' | 'defaultValue'> {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  max?: number;
  step?: 0.5 | 1;
  readOnly?: boolean;
  disabled?: boolean;
  size?: 'sm' | 'md' | 'lg';
  name?: string;
  'aria-label'?: string;
}

const sizeMap = {
  sm: 'size-4',
  md: 'size-5',
  lg: 'size-6',
} as const;

const Rating = ({
  value: valueProp,
  defaultValue,
  onValueChange,
  max = 5,
  step = 1,
  readOnly,
  disabled,
  size = 'md',
  name,
  className,
  onKeyDown,
  onMouseLeave,
  children,
  ...props
}: RatingProps) => {
  const [hover, setHover] = React.useState<number | null>(null);

  const [value, setValue] = useControllableState({
    prop: valueProp,
    defaultProp: defaultValue ?? 0,
    onChange: onValueChange,
  });
  const display = hover ?? value;

  const interactive = !readOnly && !disabled;
  const iconSize = sizeMap[size];

  const commit = React.useCallback(
    (next: number) => {
      if (readOnly || disabled) return;
      setValue(next);
    },
    [readOnly, disabled, setValue],
  );

  const stepAligned = value > 0 && value <= max && Number.isInteger(value / step);
  const tabStop = stepAligned ? value : step;

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e);
    if (e.defaultPrevented || !interactive) return;
    const rtl = getComputedStyle(e.currentTarget).direction === 'rtl';
    const next = (
      {
        ArrowRight: value + (rtl ? -step : step),
        ArrowLeft: value + (rtl ? step : -step),
        ArrowUp: value + step,
        ArrowDown: value - step,
        Home: step,
        End: max,
      } as Record<string, number>
    )[e.key];
    if (next === undefined) return;
    e.preventDefault();
    const clamped = Math.min(max, Math.max(step, next));
    commit(clamped);
    e.currentTarget.querySelector<HTMLButtonElement>(`[data-slot="rating-radio"][data-value="${clamped}"]`)?.focus();
  };

  const ctx = React.useMemo<RatingContextValue>(
    () => ({
      value,
      display,
      max,
      step,
      interactive,
      iconSize,
      tabStop,
      setHover,
      commit,
    }),
    [value, display, max, step, interactive, iconSize, tabStop, commit],
  );

  return (
    <RatingContext.Provider value={ctx}>
      <div
        data-slot="rating"
        data-readonly={readOnly || undefined}
        data-disabled={disabled || undefined}
        role={interactive ? 'radiogroup' : 'img'}
        aria-label={props['aria-label'] ?? `Rating: ${value} of ${max}`}
        className={cn('inline-flex items-center gap-0.5', disabled && 'pointer-events-none opacity-50', className)}
        onMouseLeave={(e) => {
          onMouseLeave?.(e);
          setHover(null);
        }}
        onKeyDown={handleKeyDown}
        {...props}
      >
        {children ?? Array.from({ length: max }).map((_, i) => <RatingItem key={i} index={i} />)}

        {name && <input type="hidden" name={name} value={value} />}
      </div>
    </RatingContext.Provider>
  );
};

interface RatingRadioProps {
  value: number;
  className?: string;
}

const RatingRadio = ({ value: radioValue, className }: RatingRadioProps) => {
  const ctx = useRating();

  return (
    <button
      type="button"
      role="radio"
      aria-checked={ctx.value === radioValue}
      aria-label={`${radioValue} of ${ctx.max}`}
      tabIndex={radioValue === ctx.tabStop ? 0 : -1}
      data-slot="rating-radio"
      data-value={radioValue}
      className={cn(
        'cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
        className,
      )}
      onMouseEnter={() => ctx.setHover(radioValue)}
      onClick={() => ctx.commit(ctx.value === radioValue ? 0 : radioValue)}
    />
  );
};

export interface RatingItemProps extends Omit<React.ComponentProps<'span'>, 'children'> {
  index: number;
}

const RatingItem = ({ index, className, ...props }: RatingItemProps) => {
  const ctx = useRating();
  const fullValue = index + 1;
  const halfValue = index + 0.5;
  const filled = ctx.display >= fullValue;
  const half = !filled && ctx.display >= halfValue;

  return (
    <span
      data-slot="rating-item"
      className={cn('relative inline-flex', ctx.interactive && 'cursor-pointer', className)}
      {...props}
    >
      <Star data-slot="rating-star" className={cn(ctx.iconSize, 'text-muted-foreground/40 transition-colors')} />
      {(filled || half) && (
        <Star
          data-slot="rating-star-fill"
          className={cn(
            ctx.iconSize,
            'absolute inset-0 fill-warning text-warning transition-[clip-path]',
            half && '[clip-path:inset(0_50%_0_0)] rtl:[clip-path:inset(0_0_0_50%)]',
          )}
        />
      )}

      {ctx.interactive && ctx.step === 0.5 && (
        <>
          <RatingRadio value={halfValue} className="absolute inset-y-0 start-0 w-1/2" />
          <RatingRadio value={fullValue} className="absolute inset-y-0 end-0 w-1/2" />
        </>
      )}
      {ctx.interactive && ctx.step === 1 && <RatingRadio value={fullValue} className="absolute inset-0" />}
    </span>
  );
};

export { Rating, RatingItem };

Dependencies

shadcn registry

use-controllable-state

npm

lucide-react