Skip to contentVibraUI
Inputs & filters

Rating

A star score, read-only or picked with the pointer and the arrow keys.

An interactive rating is a radiogroup whose stars are named by their value ("3 stars"), with focus following the arrow keys. A rating nothing can change — readOnly, or simply no onValueChange — is a single image named in full ("4 out of 5 stars", or "Support quality: 4 out of 5 stars" with a label), with no tab stops and no hover preview, because five radios nobody can set is not what a score is. Such a score can be drawn fractional: 4.8 is four whole stars and 80% of the fifth — the filled star laid over the empty one and cut from the inline start, so a right-to-left row fills from the right — and it is still one image, named "4.8 out of 5 stars"; the partly filled star carries its share as data-fill. Without it a score is drawn in whole stars, 4.8 as four. Hovering an interactive rating previews a value without reporting it, and the preview is readable from the root as data-preview. A focused star takes the kit focus outline, focus-ring, and every star is data-slot="rating-star", so a page restyles the stars from the root className with [&_[data-slot=rating-star]]:….

Install

npx shadcn@latest add @vibra/rating

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuenumber—The score, from zero to max.
onValueChange(value: number) => void—Called with the picked score; without it there is no way to change the rating, so it renders as a static image.
maxnumber5How many stars there are.
readOnlybooleanfalseRenders the score as one labelled image even when onValueChange is set.
fractionalbooleanfalseDraws the score's fraction as part of a star — 4.8 is four whole stars and 80% of the fifth — for a score nothing can change; an interactive rating is picked a whole star at a time.
size"sm" | "default""default"sm drops the stars to size-4 for use inside rows.
labelstring"Rating"Names the group, e.g. Support quality.

Dependencies

Source

components/ui/rating.tsx
"use client"

import * as React from "react"
import { StarIcon } from "lucide-react"

import { cn } from "@/lib/utils"

const STAR_SIZES = { default: "size-5", sm: "size-4" } as const
const FILLED = "text-warning fill-current"
const EMPTY = "text-muted-foreground/40"

const starLabel = (value: number) => `${value} star${value === 1 ? "" : "s"}`

export type RatingProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  value: number
  /** Leave it out for a display-only score: with no way to change the rating it renders as a static image. */
  onValueChange?: (value: number) => void
  max?: number
  /** Renders the score as a single labelled image even when onValueChange is set. */
  readOnly?: boolean
  /**
   * Draws the fraction of a score as part of a star — 4.8 is four whole stars
   * and 80% of the fifth — where the default draws whole stars only. A score
   * nothing can change only: an interactive rating is picked a star at a time.
   */
  fractional?: boolean
  size?: "sm" | "default"
  /** Names the group, e.g. "Support quality"; falls back to "Rating". */
  label?: string
}

/** How much of a star a score covers, from 0 to 1: all of it below the score, the remainder in the star it ends in. */
function shareOf(star: number, score: number): number {
  return Math.min(Math.max(score - (star - 1), 0), 1)
}

/** A star score, read-only or picked with the pointer and the arrow keys. */
function Rating({
  className,
  value,
  onValueChange,
  max = 5,
  readOnly = false,
  fractional = false,
  size = "default",
  label,
  ...props
}: RatingProps) {
  const [preview, setPreview] = React.useState<number | null>(null)
  const starRefs = React.useRef(new Map<number, HTMLButtonElement | null>())
  const stars = Array.from({ length: max }, (_, index) => index + 1)
  const shown = preview ?? value
  // A rating nothing can change is a picture of a number, however it got that
  // way — asked for with readOnly, or left without a handler.
  const interactive = !readOnly && onValueChange !== undefined
  // Whichever star holds the tab stop, even when the score is zero, fractional,
  // or out of range.
  const tabStop = Math.min(Math.max(Math.round(value) || 1, 1), max)
  const spokenScore = `${value} out of ${max} stars`

  function select(next: number, moveFocus = false) {
    if (next < 1 || next > max) return
    onValueChange?.(next)
    // Focus follows selection in a radiogroup, and the group is controlled, so
    // the tab stop only moves once the caller applies the new value.
    if (moveFocus) starRefs.current.get(next)?.focus()
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    if (event.key === "ArrowRight" || event.key === "ArrowUp") {
      event.preventDefault()
      select(value + 1, true)
    } else if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
      event.preventDefault()
      select(value - 1, true)
    }
  }

  return (
    <div
      data-slot="rating"
      data-size={size}
      data-value={value}
      data-preview={preview ?? undefined}
      data-readonly={!interactive || undefined}
      data-fractional={(fractional && !interactive) || undefined}
      // One name reads the score out in full, instead of five radios nobody can
      // set.
      role={interactive ? "radiogroup" : "img"}
      aria-label={
        interactive ? (label ?? "Rating") : label ? `${label}: ${spokenScore}` : spokenScore
      }
      className={cn("inline-flex w-fit items-center gap-0.5", className)}
      onMouseLeave={interactive ? () => setPreview(null) : undefined}
      {...props}
    >
      {stars.map((star) => {
        const filled = star <= shown
        const icon = <StarIcon className={cn(STAR_SIZES[size], filled ? FILLED : EMPTY)} />

        if (!interactive) {
          // In fractional mode the star the score ends in is drawn in part: the
          // filled star laid over the empty one and cut at the score's share,
          // from the inline start, so a right-to-left row fills from the right.
          const share = fractional ? shareOf(star, value) : 0
          const part = share > 0 && share < 1 ? Math.round(share * 100) : null
          return (
            <span
              key={star}
              data-slot="rating-star"
              data-filled={filled || undefined}
              data-fill={part === null ? undefined : part / 100}
              className={part === null ? undefined : "relative"}
            >
              {icon}
              {part === null ? null : (
                <span
                  data-slot="rating-star-fill"
                  className="absolute inset-y-0 start-0 overflow-hidden"
                  style={{ width: `${part}%` }}
                >
                  <StarIcon className={cn(STAR_SIZES[size], FILLED, "max-w-none")} />
                </span>
              )}
            </span>
          )
        }

        return (
          <button
            key={star}
            ref={(node) => {
              starRefs.current.set(star, node)
            }}
            type="button"
            role="radio"
            aria-checked={star === value}
            aria-label={starLabel(star)}
            data-slot="rating-star"
            data-filled={filled || undefined}
            tabIndex={star === tabStop ? 0 : -1}
            onClick={() => select(star)}
            onKeyDown={handleKeyDown}
            onMouseEnter={() => setPreview(star)}
            onFocus={() => setPreview(star)}
            onBlur={() => setPreview(null)}
            // The kit's focus outline on the star itself. A page restyles the
            // stars from the root, through [data-slot=rating-star].
            className="rounded-sm p-0.5 transition-colors focus-ring"
          >
            {icon}
          </button>
        )
      })}
    </div>
  )
}

export { Rating }