Overview

Occasional delight — commit on click, preview on hover only when (hover: hover) and (pointer: fine). Fill swaps via scale/opacity (Lucide stars, not emoji). Reduced motion skips hover preview motion.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/star-rating.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/star-rating.tsx
tsx
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";import { Star } from "lucide-react";import { cn } from "@/lib/utils";
export type StarRatingProps = {  value?: number;  defaultValue?: number;  onChange?: (value: number) => void;  max?: number;  readOnly?: boolean;  className?: string;  "aria-label"?: string;};
function useFinePointer() {  const [fine, setFine] = useState(false);  useEffect(() => {    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");    const sync = () => setFine(mq.matches);    sync();    mq.addEventListener("change", sync);    return () => mq.removeEventListener("change", sync);  }, []);  return fine;}
export function StarRating({  value: valueProp,  defaultValue = 0,  onChange,  max = 5,  readOnly,  className,  "aria-label": ariaLabel = "Rating",}: StarRatingProps) {  const fine = useFinePointer();  const rootRef = useRef<HTMLDivElement>(null);  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultValue);  const value = isControlled ? valueProp : uncontrolled;  const [hover, setHover] = useState<number | null>(null);
  const preview =    !readOnly && fine && hover != null ? hover : value;
  const setValue = useCallback(    (next: number) => {      if (readOnly) return;      if (!isControlled) setUncontrolled(next);      onChange?.(next);    },    [isControlled, onChange, readOnly],  );
  const setShifts = useCallback((activeIdx: number | null, phase: "in" | "out") => {    if (!rootRef.current) return;    const cs = getComputedStyle(document.documentElement);    const num = (name: string, fb: number) => {      const v = parseFloat(cs.getPropertyValue(name));      return Number.isFinite(v) ? v : fb;    };    const ease = (name: string, fb: string) =>      cs.getPropertyValue(name).trim() || fb;
    const lift = num("--avatar-lift", -4);    const falloff = num("--avatar-falloff", 0.45);    const scale = num("--avatar-scale", 1.05);    const tf =      phase === "out"        ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")        : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");
    rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {      el.style.transitionTimingFunction = tf;      if (activeIdx == null) {        el.style.setProperty("--shift", "0px");        el.style.setProperty("--scale-active", "1");        return;      }      const d = Math.abs(i - activeIdx);      el.style.setProperty(        "--shift",        (lift * Math.pow(falloff, d)).toFixed(3) + "px",      );      el.style.setProperty(        "--scale-active",        i === activeIdx ? String(scale) : "1",      );    });  }, []);
  return (    <div      ref={rootRef}      role="radiogroup"      aria-label={ariaLabel}      className={cn("t-avatar-group inline-flex items-center gap-0.5", className)}      onMouseLeave={() => {        setHover(null);        setShifts(null, "out");      }}    >      {Array.from({ length: max }, (_, i) => {        const n = i + 1;        const filled = n <= preview;        return (          <button            key={n}            type="button"            role="radio"            aria-checked={value === n}            aria-label={`${n} of ${max}`}            disabled={readOnly}            onClick={() => setValue(n)}            onMouseEnter={() => {              if (!readOnly && fine) {                setHover(n);                setShifts(i, "in");              }            }}            className={cn(              "t-avatar flex size-10 items-center justify-center rounded-[var(--radius-sm)] outline-none",              "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",              readOnly ? "cursor-default" : "cursor-pointer",            )}          >            <Star              className={cn(                "size-5 stroke-[1.5] transition-[fill,color] duration-150",                filled                  ? "fill-[var(--color-ink)] text-[var(--color-ink)]"                  : "fill-transparent text-[var(--color-ink-muted)]",              )}              aria-hidden            />          </button>        );      })}    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { StarRating } from "@/components/ui/star-rating";
<StarRating value={score} onChange={setScore} />

Props

PropTypeRequiredDefaultDescription
valuenumberNoControlled rating (1–max).
defaultValuenumberNo0Uncontrolled initial rating.
onChange(value: number) => voidNoFires when a star is committed.
maxnumberNo5Number of stars.
readOnlybooleanNoDisplay-only; no hover preview or click.

Best Practices

  1. Gate hover preview to fine pointers — touch should only commit on tap.
  2. Hit targets are 40×40; don’t shrink the buttons further.
  3. Use readOnly for aggregate scores in lists.