Rating

A star rating: a read-only display of an average, with half stars and a review count, or, given onChange, a radio group the user sets.

Commerce Rating.jsx Rating.d.ts Rating.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Rating is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Rating } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

A star rating: a read-only display of an average, with half stars and a review count, or, given onChange, a radio group the user sets.

Use it when

  • Showing a product's, a place's or a dish's average review score (display).
  • Asking the user to score something on a short scale, in a review form (input).

Don't use it when

  • The scale is not a quality score. Use Progress for completion and levels.
  • You need more than about ten steps, or labelled options ("Poor", "Fair", "Good"). Use RadioGroup or Slider.
  • The score is the thing being read closely, as in a review summary table. Show the number in Text beside the stars too.

Example

{/* Display: "4.5 out of 5 stars, 128 reviews" */}
<Rating value={4.4} count={128} />

{/* Input: a radio group named "Your rating" */}
<Rating label="Your rating" value={stars} onChange={setStars} size="lg" />

Variants

ModeHow you get itWhat it renders
DisplayNo onChangerole="img". value is rounded to the nearest half star. count shows "(128)" after the stars.
InputonChange and labelrole="radiogroup" of whole stars, each role="radio". Hovering previews a score.

Sizes: sm (16px stars, for cards and dense lists), md (20px, default), lg (24px, a product page or review form). In an input each star sits in a control-sized box (24, 32 or 40px), so it is easy to hit.

max sets the number of stars (default 5).

Composition

Inline. It sits beside a Price on a product card, under a title in a Card, or in a Field-style row of a review form with a visible label. Other commerce components (product cards, menus) show their ratings with it in display mode.

Tokens

Tier 3, in tokens/component/commerce.css, each repeated under .dark:

  • --dt-rating-color (--dt-surface-warning): the filled star.
  • --dt-rating-empty-color (--dt-border-strong): the unfilled part of the scale.
  • --dt-rating-count-color (--dt-text-secondary): the review count.

Star sizes read --dt-size-icon-sm|md|lg; input boxes read --dt-size-control-xs|sm|md; the count reads --dt-text-body-xs|sm|md-*. A half star clips the filled glyph over the empty one in CSS, so it needs no SVG ids and follows the tokens into a dark band.

Accessibility

  • Display: one role="img" element named "4.5 out of 5 stars", plus ", 128 reviews" when count is given. The stars and the visible count are not read separately. Pass label to replace the name, for example to translate it.
  • Input: role="radiogroup" named by the required label prop (the types make it required whenever onChange is present). Each star is role="radio" named "1 star", "2 stars"… with aria-checked.
  • Keyboard (input): one tab stop, on the checked star (the first star when nothing is chosen). ArrowRight / ArrowUp choose the next star and ArrowLeft / ArrowDown the previous, wrapping at the ends; Home and End choose the first and last. Arrows follow reading direction inside dir="rtl". Space or Enter chooses the focused star. Focus shows the system focus ring.
  • Colour is not the only signal: filled and empty stars differ in colour, and the accessible name carries the number.

Content

  • An input's label names the question in sentence case: "Your rating", "Rate your order".
  • Show count whenever you have it. Five stars from two reviews and from two thousand are not the same claim.

Props

The typed contract, from Rating.d.ts.

import * as React from "react";

interface RatingBaseProps extends Omit<React.HTMLAttributes<HTMLElement>, "onChange" | "role" | "children"> {
  /**
   * The rating, from 0 to max. A display rounds it to the nearest half star; an input
   * rounds it to a whole star. 0 in an input means nothing is chosen yet.
   */
  value: number;
  /** Number of stars in the scale. @default 5 */
  max?: number;
  /** @default "md" */
  size?: "sm" | "md" | "lg";
  /** BCP 47 locale for the numbers in the count and the accessible name. Defaults to the runtime's locale. */
  locale?: string;
}

/** A read-only star rating: role="img", named like "4.5 out of 5 stars, 128 reviews". */
export interface RatingDisplayProps extends RatingBaseProps {
  /** Omit onChange for a display. */
  onChange?: undefined;
  /** Number of reviews, shown as "(128)" after the stars and added to the accessible name. */
  count?: number;
  /** Replaces the generated accessible name, e.g. to translate it. */
  label?: string;
}

/** A star rating the user sets: a radio group of whole stars. */
export interface RatingInputProps extends RatingBaseProps {
  /** Called with the chosen number of stars, from 1 to max. Its presence makes the rating an input. */
  onChange: (value: number) => void;
  /** The radio group's accessible name, e.g. "Your rating". Required for an input. */
  label: string;
  /** The review count is display only. */
  count?: undefined;
}

/** A star rating: a display when onChange is absent, a radio-group input when it is given. */
export type RatingProps = RatingDisplayProps | RatingInputProps;

export declare function Rating(props: RatingProps): React.JSX.Element;

Tokens it reads

Every custom property Rating.jsx resolves, read from the source: 3 component, 159 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-rating-colorcomponentvar(--dt-surface-warning)
--dt-rating-count-colorcomponentvar(--dt-text-secondary)
--dt-rating-empty-colorcomponentvar(--dt-border-strong)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-size-control-mdsemanticvar(--dt-dim-10)
--dt-size-control-smsemanticvar(--dt-dim-8)
--dt-size-control-xssemanticvar(--dt-dim-6)
--dt-size-icon-lgsemanticvar(--dt-dim-6)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-size-icon-smsemanticvar(--dt-dim-4)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";

