Rating

A star scale for the judgements Comitor apps collect — how a support conversation went, how well a lead fits, how strong an interview was. Underneath it is a real radio group: every star is a named radio, exactly one is checked, and inside a form it posts its value like any other field.

Import
import { Rating, RatingItem, useRating } from '@comitor/ui' — the main entry. No optional peer to install.
Runtime
A client component. useRating throws outside a Rating, and itemLabel is a function, so it has to be written in a client component — it cannot cross from a Server Component.
Origin
Ported in 2.1.0 from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own primitives and tokens. What changed on the way is listed under What the port guarantees; the release notes are in the changelog.

Anatomy

Rating is the group, RatingItem is one point on the scale. The group never draws stars on its own — you render them, and each takes its value from its position: the first item is 1, the fifth is 5. Keep max equal to the number you render; it is what screen readers get as the size of the set.

anatomy.tsx
import { Rating, RatingItem } from '@comitor/ui'

<Rating                        // role="radiogroup" — name it: aria-label or aria-labelledby
  value={value}                // or defaultValue — 0 means "not rated"
  onValueChange={setValue}
  max={5}                      // keep it equal to the number of RatingItems you render
  name="rating"                // inside a <form>: a hidden input after the group posts the value
>
  <RatingItem />               // role="radio" — the 1st item is value 1, the 5th is value 5
  <RatingItem />
  <RatingItem />
  <RatingItem />
  <RatingItem />               // children: any node, or (state) => node — a Star by default
</Rating>

Every item carries data-state — full, partial or empty — and data-hovered while the pointer previews it, so a custom icon can be styled per state without a render function.

Basic usage

Controlled with value and onValueChange; 0 means not rated. onHover reports the star under the pointer and null when it leaves, which is what the caption uses to preview a word before the click. The group needs a name of its own — the component names every star but never the group — so pass aria-label or, as here, aria-labelledby pointing at the visible question.

How was your conversation with Mai?

Not rated yet

value = 0 · hovered = null

Half stars and read-only

step={0.5} splits each star: the left half picks x.5, the right half the whole value (mirrored with dir="rtl"), and ← → move by half a star. readOnly keeps the group visible and focusable, but no click, hover or key changes its value — the usual shape for an average next to its review count.

4.4 out of 5 · 128 reviews

  • Trần Minh

    Set up in ten minutes, and the Chat bridge worked on the first try.

  • Emma Lindqvist

    Solid board. I still miss swimlanes per client.

  • Phạm Quỳnh Anh

    Recurring tasks saved our Monday stand-up. Exports are slow on big projects.

One thing the component leaves to you: it does not round. An average of 4.43 passed straight in draws four full stars — the fifth only turns half-full from 4.5 — while the fifth radio is the one marked checked. Snap to the step first. Naming that radio is taken care of: the checked star of a half value is named after the value, so the fifth star above is announced as “4.5 stars, checked” (“4,5 sao” in Vietnamese), not “5 stars” — since 3.0.1; on 3.0.0, pass an itemLabel that names that one star after the real value. The half itself is painted with the group’s own text colour — the port’s fix, explained below.

Keyboard: when focus is a choice

With the default activationMode="automatic", the arrow keys choose as they move: each star they land on becomes the value, as in a native radio group. Tab is not a choice — it moves focus into the group and checks nothing, as the WAI-ARIA radio pattern has it, so an unrated group stays unrated — and Enter or Space pick the star that has focus. With manual, the arrows only move focus too, and the value waits for Enter or Space. Tab into each group below — neither value moves — then try the arrows.

Onboarding (automatic)

value = 0

Onboarding (manual)

value = 0

So the modes differ in the arrows, not in Tab. Automatic changes the value — and calls onValueChange — at every star on the way, so going from 1 to 4 reports 2 and 3 as well; manual reports only the star confirmed. Prefer manual where each change costs something, such as a scorecard that saves as it goes. Either mode is fine in a form that posts, and with clearable, which resets to 0 only when the checked star is chosen again. Fixed in 3.0.1 — in 3.0.0, automatic mode also chose the star Tab landed on: tabbing past an unrated group rated it 1, and tabbing into a rated, clearable one reset it to 0, so use manual for both there.

