Skip to content

RatingInput

MD · 1950B gzip budget

Editable star rating with keyboard support and live value.

Category
Forms & input
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
Rating
3 of 5 stars

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/rating-input.json

Copies the source into your project. Pulls in 1 shared registry item: cn.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useId, useRef } from "react";
import { cn } from "../lib/cn";

export interface RatingInputProps {
  value: number;
  max?: number;
  label: string;
  onChange?: (value: number) => void;
  className?: string;
}

function Star({ filled }: { filled: boolean }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 20 20"
      className={cn("size-5", filled ? "fill-amber-400" : "fill-neutral-300 dark:fill-neutral-700")}
    >
      <path d="M10 1.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L10 14.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z" />
    </svg>
  );
}

export function RatingInput({ value, max = 5, label, onChange, className }: RatingInputProps) {
  const id = useId();
  const stars = Array.from({ length: max }, (_, i) => i + 1);
  const refs = useRef<Array<HTMLButtonElement | null>>([]);

  const handleKeyDown = (e: React.KeyboardEvent, star: number) => {
    if (!onChange) return;
    let next = star;

    if (e.key === "ArrowRight" || e.key === "ArrowUp") {
      e.preventDefault();
      next = Math.min(max, star + 1);
    } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
      e.preventDefault();
      next = Math.max(1, star - 1);
    } else if (e.key === "Home") {
      e.preventDefault();
      next = 1;
    } else if (e.key === "End") {
      e.preventDefault();
      next = max;
    } else {
      return;
    }

    onChange(next);
    refs.current[next - 1]?.focus();
  };

  if (!onChange) {
    return (
      <span role="img" aria-label={`${label}: ${value} out of ${max} stars`} className={cn("inline-flex gap-0.5", className)}>
        {stars.map((star) => (
          <Star key={star} filled={star <= value} />
        ))}
      </span>
    );
  }

  return (
    <div className={cn("flex flex-col gap-1.5 text-start", className)}>
      <span id={id} className="text-sm font-medium">{label}</span>
      <div role="radiogroup" aria-labelledby={id} className="inline-flex gap-0.5">
        {stars.map((star) => (
          <button
            key={star}
            ref={(node) => { const arr = [...refs.current]; arr[star - 1] = node; refs.current = arr; }}
            type="button"
            role="radio"
            aria-checked={star === value}
            aria-label={`${star} star${star !== 1 ? "s" : ""}`}
            tabIndex={star === value ? 0 : -1}
            onClick={() => onChange(star)}
            onKeyDown={(e) => handleKeyDown(e, star)}
            className="cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
          >
            <Star filled={star <= value} />
          </button>
        ))}
      </div>
      <div aria-live="polite" className="text-xs text-neutral-600 dark:text-neutral-400">
        {value > 0 ? `${value} of ${max} stars` : "No rating"}
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation