← ui

rating

ui

stars to rate with, by halves, or the average of many beside its count

npx shadcn add @ag/rating
Already installed? Preview an update
npx shadcn add @ag/rating --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Fixed: a value that is no number (an average of no ratings, 0/0) or a count that is no whole number from 0 throws instead of drawing a full line. Inside a Setting at the root of its chain (nothing to inherit), Delete, 0, Home and a press on the value clear it to no rating, as anywhere else; they did nothing.
  • Fixed: a rating inside a hidden tab panel (kept mounted) draws its fractions right once the panel shows; it could collapse to too few stars.
  • New: right to left. Under dir="rtl" (on it or anywhere above) the line fills from its right, a half is a star's right half to the hand, and ArrowLeft gives more, ArrowRight less; Up and Down are unchanged. An average's fraction is cut from its right end too. Nothing changes left to right.
Updated 2026-10-12 · release notes

also called star rating, stars, review score

shelf ratings & feedback

uses field-context, key-router, material, setting-context

used by components

the demo above, verbatim

rating.demo.tsx
"use client"

import { Heart } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Field } from "@/registry/base-nova/ui/field"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Rating } from "@/registry/base-nova/ui/rating"
import { Setting } from "@/registry/base-nova/ui/setting"
import { Switch } from "@/registry/base-nova/ui/switch"

// #region halves
/** Halves: the hand previews the half it is on, a press sets it, a press on the
 *  value it already has clears it. */
function Halves() {
  const [stars, setStars] = React.useState(3.5)
  return (
    <div className="flex items-center gap-3">
      <Rating
        aria-label="the film"
        step={0.5}
        value={stars}
        onValueChange={setStars}
      />
      <span className="w-6 font-mono text-[12px] text-current/60 tabular-nums">
        {stars || "none"}
      </span>
    </div>
  )
}
// #endregion

// #region review
/** Your rating and everyone's, as a page shows them together. */
function Review() {
  const [stars, setStars] = React.useState(4)
  return (
    <div className="flex flex-col items-start gap-3">
      <Rating
        aria-label="your rating"
        step={0.5}
        value={stars}
        onValueChange={setStars}
      />
      <Rating value={4.3} count={120} aria-label="everyone" />
    </div>
  )
}
// #endregion

// #region stay
/** In a field and a form: named by the label, wrong while it has no star, held
 *  still while the form sends; `name` posts it with the form. */
function Stay() {
  const [stars, setStars] = React.useState(0)
  const [held, setHeld] = React.useState(false)
  const [posted, setPosted] = React.useState<string | null>(null)
  return (
    <form
      className="flex flex-col items-start gap-4"
      onSubmit={(e) => {
        e.preventDefault()
        const sent = new FormData(e.currentTarget).get("stars")
        setPosted(JSON.stringify(sent))
      }}
    >
      <Field
        label="how was the stay"
        description="half stars allowed"
        readOnly={held}
        error={stars === 0 ? "a star at least" : undefined}
      >
        <Rating
          name="stars"
          step={0.5}
          value={stars}
          onValueChange={setStars}
        />
      </Field>
      <Switch checked={held} onCheckedChange={setHeld}>
        read-only (a form sending)
      </Switch>
      <div className="flex items-center gap-3">
        <Button type="submit" size="sm">
          send
        </Button>
        {posted !== null && (
          <span className="font-mono text-[12px] text-current/60">
            stars = {posted}
          </span>
        )}
      </div>
    </form>
  )
}
// #endregion

// #region inherited
/** The lowest rating a feed shows, falling back to the workspace's: a ghost of
 *  what applies until one is set here. */
function Lowest() {
  const [lowest, setLowest] = React.useState<number | undefined>(undefined)
  return (
    <Setting
      label="lowest rating shown"
      value={lowest}
      fallback={{ value: 3, from: "the workspace" }}
      onValueChange={setLowest}
      format={(v) => `${v} stars`}
    >
      {(s) => (
        <Rating
          aria-label="lowest rating shown"
          step={0.5}
          value={s.value}
          onValueChange={s.pin}
        />
      )}
    </Setting>
  )
}
// #endregion

