Rating

Inline star rating for lightweight learner feedback.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/rating.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client";

import { useMemo, useState } from "react";

import { Star } from "lucide-react";
import type { ReactNode } from "react";

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

const sizeClasses = {
  lg: "size-6",
  md: "size-5",
  sm: "size-4",
};

export type RatingProps = {
  allowClear?: boolean;
  className?: string;
  defaultValue?: number;
  label?: string;
  max?: number;
  onValueChange?: (value: number) => void;
  readOnly?: boolean;
  showValue?: boolean;
  size?: keyof typeof sizeClasses;
  value?: number;
};

type RatingStarsProps = {
  activeValue: number;
  hoveredValue: number;
  label: string;
  max: number;
  onHoverChange: (value: number) => void;
  onSelect: (value: number) => void;
  readOnly: boolean;
  size: keyof typeof sizeClasses;
};

function RatingStars({
  activeValue,
  hoveredValue,
  label,
  max,
  onHoverChange,
  onSelect,
  readOnly,
  size,
}: RatingStarsProps): ReactNode {
  const stars = useMemo(
    () => Array.from({ length: max }, (_, index) => index + 1),
    [max],
  );
  const displayValue = hoveredValue || activeValue;

  return (
    <div
      aria-label={label}
      className="inline-flex items-center gap-1"
      role="radiogroup"
    >
      {stars.map((starValue) => {
        const isFilled = starValue <= displayValue;

        return (
          <button
            aria-checked={activeValue === starValue}
            aria-label={`${starValue} ${starValue === 1 ? "star" : "stars"}`}
            className={cn(
              "rounded-sm text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
              !readOnly && "hover:text-amber-500",
              isFilled && "text-amber-500",
            )}
            disabled={readOnly}
            key={starValue}
            onBlur={() => {
              onHoverChange(0);
            }}
            onClick={() => {
              onSelect(starValue);
            }}
            onMouseEnter={() => {
              if (!readOnly) {
                onHoverChange(starValue);
              }
            }}
            onMouseLeave={() => {
              onHoverChange(0);
            }}
            role="radio"
            type="button"
          >
            <Star
              className={cn(sizeClasses[size], isFilled && "fill-current")}
              strokeWidth={1.75}
            />
          </button>
        );
      })}
    </div>
  );
}

export function Rating({
  allowClear = false,
  className,
  defaultValue = 0,
  label = "Rating",
  max = 5,
  onValueChange,
  readOnly = false,
  showValue = false,
  size = "md",
  value,
}: RatingProps): ReactNode {
  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(defaultValue);
  const [hoveredValue, setHoveredValue] = useState(0);
  const activeValue = isControlled ? (value ?? 0) : internalValue;

  const handleSelect = (nextValue: number): void => {
    const resolvedValue =
      allowClear && activeValue === nextValue ? 0 : nextValue;

    if (!isControlled) {
      setInternalValue(resolvedValue);
    }

    onValueChange?.(resolvedValue);
  };

  return (
    <div className={cn("inline-flex flex-col gap-2", className)}>
      <div className="inline-flex items-center gap-3">
        <RatingStars
          activeValue={activeValue}
          hoveredValue={hoveredValue}
          label={label}
          max={max}
          onHoverChange={setHoveredValue}
          onSelect={handleSelect}
          readOnly={readOnly}
          size={size}
        />
        {showValue ? (
          <span className="text-sm text-muted-foreground">
            {activeValue}/{max}
          </span>
        ) : null}
      </div>
    </div>
  );
}
typescript

Dependencies

  • @vllnt/ui@^0.2.1