shadimage

Rating

Compound star rating built from Rating and RatingButton — hover to preview, click or use arrow keys to set the value, with configurable size on the root, a springy scale pop, and color fade that ripples across the stars.

rating.tsx

"use client";

import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { motion, useReducedMotion } from "motion/react";
import type {
  ComponentPropsWithoutRef,
  KeyboardEvent,
  MouseEvent,
  ReactElement,
  ReactNode,
} from "react";
import {
  Children,
  cloneElement,
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";

import { cn } from "@/lib/utils";

const STAR_POINTS =
  "9 1.75 11.24 6.289 16.25 7.017 12.625 10.551 13.481 15.54 9 13.185 4.519 15.54 5.375 10.551 1.75 7.017 6.76 6.289 9 1.75";

const POP_SPRING = {
  type: "spring",
  stiffness: 300,
  damping: 7,
  mass: 0.9,
} as const;
const STAGGER_SECONDS = 0.025;

type StarIconProps = { className?: string; size?: number };

function StarIcon({ className, size = 24 }: StarIconProps) {
  return (
    <svg
      className={className}
      fill="none"
      height={size}
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth={1.5}
      viewBox="0 0 18 18"
      width={size}
      xmlns="http://www.w3.org/2000/svg"
    >
      <polygon points={STAR_POINTS} />
    </svg>
  );
}

// Reused across every RatingButton instance — a plain element with no
// per-instance state, so allocating it once avoids a fresh object per render.
const DEFAULT_ICON = <StarIcon />;
const DEFAULT_SIZE = 20;

type RatingContextValue = {
  value: number;
  readOnly: boolean;
  size: number;
  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 = {
  /** Icon element to render. Must accept size and className. @default <StarIcon /> */
  icon?: ReactElement<StarIconProps>;
  /** Icon size in pixels. Falls back to the parent Rating's size. @default 20 */
  size?: number;
  /** Optional class names applied to the button. */
  className?: string;
  /** Position in the scale. Injected automatically by Rating from child order — you don't need to pass it yourself. */
  index?: number;
};

// Renders a single star. Must be used inside Rating — reads shared state
// from context rather than taking a value prop directly.
export const RatingButton = ({
  index: providedIndex,
  size: sizeProp,
  className,
  icon = DEFAULT_ICON,
}: RatingButtonProps) => {
  const {
    value,
    readOnly,
    size: contextSize,
    hoverValue,
    focusedStar,
    handleValueChange,
    handleKeyDown,
    setHoverValue,
    setFocusedStar,
  } = useRating();
  const size = sizeProp ?? contextSize;
  const reduceMotion = useReducedMotion();

  const index = providedIndex ?? 0;
  const isActive = index < (hoverValue ?? focusedStar ?? value ?? 0);
  const isChecked = value === index + 1;
  const tabIndex = readOnly
    ? -1
    : isChecked || (value === 0 && index === 0)
      ? 0
      : -1;

  const handleClick = useCallback(
    (event: MouseEvent<HTMLButtonElement>) => {
      handleValueChange(event, isChecked ? 0 : index + 1);
    },
    [handleValueChange, index, isChecked]
  );

  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 (
    // biome-ignore lint/a11y/useSemanticElements: a native <input type="radio"> can't host the icon/motion content each star renders
    <button
      aria-checked={isChecked}
      aria-label={`Rate ${index + 1} star${index === 0 ? "" : "s"}`}
      className={cn(
        "rounded-full p-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ic-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ic-card)]",
        readOnly ? "cursor-default" : "cursor-pointer",
        className
      )}
      disabled={readOnly}
      onBlur={handleBlur}
      onClick={handleClick}
      onFocus={handleFocus}
      onKeyDown={handleKeyDown}
      onMouseEnter={handleMouseEnter}
      role="radio"
      tabIndex={tabIndex}
      type="button"
    >
      <motion.span
        animate={{ scale: isActive ? 1 : 0.75 }}
        className="inline-flex"
        transition={
          reduceMotion
            ? { duration: 0 }
            : { ...POP_SPRING, delay: index * STAGGER_SECONDS }
        }
        whileHover={readOnly || reduceMotion ? undefined : { scale: 1.18 }}
        whileTap={readOnly || reduceMotion ? undefined : { scale: 0.82 }}
      >
        {cloneElement(icon, {
          size,
          className: cn(
            "transition-colors duration-200 ease-out",
            isActive
              ? "fill-current text-[var(--ic-primary)]"
              : "text-[var(--ic-border)]"
          ),
        })}
      </motion.span>
    </button>
  );
};

export type RatingProps = Omit<
  ComponentPropsWithoutRef<"div">,
  "defaultValue" | "onChange"
> & {
  /** One RatingButton per star. Their count is the scale. */
  children?: ReactNode;
  /** Controlled rating value. Pair with onValueChange. */
  value?: number;
  /** Initial rating for uncontrolled usage. @default 0 */
  defaultValue?: number;
  /** Called with the new value on click or arrow key. */
  onValueChange?: (value: number) => void;
  /** Lower-level change handler that also receives the originating mouse or keyboard event. */
  onChange?: (
    event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,
    value: number
  ) => void;
  /** Renders every RatingButton as a native disabled button, for showing an existing rating without interaction. @default false */
  readOnly?: boolean;
  /** Icon size in pixels for all RatingButton children. @default 20 */
  size?: number;
};

// Compound star rating root. Manages the current value, icon size, and
// shared interaction state for its RatingButton children — tap a star to
// set the rating instantly, drag your focus with the Arrow keys, or tap the
// already-selected star again to clear it.
export const Rating = ({
  value: controlledValue,
  onValueChange: controlledOnValueChange,
  defaultValue = 0,
  onChange,
  readOnly = false,
  size = DEFAULT_SIZE,
  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,
  });

  // Children.toArray drops null/false/undefined entries and normalizes
  // keys, so conditionally rendered RatingButton children never leave a
  // gap in the index sequence or inflate the Home/End/Arrow-key total.
  const validChildren = Children.toArray(children).filter(
    Boolean
  ) as ReactElement<RatingButtonProps>[];
  const total = validChildren.length;

  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 || total === 0) {
        return;
      }

      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, total, 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,
    size,
    hoverValue,
    focusedStar,
    handleValueChange,
    handleKeyDown,
    setHoverValue,
    setFocusedStar,
  };

  return (
    <RatingContext.Provider value={contextValue}>
      <div
        aria-label="Rating"
        aria-readonly={readOnly}
        className={cn("inline-flex items-center gap-0.5", className)}
        onMouseLeave={() => setHoverValue(null)}
        ref={containerRef}
        role="radiogroup"
        {...props}
      >
        {validChildren.map((child, index) => cloneElement(child, { index }))}
      </div>
    </RatingContext.Provider>
  );
};
Where it goes
testimonial

DependenciesBundle weight · medium

ui-primitives
@radix-ui/react-use-controllable-state
animation
motion