/* A star on the 24-unit icon grid, filled and stroked with round joins so its
   points are softened the way the rest of the system's icons are. */
const STAR =
  "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z";

/* The star, the box an input star sits in (a comfortable hit area), and the
   count's type role. */
const SIZES = {
  sm: { star: "var(--dt-size-icon-sm)", box: "var(--dt-size-control-xs)", text: "body-xs" },
  md: { star: "var(--dt-size-icon-md)", box: "var(--dt-size-control-sm)", text: "body-sm" },
  lg: { star: "var(--dt-size-icon-lg)", box: "var(--dt-size-control-md)", text: "body-md" },
};

function Glyph({ size }) {
  return (
    <svg
      viewBox="0 0 24 24" aria-hidden="true" focusable="false"
      fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"
      style={{ display: "block", flex: "none", width: size, height: size, maxWidth: "none" }}
    >
      <path d={STAR} />
    </svg>
  );
}

/* fill is 0, 0.5 or 1. A half star lays the filled glyph over the empty one
   and clips it to its inline-start half, so it needs no SVG ids, follows the
   colour tokens into a dark band, and fills from the right in RTL. */
function Star({ fill, size }) {
  return (
    <span style={{ position: "relative", display: "block", flex: "none", width: size, height: size }}>
      <span style={{ display: "block", color: fill === 1 ? "var(--dt-rating-color)" : "var(--dt-rating-empty-color)", transition: "color var(--dt-motion-micro)" }}>
        <Glyph size={size} />
      </span>
      {fill === 0.5 && (
        <span style={{ position: "absolute", insetBlockStart: 0, insetInlineStart: 0, width: "50%", height: "100%", overflow: "hidden", color: "var(--dt-rating-color)" }}>
          <Glyph size={size} />
        </span>
      )}
    </span>
  );
}

const fillFor = (shown, n) => (shown >= n ? 1 : shown >= n - 0.5 ? 0.5 : 0);

export function Rating({ value = 0, max = 5, count, size = "md", onChange, label, locale, style, ...rest }) {
  const s = SIZES[size] || SIZES.md;
  const [hover, setHover] = React.useState(null);
  const refs = React.useRef([]);
  const stars = Array.from({ length: max }, (_, i) => i + 1);
  const num = React.useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }), [locale]);

  if (!onChange) {
    const rounded = Math.round(Math.min(Math.max(value, 0), max) * 2) / 2;
    const reviews = typeof count === "number" ? `, ${num.format(count)} ${count === 1 ? "review" : "reviews"}` : "";
    return (
      <span
        role="img"
        aria-label={label || `${num.format(rounded)} out of ${max} stars${reviews}`}
        style={{ display: "inline-flex", alignItems: "center", gap: "var(--dt-space-inline-xs)", verticalAlign: "middle", ...style }}
        {...rest}
      >
        <span style={{ display: "inline-flex", alignItems: "center" }}>
          {stars.map((n) => <Star key={n} fill={fillFor(rounded, n)} size={s.star} />)}
        </span>
        {typeof count === "number" && (
          <span
            style={{
              fontFamily: `var(--dt-text-${s.text}-family)`, fontSize: `var(--dt-text-${s.text}-size)`,
              lineHeight: `var(--dt-text-${s.text}-line)`, color: "var(--dt-rating-count-color)",
              fontVariantNumeric: "tabular-nums",
            }}
          >
            ({num.format(count)})
          </span>
        )}
      </span>
    );
  }

  /* Input: a radio group of whole stars. The checked star is the one tab
     stop (the first when nothing is chosen yet); arrows move and select. */
  const selected = Math.min(Math.max(Math.round(value), 0), max);
  const shown = hover != null ? hover : selected;
  const tabStop = selected || 1;

  const choose = (n) => {
    const next = Math.min(Math.max(n, 1), max);
    if (next !== selected) onChange(next);
    const el = refs.current[next];
    if (el) el.focus();
  };

  const onKeyDown = (e, n) => {
    const rtl = !!(e.currentTarget.closest && e.currentTarget.closest('[dir="rtl"]'));
    const fwd = rtl ? "ArrowLeft" : "ArrowRight";
    const back = rtl ? "ArrowRight" : "ArrowLeft";
    let next = null;
    if (e.key === fwd || e.key === "ArrowUp") next = n >= max ? 1 : n + 1;
    else if (e.key === back || e.key === "ArrowDown") next = n <= 1 ? max : n - 1;
    else if (e.key === "Home") next = 1;
    else if (e.key === "End") next = max;
    else if (e.key === " " || e.key === "Enter") next = n;
    if (next == null) return;
    e.preventDefault();
    choose(next);
  };

  return (
    <div
      role="radiogroup"
      aria-label={label}
      onMouseLeave={() => setHover(null)}
      style={{ display: "inline-flex", alignItems: "center", verticalAlign: "middle", ...style }}
      {...rest}
    >
      {stars.map((n) => (
        <span
          key={n}
          ref={(el) => { refs.current[n] = el; }}
          role="radio"
          aria-checked={n === selected}
          aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
          tabIndex={n === tabStop ? 0 : -1}
          onClick={() => choose(n)}
          onKeyDown={(e) => onKeyDown(e, n)}
          onMouseEnter={() => setHover(n)}
          style={{
            display: "inline-flex", alignItems: "center", justifyContent: "center",
            width: s.box, height: s.box, borderRadius: "var(--dt-radius-control)", cursor: "pointer",
          }}
        >
          <Star fill={fillFor(shown, n)} size={s.star} />
        </span>
      ))}
    </div>
  );
}