deepmoon

Rating

Interactive

Value: 3

Read-only

Out of 10

Installation

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

npm dependencies: lucide-react

Props

RatingProps
PropTypeRequired
valuenumberYes
onChange(value: number) => voidNo
maxnumberNo
readOnlybooleanNo
classNamestringNo
aria-labelstringNo

Code

rating.tsx
"use client";

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

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

export interface RatingProps {
  value: number;
  onChange?: (value: number) => void;
  max?: number;
  readOnly?: boolean;
  className?: string;
  "aria-label"?: string;
}

function StarIcon({ filled }: { filled: boolean }) {
  return (
    <Star
      aria-hidden="true"
      className={cn("size-5", filled ? "fill-primary text-primary" : "fill-none text-muted-foreground")}
    />
  );
}

/** ARIA radiogroup pattern: one star selected among discrete options, roving tabindex. */
export function Rating({ value, onChange, max = 5, readOnly = false, className, "aria-label": ariaLabel = "Rating" }: RatingProps) {
  const interactive = !readOnly && Boolean(onChange);
  const [hovered, setHovered] = React.useState<number | null>(null);
  const display = hovered ?? value;

  if (!interactive) {
    return (
      <div role="img" aria-label={`${value} out of ${max} stars`} className={cn("flex gap-0.5", className)}>
        {Array.from({ length: max }, (_, index) => (
          <StarIcon key={index} filled={index < value} />
        ))}
      </div>
    );
  }

  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      className={cn("flex gap-0.5", className)}
      onMouseLeave={() => setHovered(null)}
    >
      {Array.from({ length: max }, (_, index) => {
        const starValue = index + 1;
        const isTabStop = value === starValue || (value === 0 && starValue === 1);
        return (
          <button
            key={index}
            type="button"
            role="radio"
            aria-checked={value === starValue}
            aria-label={`${starValue} star${starValue > 1 ? "s" : ""}`}
            tabIndex={isTabStop ? 0 : -1}
            onClick={() => onChange?.(starValue)}
            onMouseEnter={() => setHovered(starValue)}
            onKeyDown={(event) => {
              let next: number | null = null;
              if (event.key === "ArrowRight" || event.key === "ArrowUp") next = Math.min(max, (value || 0) + 1);
              else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = Math.max(1, (value || 1) - 1);
              if (next === null) return;
              event.preventDefault();
              onChange?.(next);
              const group = event.currentTarget.parentElement;
              (group?.children[next - 1] as HTMLElement | undefined)?.focus();
            }}
            className="rounded outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <StarIcon filled={starValue <= display} />
          </button>
        );
      })}
    </div>
  );
}