In a form

Give it a name and it joins the form: a hidden <input> sits right after the group — only when a <form> is an ancestor — and every change goes through the native value setter plus a bubbling input event, so a form-level listener hears it. Submit the form below without touching the stars: it posts rating = "3". Before 2.1.0 the same form posted rating = "" — the hidden input wrote its value only when it changed, and the first render wrote nothing.

Overall

FormData on submit

Nothing sent yet — try submitting untouched.

Two things a hidden input cannot do. It takes no part in constraint validation — browsers skip type="hidden", so required only reaches assistive technology, as aria-required — and it cannot tell “not rated” from a number: it posts "0". The submit handler checks for that itself. To see it, choose the checked star again (click it, or focus it and press Enter) — the demo is clearable in manual mode — and submit.

Scales that are not stars

The star is only the default child. RatingItem takes any node — or a function of its state — and the fill rules apply to whatever <svg> is inside, so an HR skills matrix can use dots. itemLabel is where the scale gets its words: each dot below is named after the level it stands for, so a screen reader hears “Proficient, 4 of 5” rather than “4 stars”. The caption reads the rating’s own store with useRating; it renders inside the group, so it is aria-hidden — the checked radio already says the same thing.

TypeScript
Figma
Vietnamese

Colour comes from the group’s text colour — text-primary-ink by default, text-foreground here — and every icon, half-filled ones included, follows it.

What the port guarantees

Behaviour you can rely on that the Dice UI original did not give you — each point compared against the upstream source.

  • Every star has a name. Each radio carries aria-label from itemLabel(value, max) — “1 star”, “4 stars”, or “4 sao” under a Vietnamese provider. Upstream each radio held nothing but an <svg>, so a screen reader said “radio button, 3 of 5” without saying three of what. Since 3.0.1 the checked star of a half value is named after the value — “4.5 stars”, not “5 stars”.
  • Exactly one radio is checked. It is the star that contains the value (3.5 → the fourth), and it follows the real value, never the star under the pointer. Upstream set aria-checked on every filled star, so choosing four reported four checked radios in one radio group — a state no screen reader can express.
  • Tab does not rate. In automatic mode a star is chosen on focus only when focus comes from another star of the same group — the arrow keys, Home, End. Upstream chose on every keyboard focus, so tabbing through a form rated an unrated group 1 star and cleared a rated, clearable one (fixed in 3.0.1).
  • Half stars take the star colour. In SVG, currentColor inside a gradient resolves where the gradient is declared, not where it is used. Upstream declared the half-star gradient beside the group, so half stars were painted in the page’s text colour — #0E1116 in light, #F7F7F8 in dark — instead of the star colour; it went unnoticed there because shadcn’s primary is nearly black. The port declares it inside the group, so a half is exactly the group’s colour, whatever you set. The one exception is asChild: a Slot cannot take the extra child, so the gradient stays outside and the colour must be set on an element wrapping both.
  • Stars use the ink role. The group is text-primary-ink, not upstream’s text-primary. In the default palette the two are the same gold; the high-contrast palette re-points only the ink step — to #7A5800 in light (6.51:1 on the background), with #F5D060 in dark (12.67:1). The package’s contrast gate cannot read a url(#…) fill, so the half-star rule carries an exemption recording those numbers.
  • An untouched form posts the default. <Rating name="rating" defaultValue={3} /> in a form submitted before anyone touched it used to post rating=""; the hidden input now starts with the initial value, and every later change still fires a bubbling input event. The fix lives in the package’s shared hidden input, so the other custom controls that post through it get it too.
  • The group states its condition. aria-disabled, aria-readonly and aria-required land on the radio group — upstream set only data-* attributes — and the star icons are aria-hidden.

Language