export default function RatingDemo() {
  const [hearts, setHearts] = React.useState(3)
  const [heat, setHeat] = React.useState(2)
  const [effort, setEffort] = React.useState(6)
  return (
    <div className="space-y-8">
      <Sample name="halves" glance title="half stars" with="halves">
        <Halves />
      </Sample>
      <Sample
        name="materials"
        title="plain and glass"
        description="yours, set by the hand or the keys (arrows by the step, Home, End, a digit, 0 or Delete to clear), and an average of many beside its count."
        with="review"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Review />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-6 text-white">
              <Review />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="average"
        title="an average of many"
        description="given a count it is a readout: one image to a screen reader (4.3 of 5 stars, 120 ratings), never a Tab stop, the fraction filled."
      >
        <div className="flex flex-col items-start gap-2">
          <Rating value={4.3} count={120} />
          <Rating value={3.5} count={8} tone="var(--tone-warn)" />
          <Rating value={4.82} count={2431} tone="var(--tone-warn)" />
          <Rating value={0} count={0} />
        </div>
      </Sample>
      <Sample name="field" title="in a field and a form" with="stay">
        <Stay />
      </Sample>
      <Sample name="inherited" title="following a default" with="inherited">
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Lowest />
          </div>
          <MaterialProvider material="glass" scheme="dark">
            <div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
              <Lowest />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="glyphs"
        title="any glyph, any count"
        description="an svg is filled in the tone; a character is greyed where it is empty."
      >
        <div className="flex flex-col items-start gap-2">
          <Rating
            aria-label="how much you like it"
            glyph={<Heart />}
            tone="var(--tone-bad)"
            format={(v, max) => (v ? `${v} of ${max} hearts` : "no hearts")}
            value={hearts}
            onValueChange={setHearts}
          />
          <Rating
            aria-label="heat"
            glyph="🌶️"
            max={3}
            format={(v, max) => `${v} of ${max} chillies`}
            value={heat}
            onValueChange={setHeat}
          />
          <Rating
            aria-label="effort"
            size="sm"
            max={10}
            format={(v, max) => `${v} of ${max}`}
            value={effort}
            onValueChange={setEffort}
          />
        </div>
      </Sample>
      <Sample name="sizes" title="the input's heights">
        <div className="flex flex-col items-start gap-2">
          {(["sm", "md", "lg"] as const).map((size) => (
            <Rating
              key={size}
              size={size}
              aria-label={`${size} rating`}
              defaultValue={3}
            />
          ))}
        </div>
      </Sample>
      <Sample
        name="right-to-left"
        title="right to left"
        description="under dir=rtl the line fills from its right, a half is a star's right half, and the arrows follow the line: left gives more."
      >
        <div dir="rtl" className="flex flex-col items-start gap-2">
          <Halves />
          <Rating value={4.3} count={120} aria-label="everyone" />
        </div>
      </Sample>
      <Sample
        name="read-only"
        title="read-only"
        description="a person's rating held still: kept in the Tab order and read out (3.5 of 5 stars, read-only), the hand and the keys change nothing. never dimmed, which is disabled."
      >
        <div className="flex flex-col items-start gap-2">
          <Rating aria-label="your rating" value={3.5} readOnly />
          <Rating aria-label="your rating" value={2} disabled />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

rating.tsx
"use client"

import { Star } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useField } from "@/registry/base-nova/ui/field-context"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
  GLASS_GHOST,
  useSetting,
} from "@/registry/base-nova/ui/setting-context"

// A JUDGEMENT ON A LINE OF STARS (or any glyph handed in): a person's own rating, or
// the average of many. The value is a number from 0 (no rating) to `max`, drawn as
// that much of the line filled, so a 3.5 is three stars and a half and an average of
// 4.3 fills 4.3.
//
//   own      a slider (`role="slider"`, "3.5 of 5 stars"): one Tab stop, the arrows
//            by `step` (1, or 0.5 for halves), PageUp and PageDown to the next whole
//            star, Home (0) and End, a digit to its star, 0, Delete or Backspace to
//            clear.
//            The hand previews before it sets: the line fills to where the pointer
//            is, a half when it is on a star's left half (step 0.5); a press sets it,
//            a drag along the line sets where it lets go, and a press on the value
//            it already has clears it. `name` submits it with a form ("" when none).
//            Right to left (`dir="rtl"` on it or above) the line fills from its
//            right, halves are a glyph's right half, and the arrows follow the line.
//   average  given `count` (how many rated), a readout: one image to a screen
//            reader ("4.3 of 5 stars, 120 ratings"), never focused, the number and
//            the count beside the line.
//
// Plain: bare glyphs, the empty ones the text's colour at a fifth, the filled ones
// `tone` (the text's colour unless given). `material="glass"`: the line on a pill of
// glass at the input's heights. `readOnly` holds it still (focus and reading kept,
// never dimmed). Inside a field it is named and described by it, red when invalid,
// held still while the field is read-only. Inside a `Setting` whose value is
// inherited, the filled part is a ghost, hollow; pressing the value that applies sets
// it here, and clearing goes back to what it inherits.

