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.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useEffect, useRef, useState } from "react";4import { Star } from "lucide-react";5import { cn } from "@/lib/utils";6 7export type StarRatingProps = {8 value?: number;9 defaultValue?: number;10 onChange?: (value: number) => void;11 max?: number;12 readOnly?: boolean;13 className?: string;14 "aria-label"?: string;15};16 17function useFinePointer() {18 const [fine, setFine] = useState(false);19 useEffect(() => {20 const mq = window.matchMedia("(hover: hover) and (pointer: fine)");21 const sync = () => setFine(mq.matches);22 sync();23 mq.addEventListener("change", sync);24 return () => mq.removeEventListener("change", sync);25 }, []);26 return fine;27}28 29export function StarRating({30 value: valueProp,31 defaultValue = 0,32 onChange,33 max = 5,34 readOnly,35 className,36 "aria-label": ariaLabel = "Rating",37}: StarRatingProps) {38 const fine = useFinePointer();39 const rootRef = useRef<HTMLDivElement>(null);40 const isControlled = valueProp !== undefined;41 const [uncontrolled, setUncontrolled] = useState(defaultValue);42 const value = isControlled ? valueProp : uncontrolled;43 const [hover, setHover] = useState<number | null>(null);44 45 const preview =46 !readOnly && fine && hover != null ? hover : value;47 48 const setValue = useCallback(49 (next: number) => {50 if (readOnly) return;51 if (!isControlled) setUncontrolled(next);52 onChange?.(next);53 },54 [isControlled, onChange, readOnly],55 );56 57 const setShifts = useCallback((activeIdx: number | null, phase: "in" | "out") => {58 if (!rootRef.current) return;59 const cs = getComputedStyle(document.documentElement);60 const num = (name: string, fb: number) => {61 const v = parseFloat(cs.getPropertyValue(name));62 return Number.isFinite(v) ? v : fb;63 };64 const ease = (name: string, fb: string) =>65 cs.getPropertyValue(name).trim() || fb;66 67 const lift = num("--avatar-lift", -4);68 const falloff = num("--avatar-falloff", 0.45);69 const scale = num("--avatar-scale", 1.05);70 const tf =71 phase === "out"72 ? ease("--avatar-ease-out", "cubic-bezier(0.34, 3.85, 0.64, 1)")73 : ease("--avatar-ease-in", "cubic-bezier(0.22, 1, 0.36, 1)");74 75 rootRef.current.querySelectorAll<HTMLElement>(".t-avatar").forEach((el, i) => {76 el.style.transitionTimingFunction = tf;77 if (activeIdx == null) {78 el.style.setProperty("--shift", "0px");79 el.style.setProperty("--scale-active", "1");80 return;81 }82 const d = Math.abs(i - activeIdx);83 el.style.setProperty(84 "--shift",85 (lift * Math.pow(falloff, d)).toFixed(3) + "px",86 );87 el.style.setProperty(88 "--scale-active",89 i === activeIdx ? String(scale) : "1",90 );91 });92 }, []);93 94 return (95 <div96 ref={rootRef}97 role="radiogroup"98 aria-label={ariaLabel}99 className={cn("t-avatar-group inline-flex items-center gap-0.5", className)}100 onMouseLeave={() => {101 setHover(null);102 setShifts(null, "out");103 }}104 >105 {Array.from({ length: max }, (_, i) => {106 const n = i + 1;107 const filled = n <= preview;108 return (109 <button110 key={n}111 type="button"112 role="radio"113 aria-checked={value === n}114 aria-label={`${n} of ${max}`}115 disabled={readOnly}116 onClick={() => setValue(n)}117 onMouseEnter={() => {118 if (!readOnly && fine) {119 setHover(n);120 setShifts(i, "in");121 }122 }}123 className={cn(124 "t-avatar flex size-10 items-center justify-center rounded-[var(--radius-sm)] outline-none",125 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",126 readOnly ? "cursor-default" : "cursor-pointer",127 )}128 >129 <Star130 className={cn(131 "size-5 stroke-[1.5] transition-[fill,color] duration-150",132 filled133 ? "fill-[var(--color-ink)] text-[var(--color-ink)]"134 : "fill-transparent text-[var(--color-ink-muted)]",135 )}136 aria-hidden137 />138 </button>139 );140 })}141 </div>142 );143}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { StarRating } from "@/components/ui/star-rating";2 3<StarRating value={score} onChange={setScore} />
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| value | number | No | — | Controlled rating (1–max). |
| defaultValue | number | No | 0 | Uncontrolled initial rating. |
| onChange | (value: number) => void | No | — | Fires when a star is committed. |
| max | number | No | 5 | Number of stars. |
| readOnly | boolean | No | — | Display-only; no hover preview or click. |
Best Practices
- Gate hover preview to fine pointers — touch should only commit on tap.
- Hit targets are 40×40; don’t shrink the buttons further.
- Use
readOnlyfor aggregate scores in lists.