Rating has one string, and nobody sees it: the accessible name of each star. It lives in the rating namespace as a function, itemLabel(value, max), because the number sits inside the sentence and English also needs a singular — “1 star”, “4 stars”, and “4 sao” in the Vietnamese table (comitorMessagesVi.rating), which writes a half value with a decimal comma: “4,5 sao”. There is no DEFAULT_RATING_LABELS constant; the per-instance override is the itemLabel prop, which beats the nearest ComitorLocaleProvider, which beats English. Switch the language and read the names back:

Accessible names in the DOM (en):

The group’s own aria-label is always your string, in whatever language the page is in. Where the provider goes and how messages overrides work: Get started → Set the language.

Props

Rating — every other prop goes to the group’s <div>.

PropTypeDefaultDescription
valuenumber—Controlled value. 0 means not rated. Pair it with onValueChange.
defaultValuenumber0Uncontrolled starting value. Inside a form it is also what an untouched form posts (since 2.1.0).
onValueChange(value: number) => void—Fires on every change — click, keyboard, clearing — and also when a controlled value prop changes.
onHover(value: number | null) => void—The value under the pointer (x.5 over a left half with step 0.5), and null when the pointer leaves a star. Never fires while readOnly or disabled.
maxnumber5The top of the scale. It does not render anything — you render the RatingItems — but it feeds aria-setsize, the second argument of itemLabel, and the ceiling of the arrow keys in half-star mode. Keep it equal to the number of items.
step0.5 | 110.5 splits each star: the left half picks x.5 (the right half in rtl) and the arrow keys move by 0.5 in both activation modes.
activationMode'automatic' | 'manual''automatic'automatic: the arrow keys, Home and End select the star they move to. manual: they only move focus. In both, Enter or Space select the focused star, and Tab into the group selects nothing (since 3.0.1 — in 3.0.0 automatic selected the star Tab landed on). With step 0.5 the arrows change the value in either mode.
clearablebooleanfalseChoosing the current value again — a click, or Enter or Space on the checked star — sets 0. Works in either activation mode since 3.0.1; in 3.0.0, automatic mode cleared a rated group as soon as Tab landed on its checked star, so pair it with manual there.
readOnlybooleanfalseShows the value and stays focusable, but no click, hover or key changes the value. Sets aria-readonly on the group.
disabledbooleanfalseDisables every star and takes the group out of the tab order. The hidden form input is disabled too, so nothing is posted.
requiredbooleanfalseSets aria-required on the group — and nothing else. The hidden input cannot block a submit; check for 0 yourself.
namestring—Name of the hidden input rendered after the group. The input only exists when a form element is an ancestor.
itemLabel(value: number, max: number) => stringrating.itemLabelAccessible name of each star, called with that star's whole value — except the checked star of a fractional value, which gets the value itself (4.5 → '4.5 stars', since 3.0.1). Defaults to the provider's table: '1 star', '4 stars' in English, '4 sao' in Vietnamese.
size'sm' | 'default' | 'lg''default'Box of each item: size-4, size-5 or size-6 (16, 20 or 24px at the default font size), 4px apart.
orientation'horizontal' | 'vertical''horizontal'Stacks the items and swaps the navigation keys: Up and Down in vertical, Left and Right in horizontal. The other pair is ignored.
dir'ltr' | 'rtl'from DirectionProvider, else 'ltr'Mirrors the arrow keys, the half-star gradient and which half of a star picks x.5.
asChildbooleanfalseRenders your element as the group. The half-star gradient then has to sit outside it, so set the star colour on an element that wraps both.
aria-label / aria-labelledbystring—The group's name. The component names every star but never the group — pass one of these on every rating.
classNamestring—Merged onto the group, after text-primary-ink. A text colour here recolours every icon, halves included.

RatingItem — every other prop goes to the star’s <button>.

PropTypeDefaultDescription
indexnumberrender orderZero-based position; the item's value is index + 1. Auto-assigned the first time each item renders — pass it when items can mount later or change order, or they are numbered after the ones already seen.
childrenReactNode | (state: 'full' | 'partial' | 'empty') => ReactNode<Star />The icon. Any svg inside gets the fill rules — filled when full, transparent when empty, the gradient when partial — and is sized to the item unless it has its own size-* class.
disabledbooleanfalseDisables one item. The group's disabled wins over it.
asChildbooleanfalseRenders your element as the radio instead of a button.
data-state'full' | 'partial' | 'empty'—Set by the component on every item, next to data-hovered while the pointer previews it. Style against these.