export type RatingSize = "sm" | "md" | "lg"
export type RatingStep = 0.5 | 1

/** The line at the input's heights, so it sits in a form's row as a field does. */
const ROW: Record<RatingSize, string> = {
  sm: "h-7 text-[12px]",
  md: "h-8 text-[13px]",
  lg: "h-10 text-sm",
}
/** One glyph's box; an svg fills it, a character is set at its size. */
const CELL: Record<RatingSize, string> = {
  sm: "size-4 text-[14px]",
  md: "size-5 text-[17px]",
  lg: "size-6 text-[21px]",
}
/** The glass pill: its radius half of `ROW`'s height. */
const GLASS_PILL: Record<RatingSize, string> = {
  sm: "px-2 [--glass-radius:14px]",
  md: "px-2.5 [--glass-radius:16px]",
  lg: "px-3 [--glass-radius:20px]",
}

/** Every glyph, both layers: an svg fills its box, drawn solid in its colour. */
const GLYPH =
  "col-start-1 row-start-1 grid place-items-center leading-none [&_svg]:size-full [&_svg]:fill-current [&_svg]:stroke-current [&_svg]:stroke-[1.5]"
/** The line under the value: the text's colour at a fifth (a character, a picture
 *  of its own colours, greyed). Red, faintly, when the field says it is wrong. */
const EMPTY = "text-current opacity-20 [&:not(:has(svg))]:grayscale"
const EMPTY_INVALID = "text-tone-bad opacity-45"
/** What the value fills, cut at its fraction (`--rating-clip`, worked out per glyph). */
const FULL = "text-(--rating-tone,currentColor) [clip-path:var(--rating-clip)]"
/** Inherited (inside a `Setting`, nothing set here): the value that applies drawn
 *  hollow, its outline in the tone (a character at a little over half). */
const FULL_GHOST = "[&_svg]:fill-transparent [&:not(:has(svg))]:opacity-55"

export interface RatingProps {
  /** 0 (no rating) to `max`; an average may be any fraction. Controlled. */
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
  /** How many glyphs; 5 by default. */
  max?: number
  /** What a press, the hand and the arrows move by: 1 (whole stars), or 0.5. */
  step?: RatingStep
  /** The glyph, drawn empty and filled: a star unless given (an svg is filled in its
   *  colour; a character is greyed where empty). */
  glyph?: React.ReactNode
  /** The filled glyphs' colour; the text's colour otherwise. */
  tone?: string
  /** How many rated: makes it the average of their ratings, a readout. */
  count?: number
  /** The value as words, for the reader. Default "3.5 of 5 stars", "no rating". */
  format?: (value: number, max: number) => string
  /** Submitted with a form under this name: the value, or "" when there is none. */
  name?: string
  size?: RatingSize
  /** Shown, never changed here: the hand and the keys change nothing, the Tab stop
   *  and the reading stay. Never dimmed (that is `disabled`). A field's read-only
   *  and a locked setting's hold it the same way. */
  readOnly?: boolean
  disabled?: boolean
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  /** What is rated, for assistive tech. */
  "aria-label"?: string
  /** Or the label that names it (a field's is used unless this or a label is given). */
  "aria-labelledby"?: string
  /** What else says something about it; a field's and a setting's words join it. */
  "aria-describedby"?: string
  className?: string
  ref?: React.Ref<HTMLDivElement>
}

/** A value as a person reads it: one decimal at most. */
const tenths = (v: number) => String(Math.round(v * 10) / 10)

const starWords = (value: number, max: number) =>
  value === 0 ? "no rating" : `${tenths(value)} of ${max} stars`

