Skip to content
goodtabs UI
BrowseBrowse components

Rating input

Five stars as a radio group, with hover preview, arrow keys and a read only display.

npx shadcn@latest add https://ui.goodtabs.co/r/rating-input.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/rating-input

Controlled. Hover previews a value, click or arrow keys set it.

Read only in a review row

  • Mira Hale

    Installed with one command and matched our theme straight away.

  • Tom Okafor

    Calm defaults. I only changed the button label.

The sm display inside a list of reviews, labelled Rated 4 of 5.

Large

How was the install?

24px stars under a question, uncontrolled with a default value.

Usage

import { RatingInput } from "@/components/ui/rating-input"

<RatingInput value={rating} onValueChange={setRating} />
<RatingInput readOnly value={4} size="sm" />

Props

PropTypeDefaultDescription
valuenumberControlled value, 0 to max. Leave out to go uncontrolled.
defaultValuenumber0Starting value for the uncontrolled form.
onValueChange(value: number) => voidFires with the new value.
maxnumber5Number of stars.
size"sm" | "default" | "lg""default"Star size: 14, 16 or 24px.
readOnlybooleanfalseRenders a static display with an aria-label like Rated 4 of 5.
labelstring"Rating"Accessible name of the group.

Source

rating-input/rating-input.tsx
"use client";

import * as React from "react";
import { StarIcon } from "lucide-react";
import { cn } from "@/lib/utils";

type Size = "sm" | "default" | "lg";

type RatingInputProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** Controlled value, 0 to max. Leave out for an uncontrolled input. */
  value?: number;
  /** Starting value for the uncontrolled form. */
  defaultValue?: number;
  /** Fires with the new value when a star is chosen. */
  onValueChange?: (value: number) => void;
  /** Number of stars. */
  max?: number;
  /** Star size: 14, 16 or 24px. */
  size?: Size;
  /** Renders a static display with an aria-label like "Rated 4 of 5". */
  readOnly?: boolean;
  /** Accessible name of the group. */
  label?: string;
};

const star = { sm: "size-3.5", default: "size-4", lg: "size-6" };
const hit = { sm: "size-5", default: "size-6", lg: "size-8" };

/**
 * Five stars as a radio group. Arrow keys move the value, hover previews it.
 * Monochrome: filled stars are foreground, empty ones are the muted outline.
 */
export function RatingInput({
  value,
  defaultValue = 0,
  onValueChange,
  max = 5,
  size = "default",
  readOnly = false,
  label = "Rating",
  className,
  ...props
}: RatingInputProps) {
  const [inner, setInner] = React.useState(defaultValue);
  const [hover, setHover] = React.useState<number | null>(null);
  const controlled = value !== undefined;
  const current = controlled ? value : inner;
  const shown = hover ?? current;
  const stars = Array.from({ length: max }, (_, i) => i + 1);

  const update = (next: number) => {
    if (!controlled) setInner(next);
    onValueChange?.(next);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    let next: number | null = null;
    if (e.key === "ArrowRight" || e.key === "ArrowUp") next = Math.min(max, current + 1);
    if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = Math.max(1, current - 1);
    if (e.key === "Home") next = 1;
    if (e.key === "End") next = max;
    if (next === null) return;
    e.preventDefault();
    update(next);
    const group = e.currentTarget.parentElement;
    group?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next - 1]?.focus();
  };

  if (readOnly) {
    return (
      <div
        role="img"
        aria-label={`Rated ${current} of ${max}`}
        data-slot="rating-display"
        className={cn("inline-flex items-center gap-0.5", className)}
        {...props}
      >
        {stars.map((n) => (
          <StarIcon
            key={n}
            aria-hidden="true"
            className={cn(star[size], n <= current ? "fill-current text-foreground" : "text-muted-foreground")}
          />
        ))}
      </div>
    );
  }

  return (
    <div
      role="radiogroup"
      aria-label={label}
      data-slot="rating-input"
      className={cn("inline-flex items-center", className)}
      onMouseLeave={() => setHover(null)}
      {...props}
    >
      {stars.map((n) => {
        const checked = n === current;
        const tabbable = current === 0 ? n === 1 : checked;
        return (
          <button
            key={n}
            type="button"
            role="radio"
            aria-checked={checked}
            aria-label={`${n} of ${max}`}
            tabIndex={tabbable ? 0 : -1}
            onClick={() => update(n)}
            onKeyDown={onKeyDown}
            onMouseEnter={() => setHover(n)}
            onFocus={() => setHover(null)}
            className={cn(
              "inline-flex items-center justify-center rounded-sm transition-colors duration-100 ease-out outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
              hit[size],
              n <= shown ? "text-foreground" : "text-muted-foreground",
            )}
          >
            <StarIcon aria-hidden="true" className={cn(star[size], n <= shown && "fill-current")} />
          </button>
        );
      })}
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/rating-input/prompt.md, or the Copy AI prompt button above.

When to use

  • Collecting a one to five score in a review or feedback form
  • Showing a score next to a product, a review or a listing with readOnly
  • When the input should stay small enough to sit on one row with a label

When not to use

  • Scales with more than seven steps or with text anchors: use a slider or a radio group
  • A like or save mark, which is one toggle, not a scale
  • Scores that need half stars; this one steps by whole numbers

Agent notes

  • onValueChange is a function, so the component that renders a controlled RatingInput must be a client component
  • Roving tabindex: one star is in the tab order, arrow keys move the value and the focus, Home and End jump
  • A value of 0 means nothing chosen; the first star is then the tab stop
  • readOnly swaps the buttons for a role img with the label Rated n of max, so it is one accessible node
  • No colour: filled stars use fill-current on text-foreground, empty ones are the muted outline