The hook. Prefer onValueChange and onHover for anything outside the group; the hook is for what renders inside it.

PropTypeDefaultDescription
useRating(selector)(state: { value: number; hoveredValue: number | null }) => T—Reads the nearest Rating's store and re-renders when the selected slice changes. Throws when called outside a Rating.

Known limitations

  • Controlled means echoed, not enforced. The rating updates its own state before it calls onValueChange, so a parent that ignores the call does not undo the change — and passing the same old value again changes nothing. To stop changes, use readOnly or disabled.
  • No rounding. A value off the step grid is drawn rounded down to the step, while the star above it is the one checked. Round first: Math.round(value / step) * step.
  • required never blocks a submit, and “not rated” posts "0". Hidden inputs are outside constraint validation. Validate in the submit handler or your form library.
  • Only an ancestor form counts. The hidden input is rendered when closest('form') finds one, so a rating tied to a form elsewhere through the form attribute posts nothing. A disabled rating posts nothing either — disabled inputs are left out of FormData.
  • No Escape-to-clear. Dice UI’s documentation lists Esc as “clear the rating” when clearable; neither its code nor the port handles the key. Clear by choosing the current value again.
  • Enter picks the half in manual half-star mode. Manual activation fires a synthetic click(), which carries no pointer position, and the click handler decides the half from the pointer — so Enter on the fourth star selects 3.5. The arrow keys step by 0.5 in either mode and are the dependable keyboard route for halves.
  • Auto-numbering counts every mount. An item without index is numbered the first time it renders, so items that mount later — a scale that grows from 5 to 10, stars rendered conditionally — continue from the last number handed out. Pass index whenever the list can change.

Accessibility

  • The group is role="radiogroup" with aria-orientation; each star is a <button type="button" role="radio"> with aria-checked (on exactly one), aria-posinset / aria-setsize, and a name from itemLabel. The type attribute means a star never submits the form it sits in.
  • Name the group yourself — aria-label or aria-labelledby on Rating. A radio group without a name is announced as just “radio group”.
  • Keyboard: the group is one Tab stop, entered on the checked star, else the star last visited, else the first — and entering it never changes the value. ← → move (↑ ↓ when vertical; mirrored in rtl) and Home and End jump to the ends, each of them choosing as it goes in automatic mode; Enter and Space choose the focused star in either mode; Shift Tab leaves backwards in one step. With step={0.5} the arrows change the value by half a star.
  • The focus ring is the one Checkbox and RadioGroup use: a 2px ring-ring/50 with a 2px offset, on the star that has focus.
  • A read-only rating stays focusable, like a native read-only input, so each one is a Tab stop — a list of thirty reviews is thirty stops. A disabled rating leaves the tab order entirely.
  • The hover preview is visual only; screen readers hear real changes of value, not the pointer moving.
  • In a form the value posts through the hidden input, but the error state is yours: set aria-invalid on the group and point aria-describedby at the message, as the form demo does — the group already carries aria-required when required is set.
  • Print the number next to a read-only rating. In the default light palette the gold star measures 1.86:1 on white — under the 3:1 WCAG 1.4.11 asks of a graphic that carries meaning, a palette-wide decision the package records in its stylesheet; the dark and high-contrast palettes clear it — so the value must not depend on seeing the outlines.
  • For ratings people change, prefer the default or lg size. sm stars are 16px on a 20px pitch, under the 24px target size of WCAG 2.5.8; keep them for display.

Related

  • Commerce — product cards and reviews, where an average sits next to its count.
  • HR — interview scorecards and performance reviews, the manual-mode case.
  • CRM — scoring how well a lead or an account fits.
  • Radio Group — the plain control Rating is modelled on, for choices that are not a scale.
  • Field — titles, descriptions and errors around a rating in a form.