/** Where a glyph's ink lies across its box, as shares of its width. */
interface Ink {
  from: number
  to: number
}
/** A character's: its whole box. */
const WHOLE: Ink = { from: 0, to: 1 }

/** An svg's ink, read off the drawing itself: its shapes' extent in its viewBox,
 *  widened by half its stroke. */
function inkOf(svg: SVGSVGElement): Ink {
  const view = svg.viewBox.baseVal
  if (!view || view.width === 0) return WHOLE
  const shapes = svg.getBBox()
  const half = (Number.parseFloat(getComputedStyle(svg).strokeWidth) || 0) / 2
  return {
    from: Math.max(0, (shapes.x - half - view.x) / view.width),
    to: Math.min(1, (shapes.x + shapes.width + half - view.x) / view.width),
  }
}

/** The clip that shows `fill` (0 to 1) of a glyph's ink from the line's start, its
 *  left, or its right when the line runs right to left: a fraction is a share of what
 *  is drawn, not of the box it is drawn in (lucide's star leaves a twelfth of its box
 *  empty each side, and 4.3 cut on the box showed a sliver of the fifth star's arm
 *  where three tenths of a star belonged). */
const clipOf = (fill: number, ink: Ink, rtl: boolean) => {
  if (fill >= 1) return "none"
  if (fill <= 0) return "inset(0 100% 0 0)"
  const reach = fill * (ink.to - ink.from)
  return rtl
    ? `inset(0 0 0 ${(ink.to - reach) * 100}%)`
    : `inset(0 ${(1 - (ink.from + reach)) * 100}% 0 0)`
}

/** The line runs right to left: the page's `dir`, or any ancestor's, as CSS resolved
 *  it on the element. */
const rightToLeft = (el: Element) => getComputedStyle(el).direction === "rtl"

