Browse the library
Components / Feedback
Star rating
FreeAnimated fractional star rating with review count, plus an accessible interactive input mode.
components/facade/star-rating.tsx
"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils";
const SIZES = {
sm: { star: "size-3.5", gap: "gap-0.5", text: "text-xs" },
md: { star: "size-5", gap: "gap-1", text: "text-sm" },
lg: { star: "size-7", gap: "gap-1.5", text: "text-base" },
} as const;
/* ------------------------------------------------------------------
Display mode
------------------------------------------------------------------- */
export interface StarRatingProps {
/** Rating value, fractional allowed (e.g. 4.8). Defaults to 5. */
value?: number;
max?: number;
/** Number of reviews. Shown as "4.8 ยท 312 Google reviews". */
count?: number;
/** Review source shown before "reviews", e.g. "Google". */
source?: string;
size?: keyof typeof SIZES;
/** Hide the numeric value and count. */
starsOnly?: boolean;
/** Animate the fill when scrolled into view. */
animate?: boolean;
className?: string;
}
export function StarRating({
value = 5,
max = 5,
count,
source,
size = "md",
starsOnly = false,
animate = true,
className,
}: StarRatingProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-40px 0px" });
const reduce = useReducedMotion();
const clamped = Math.max(0, Math.min(max, value));
const s = SIZES[size];
const shouldAnimate = animate && !reduce;
const label = `Rated ${clamped.toFixed(1)} out of ${max}${count ? `, based on ${count.toLocaleString("en-US")} reviews` : ""}`;
return (
<div ref={ref} className={cn("inline-flex items-center gap-2", className)} role="img" aria-label={label}>
<div className={cn("flex items-center", s.gap)} aria-hidden>
{Array.from({ length: max }, (_, i) => {
const fill = Math.max(0, Math.min(1, clamped - i));
return (
<span key={i} className={cn("relative inline-block shrink-0", s.star)}>
<Star className={cn("absolute inset-0 fill-muted text-muted-foreground/25", s.star)} strokeWidth={1.5} />
<motion.span
className="absolute inset-0 overflow-hidden"
initial={shouldAnimate ? { clipPath: "inset(0 100% 0 0)" } : false}
animate={
inView || !shouldAnimate
? { clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` }
: { clipPath: "inset(0 100% 0 0)" }
}
transition={{ duration: 0.5, delay: shouldAnimate ? 0.08 * i : 0, ease: [0.22, 1, 0.36, 1] }}
>
<Star className={cn("fill-warning text-warning", s.star)} strokeWidth={1.5} />
</motion.span>
</span>
);
})}
</div>
{!starsOnly && (
<span className={cn("flex items-center gap-1.5 whitespace-nowrap", s.text)} aria-hidden>
<span className="font-mono font-semibold tabular-nums text-foreground">{clamped.toFixed(1)}</span>
{typeof count === "number" && (
<>
<span className="text-muted-foreground/60">ยท</span>
<span className="text-muted-foreground">
{count.toLocaleString("en-US")} {source ? `${source} ` : ""}
{count === 1 ? "review" : "reviews"}
</span>
</>
)}
</span>
)}
</div>
);
}
/* ------------------------------------------------------------------
Input mode (radio group semantics)
------------------------------------------------------------------- */
export interface StarRatingInputProps {
value?: number;
defaultValue?: number;
onChange?: (value: number) => void;
max?: number;
size?: keyof typeof SIZES;
/** Accessible group label. */
label?: string;
/** Labels per star, shown as hint text. */
hints?: string[];
name?: string;
disabled?: boolean;
className?: string;
}
const DEFAULT_HINTS = ["Poor", "Fair", "Good", "Great", "Excellent"];
export function StarRatingInput({
value: valueProp,
defaultValue = 0,
onChange,
max = 5,
size = "lg",
label = "Your rating",
hints = DEFAULT_HINTS,
name,
disabled = false,
className,
}: StarRatingInputProps) {
const [inner, setInner] = React.useState(defaultValue);
const value = valueProp ?? inner;
const [hover, setHover] = React.useState<number | null>(null);
const refs = React.useRef<Array<HTMLButtonElement | null>>([]);
const reduce = useReducedMotion();
const s = SIZES[size];
const shown = hover ?? value;
const groupId = React.useId();
const commit = (v: number) => {
if (disabled) return;
if (valueProp === undefined) setInner(v);
onChange?.(v);
};
const onKeyDown = (e: React.KeyboardEvent, i: number) => {
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowUp") next = Math.min(max, i + 2);
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = Math.max(1, i);
if (e.key === "Home") next = 1;
if (e.key === "End") next = max;
if (next !== null) {
e.preventDefault();
commit(next);
refs.current[next - 1]?.focus();
}
};
return (
<div className={cn("inline-flex flex-col gap-2", className)}>
<div
role="radiogroup"
aria-label={label}
aria-describedby={`${groupId}-hint`}
aria-disabled={disabled || undefined}
className={cn("flex items-center", s.gap, disabled && "opacity-50")}
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: max }, (_, i) => {
const n = i + 1;
const active = n <= shown;
const checked = value === n;
const tabbable = value === 0 ? i === 0 : checked;
return (
<motion.button
key={n}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={`${n} ${n === 1 ? "star" : "stars"}${hints[i] ? `, ${hints[i]}` : ""}`}
tabIndex={tabbable ? 0 : -1}
disabled={disabled}
onClick={() => commit(n)}
onMouseEnter={() => !disabled && setHover(n)}
onFocus={() => setHover(null)}
onKeyDown={(e) => onKeyDown(e, i)}
whileTap={reduce ? undefined : { scale: 0.86 }}
className={cn(
"relative rounded-md p-0.5 outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
disabled ? "cursor-not-allowed" : "cursor-pointer",
)}
>
<motion.span
className="block"
animate={
reduce
? undefined
: { scale: active && hover !== null ? 1.12 : 1, rotate: checked && hover === null ? [0, -8, 0] : 0 }
}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
>
<Star
className={cn(
s.star,
"transition-colors duration-150",
active ? "fill-warning text-warning" : "fill-muted text-muted-foreground/30",
hover !== null && active && n > value && "fill-warning/60 text-warning/70",
)}
strokeWidth={1.5}
aria-hidden
/>
</motion.span>
</motion.button>
);
})}
</div>
<p id={`${groupId}-hint`} className="h-5 text-sm text-muted-foreground" aria-live="polite">
{shown > 0 ? (
<span>
<span className="font-medium text-foreground">{hints[shown - 1] ?? `${shown} stars`}</span>
{hover === null && value > 0 ? " selected" : ""}
</span>
) : (
"Tap a star to rate"
)}
</p>
{name && <input type="hidden" name={name} value={value || ""} />}
</div>
);
}
export default StarRating;app/page.tsx
"use client";
import * as React from "react";
import { CircleCheck } from "lucide-react";
import { StarRating, StarRatingInput } from "@/components/facade/star-rating";
const breakdown = [
{ stars: 5, pct: 86 },
{ stars: 4, pct: 9 },
{ stars: 3, pct: 3 },
{ stars: 2, pct: 1 },
{ stars: 1, pct: 1 },
];
export default function Demo() {
const [rating, setRating] = React.useState(0);
return (
<div className="mx-auto grid w-full max-w-3xl gap-4 p-4 md:grid-cols-2">
<section className="rounded-2xl border border-border bg-card p-5 text-card-foreground">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Bright Smile Dental, Denver</p>
<div className="mt-3 flex items-end gap-3">
<span className="font-display text-5xl font-semibold tracking-tight">4.8</span>
<span className="pb-1.5 text-sm text-muted-foreground">out of 5</span>
</div>
<StarRating value={4.8} count={312} source="Google" className="mt-2" />
<ul className="mt-5 space-y-1.5">
{breakdown.map((b) => (
<li key={b.stars} className="flex items-center gap-3 text-xs">
<span className="w-3 font-mono text-muted-foreground">{b.stars}</span>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-warning" style={{ width: `${b.pct}%` }} />
</span>
<span className="w-8 text-right font-mono tabular-nums text-muted-foreground">{b.pct}%</span>
</li>
))}
</ul>
</section>
<section className="flex flex-col rounded-2xl border border-border bg-card p-5 text-card-foreground">
<h3 className="font-display text-lg font-semibold tracking-tight">How was your cleaning with Dr. Patel?</h3>
<p className="mt-1 text-sm text-muted-foreground">Your feedback helps neighbors in Cherry Creek find great care.</p>
<StarRatingInput className="mt-5" value={rating} onChange={setRating} label="Rate your visit" />
<div className="mt-auto flex items-center justify-between gap-3 pt-5">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CircleCheck className="size-3.5 text-success" aria-hidden />
Verified patient
</span>
<button
type="button"
disabled={rating === 0}
className="h-9 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
>
Continue
</button>
</div>
</section>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/star-rating.jsonUsage
import { StarRating } from "@/components/facade/star-rating";
export default function Page() {
return <StarRating />;
}Props
StarRatingProps is exported, so you can extend or wrap it. Every prop has a default; <StarRating /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| value | number | Rating value, fractional allowed (e.g. 4.8). Defaults to 5. |
| max | number | |
| count | number | Number of reviews. Shown as "4.8 ยท 312 Google reviews". |
| source | string | Review source shown before "reviews", e.g. "Google". |
| size | keyof typeof SIZES | |
| starsOnly | boolean | Hide the numeric value and count. |
| animate | boolean | Animate the fill when scrolled into view. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade star rating to this page and adapt the copy to our business.