export function Rating({
  value: given,
  defaultValue = 0,
  onValueChange,
  max = 5,
  step = 1,
  glyph = <Star />,
  tone,
  count,
  format = starWords,
  name,
  size = "md",
  readOnly,
  disabled,
  material,
  scheme,
  "aria-label": label,
  "aria-labelledby": labelledBy,
  "aria-describedby": describedBy,
  className,
  ref,
}: RatingProps) {
  if (!Number.isInteger(max) || max < 1)
    throw new Error(`Rating: max is a whole number of glyphs, got ${max}`)
  if (step !== 1 && step !== 0.5)
    throw new Error(`Rating: step is 1 or 0.5, got ${step}`)
  const average = count !== undefined
  if (average && onValueChange)
    throw new Error(
      "Rating: with a count it is an average of many ratings, a readout; a person's own rating takes no count",
    )
  const m = useMaterial(material)
  // Over a picture it is read by the part of it under the line.
  const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
  const field = useField()
  const setting = useSetting()
  const ghost = setting?.inherited ?? false
  const held =
    (readOnly ?? false) ||
    (field?.readOnly ?? false) ||
    (setting?.readOnly ?? false)
  const off = disabled ?? field?.wiring.disabled ?? false
  const live = !average && !held && !off

  const [own, setOwn] = React.useState(defaultValue)
  const value = given ?? own
  if (!(value >= 0 && value <= max))
    throw new Error(`Rating: value ${value} is outside 0 to ${max}`)
  if (average && !(Number.isInteger(count) && count >= 0))
    throw new Error(`Rating: count is how many rated, got ${count}`)

  // What the hand would set, drawn until it leaves or sets it. `quiet` is the value a
  // press just set (or cleared from): the hand resting where it pressed previews
  // nothing until it moves to another, so a clear shows the empty line at once.
  const [preview, setPreview] = React.useState<number | null>(null)
  const quiet = React.useRef<number | null>(null)
  const press = React.useRef<{ at: number; moved: boolean } | null>(null)

  const set = (next: number) => {
    if (!live) return
    if (given === undefined) setOwn(next)
    onValueChange?.(next)
  }
  // No rating. Inside a setting with something to inherit: back to it when one is set
  // here (as an emptied number goes back), nothing while it is inherited; anywhere
  // else (a setting at the root of its chain included), 0.
  const clear = () => {
    if (!live) return
    if (setting?.fallback) return setting.clear?.()
    set(0)
  }

  /** The value under the pointer: the glyph it is over, a half on its first half
   *  (its left, or its right right to left) when halves are allowed; before the first
   *  glyph the lowest step, past the last the whole line. */
  const under = (root: HTMLElement, x: number): number => {
    const rtl = rightToLeft(root)
    const cells = root.querySelectorAll<HTMLElement>("[data-slot=rating-item]")
    let i = 0
    while (i < cells.length - 1) {
      const box = (cells[i] as HTMLElement).getBoundingClientRect()
      if (rtl ? x > box.left : x < box.right) break
      i++
    }
    const box = (cells[i] as HTMLElement).getBoundingClientRect()
    const into = (rtl ? box.right - x : x - box.left) / box.width
    return step === 0.5 && into <= 0.5 ? i + 0.5 : i + 1
  }

  const hover = (at: number) => {
    if (quiet.current === at) return
    quiet.current = null
    setPreview(at)
  }

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (!live || ownedHere(e.nativeEvent, e.currentTarget)) return
    const up = Math.floor(value / step + 1e-9) * step + step
    const down = Math.ceil(value / step - 1e-9) * step - step
    const more = () => set(Math.min(max, up))
    const less = () => (down <= 0 ? clear() : set(down))
    // Right to left the line grows leftwards, and the arrows follow the line.
    const rtl = rightToLeft(e.currentTarget)
    const keyed: Record<string, () => void> = {
      ArrowRight: rtl ? less : more,
      ArrowUp: more,
      ArrowLeft: rtl ? more : less,
      ArrowDown: less,
      PageUp: () => set(Math.min(max, Math.floor(value + 1e-9) + 1)),
      PageDown: () => {
        const next = Math.ceil(value - 1e-9) - 1
        return next <= 0 ? clear() : set(next)
      },
      Home: clear,
      End: () => set(max),
      Delete: clear,
      Backspace: clear,
    }
    const digit = /^[0-9]$/.test(e.key) ? Number(e.key) : null
    const act =
      keyed[e.key] ??
      (digit === 0
        ? clear
        : digit !== null && digit <= max
          ? () => set(digit)
          : undefined)
    if (!act || e.metaKey || e.ctrlKey || e.altKey) return
    e.preventDefault()
    // The keys speak now: a preview the hand left standing gives way to the value
    // until the hand moves on.
    quiet.current = preview
    setPreview(null)
    act()
  }

  // The glyph's ink, measured once it is drawn: what a fraction is a share of; and
  // which way the line runs, which end a fraction fills from. Read after every render
  // (a new glyph) and whenever the line is laid out anew: one mounted inside a hidden
  // tab panel has no box to read until the panel shows, and its ink stays what it was
  // until then, never a zero-width ink that draws no star.
  const drawn = React.useRef<HTMLSpanElement>(null)
  const [ink, setInk] = React.useState<Ink>(WHOLE)
  const [rtl, setRtl] = React.useState(false)
  const measure = React.useCallback(() => {
    const el = drawn.current
    if (!el) throw new Error("rating drew no line")
    const svg = el.querySelector("svg")
    if (!svg) setInk(WHOLE)
    else if (svg.getBBox().width > 0) {
      const next = inkOf(svg)
      setInk((was) =>
        was.from === next.from && was.to === next.to ? was : next,
      )
    }
    setRtl(rightToLeft(el))
  }, [])
  React.useLayoutEffect(measure)
  React.useLayoutEffect(() => {
    const el = drawn.current
    if (!el) throw new Error("rating drew no line")
    const laid = new ResizeObserver(measure)
    laid.observe(el)
    return () => laid.disconnect()
  }, [measure])

  const shown = preview ?? value
  const words = format(value, max)
  const described =
    [describedBy ?? field?.wiring["aria-describedby"], setting?.says]
      .filter(Boolean)
      .join(" ") || undefined
  const invalid = field?.invalid ?? false
  const glass = m === "glass"

  const line = Array.from({ length: max }, (_, i) => {
    const fill = Math.min(1, Math.max(0, shown - i))
    return (
      <span
        // biome-ignore lint/suspicious/noArrayIndexKey: a glyph is its place on the line
        key={i}
        data-slot="rating-item"
        data-fill={fill === 1 ? "full" : fill === 0 ? "empty" : "part"}
        className="grid px-px"
      >
        <span aria-hidden className={cn("grid", CELL[size])}>
          <span className={cn(GLYPH, invalid ? EMPTY_INVALID : EMPTY)}>
            {glyph}
          </span>
          <span
            className={cn(
              GLYPH,
              FULL,
              ghost && FULL_GHOST,
              "transition-opacity duration-150 group-data-preview/rating:opacity-75 motion-reduce:transition-none",
            )}
            style={
              { "--rating-clip": clipOf(fill, ink, rtl) } as React.CSSProperties
            }
          >
            {glyph}
          </span>
        </span>
      </span>
    )
  })

  const face = cn(
    "group/rating inline-flex w-fit shrink-0 items-center gap-2 select-none",
    ROW[size],
    schemeOf(s),
    glass
      ? [
          glassVariants({ shape: "surface" }),
          GLASS_PILL[size],
          live && "hover:[--glass-rim:1.4]!",
          invalid &&
            "[--glass-stain:var(--tone-bad)]! [--glass-rim-color:var(--tone-bad)]! [--glass-rim:2.2]!",
          ghost && GLASS_GHOST,
        ]
      : "rounded-md",
    off && "cursor-not-allowed opacity-50",
    live ? "cursor-pointer touch-pan-y" : "cursor-default",
    className,
  )
  const style = tone
    ? ({ "--rating-tone": tone } as React.CSSProperties)
    : undefined

  if (average) {
    const said =
      count === 0
        ? "no ratings yet"
        : `${words}, ${count} rating${count === 1 ? "" : "s"}`
    return (
      <div
        ref={here}
        role="img"
        aria-label={label ? `${label}: ${said}` : said}
        data-slot="rating"
        data-size={size}
        data-material={m}
        data-average=""
        className={face}
        style={style}
      >
        <span ref={drawn} className="flex">
          {line}
        </span>
        <span
          data-slot="rating-count"
          className="flex items-baseline gap-1 tabular-nums"
        >
          {count > 0 && (
            <span className="font-medium text-current/85">{tenths(value)}</span>
          )}
          <span className="text-current/50">
            ({count.toLocaleString("en-US")})
          </span>
        </span>
      </div>
    )
  }

  return (
    <div
      ref={here}
      role="slider"
      id={field?.wiring.id}
      tabIndex={off ? -1 : 0}
      aria-label={label}
      aria-labelledby={labelledBy ?? (label ? undefined : field?.labelId)}
      aria-describedby={described}
      aria-invalid={invalid || undefined}
      aria-readonly={held || undefined}
      aria-disabled={off || undefined}
      aria-orientation="horizontal"
      aria-valuemin={0}
      aria-valuemax={max}
      aria-valuenow={value}
      aria-valuetext={words}
      data-slot="rating"
      data-size={size}
      data-material={m}
      data-inherited={ghost ? "" : undefined}
      data-readonly={held ? "" : undefined}
      data-disabled={off ? "" : undefined}
      data-preview={preview !== null ? "" : undefined}
      className={face}
      style={style}
      onKeyDown={onKeyDown}
      onPointerMove={(e) => {
        if (!live) return
        if (e.pointerType === "mouse" && !press.current)
          return hover(under(e.currentTarget, e.clientX))
        if (!press.current) return
        const at = under(e.currentTarget, e.clientX)
        if (at !== press.current.at) press.current.moved = true
        setPreview(at)
      }}
      onPointerLeave={() => {
        if (press.current) return
        quiet.current = null
        setPreview(null)
      }}
      onPointerDown={(e) => {
        if (!live || e.button !== 0) return
        e.currentTarget.setPointerCapture(e.pointerId)
        const at = under(e.currentTarget, e.clientX)
        press.current = { at, moved: false }
        quiet.current = null
        setPreview(at)
      }}
      onPointerUp={(e) => {
        const p = press.current
        if (!p) return
        press.current = null
        const at = under(e.currentTarget, e.clientX)
        // A press on the value it already has clears it; inherited, that press says
        // "this one, set here". A drag sets where it lets go.
        if (!p.moved && at === value && !ghost) clear()
        else set(at)
        quiet.current = at
        setPreview(null)
      }}
      onPointerCancel={() => {
        press.current = null
        setPreview(null)
      }}
    >
      <span ref={drawn} className="flex">
        {line}
      </span>
      {name && (
        <input
          type="hidden"
          name={name}
          value={value === 0 ? "" : String(value)}
        />
      )}
    </div>
  )
}