Skip to content
UI Kit
Accent color

Rating

Stars to show a score or pick one, and a summary with a bar per star.

One of 5 components in Data display

3 variants

Variants

Display

Exact fractions with value and review count, three sizes and tones.

Input

A feedback form with a keyboard friendly star choice.

Summary

Average, distribution and reviews, rows filter the list.

Details

What Rating brings along, read from the registry and the source.

Category
Data display
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/rating.tsx
Brings along
utilskit
npm packages
None
Languages
English and German, 8 texts of its own
Studio
Adjust in code

Props

The RatingProps interface from components/ui/rating.tsx, with the comments from the code.

  • valuerequired
    number

    Current value, may be fractional for display (4.6)

  • maxoptional
    number
  • sizeoptional
    RatingSize

    smmdlg

  • toneoptional
    RatingTone

    accentgoldneutral

  • onChangeoptional
    (value: number) => void

    Makes it an input. Without it the rating only shows.

  • nameoptional
    string

    Form field name for the input mode

  • showValueoptional
    boolean

    Show the number next to the stars

  • countoptional
    number

    Number of reviews, shown after the value

  • labeloptional
    string

    Accessible name, e.g. "Your rating for Café Mira"

Built-in texts

Rating writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 8 texts
ratingEnglish RatingGerman Bewertung
outOfEnglish out ofGerman von
starsEnglish starsGerman Sternen
starEnglish starGerman Stern
reviewsEnglish reviewsGerman Bewertungen
basedOnEnglish Based onGerman Aus
wordsEnglish Poor,Fair,Good,Very good,ExcellentGerman Schlecht,Geht so,Gut,Sehr gut,Ausgezeichnet
chooseEnglish Choose a ratingGerman Bewertung wählen

Accessibility

Read from the source of the component.

  • Sets aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles img and radiogroup.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 266 lines
components/ui/rating.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';

export type RatingSize = 'sm' | 'md' | 'lg';
export type RatingTone = 'accent' | 'gold' | 'neutral';

const PX: Record<RatingSize, number> = { sm: 14, md: 18, lg: 30 };
const GAP: Record<RatingSize, string> = { sm: 'gap-0.5', md: 'gap-1', lg: 'gap-1.5' };
const TONE: Record<RatingTone, string> = {
  accent: 'var(--primary)',
  // The kit warning tone, warm amber in light and dark
  gold: 'var(--warning)',
  neutral: 'var(--foreground)',
};
const EMPTY = 'color-mix(in oklab, var(--foreground) 13%, transparent)';

/** The kit star, filled. Same outline as the "star" icon. */
const STAR = 'M12 3.4l2.62 5.3 5.86.85-4.24 4.13 1 5.83L12 16.76l-5.24 2.75 1-5.83L3.52 9.55l5.86-.85Z';

/* A star that was just chosen pops, the word for the choice rolls in */
const STYLES =
  '.kitfx-rt-pop{animation:kitfx-rt-pop .5s cubic-bezier(.34,1.56,.64,1)}@keyframes kitfx-rt-pop{40%{transform:scale(1.32) rotate(-8deg)}}' +
  '.kitfx-rt-word{display:inline-block;animation:kitfx-rt-word .35s var(--ease-kit) both}@keyframes kitfx-rt-word{from{opacity:0;transform:translateY(6px)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-rt-pop,.kitfx-rt-word{animation:none}}';

/** Entrance only for elements that were below the fold, so nothing above it blinks on load */
function useEntrance<T extends Element>() {
  const [ref, inView] = useInView<T>();
  const waited = useRef(false);
  if (!inView) waited.current = true;
  return [ref, inView, inView && waited.current] as const;
}

/** Counts from 0 to the value once `run` turns true. Starts at the value, so the server and reduced motion show it. */
function useCount(value: number, run: boolean, ms = 1100) {
  const [n, setN] = useState(value);
  const done = useRef(false);
  useEffect(() => {
    if (!run || done.current || prefersReducedMotion()) return;
    done.current = true;
    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / ms);
      setN(value * (1 - Math.pow(1 - t, 3)));
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [run, value, ms]);
  return n;
}

const TEXTS = {
  en: { rating: 'Rating', outOf: 'out of', stars: 'stars', star: 'star', reviews: 'reviews', basedOn: 'Based on', words: 'Poor,Fair,Good,Very good,Excellent', choose: 'Choose a rating' },
  de: { rating: 'Bewertung', outOf: 'von', stars: 'Sternen', star: 'Stern', reviews: 'Bewertungen', basedOn: 'Aus', words: 'Schlecht,Geht so,Gut,Sehr gut,Ausgezeichnet', choose: 'Bewertung wählen' },
};
type RatingTexts = (typeof TEXTS)['en'];

function useNumber() {
  const locale = useLocale();
  return (n: number, digits = 1) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n);
}

/**
 * One star. The fill is a clip that opens from the start, so a fraction like 0.6 shows six tenths.
 * `lit` false keeps it empty, the fill then sweeps in after `delay`.
 */
function Star({ px, fill, tone, lit = true, delay = 0, fast }: { px: number; fill: number; tone: RatingTone; lit?: boolean; delay?: number; fast?: boolean }) {
  const f = Math.min(Math.max(fill, 0), 1);
  return (
    <svg width={px} height={px} viewBox="0 0 24 24" aria-hidden className="shrink-0 overflow-visible">
      <path d={STAR} style={{ fill: EMPTY }} strokeLinejoin="round" />
      <path
        d={STAR}
        className={cn('motion-reduce:transition-none', fast ? 'transition-[clip-path] duration-150 ease-out' : 'transition-[clip-path] duration-500 ease-[var(--ease-kit)]')}
        style={{ fill: TONE[tone], clipPath: `inset(-10% ${round(100 - (lit ? f : 0) * 100)}% -10% -10%)`, transitionDelay: `${delay}ms` }}
      />
    </svg>
  );
}

export interface RatingProps {
  /** Current value, may be fractional for display (4.6) */
  value: number;
  max?: number;
  size?: RatingSize;
  tone?: RatingTone;
  /** Makes it an input. Without it the rating only shows. */
  onChange?: (value: number) => void;
  /** Form field name for the input mode */
  name?: string;
  /** Show the number next to the stars */
  showValue?: boolean;
  /** Number of reviews, shown after the value */
  count?: number;
  /** Accessible name, e.g. "Your rating for Café Mira" */
  label?: string;
  labels?: Partial<RatingTexts>;
  className?: string;
}

/** Stars that show a score, or let people pick one. */
export function Rating({ value, max = 5, size = 'md', tone = 'accent', onChange, name, showValue, count, label, labels, className }: RatingProps) {
  const t = useTexts(TEXTS, labels);
  const num = useNumber();
  const locale = useLocale();
  const [hover, setHover] = useState<number | null>(null);
  const [popped, setPopped] = useState(0);
  const groupId = useId();
  const px = PX[size];
  const [ref, inView, entering] = useEntrance<HTMLSpanElement>();

  if (onChange) {
    const words = t.words.split(',');
    const shown = hover ?? value;
    return (
      <div className={cn('inline-flex flex-wrap items-center gap-x-4 gap-y-2', className)}>
        {/* the stars sit in 44 px tap cells; pull the row out by the cell padding so the first star sits on the text edge */}
        <div role="radiogroup" aria-label={label ?? t.choose} className={cn('flex', GAP[size])} style={{ marginInlineStart: -round((Math.max(44, px + 8) - px) / 2) }} onPointerLeave={() => setHover(null)}>
          {Array.from({ length: max }, (_, i) => {
            const v = i + 1;
            const id = `${groupId}-${v}`;
            return (
              <span key={v} className="group/star relative" onPointerEnter={() => setHover(v)}>
                <input
                  id={id}
                  type="radio"
                  name={name ?? groupId}
                  value={v}
                  checked={value === v}
                  onChange={() => {
                    onChange(v);
                    setPopped((p) => p + 1);
                  }}
                  className="peer absolute inset-0 size-full cursor-pointer appearance-none rounded-md opacity-0"
                  aria-label={`${v} ${v === 1 ? t.star : t.stars}${max === 5 ? `, ${words[i]}` : ''}`}
                />
                <label
                  htmlFor={id}
                  className="grid min-h-11 min-w-11 cursor-pointer place-items-center rounded-md transition-transform duration-150 ease-[var(--ease-kit)] group-hover/star:scale-110 peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--ring)] motion-reduce:transition-none motion-reduce:group-hover/star:scale-100"
                  style={{ minWidth: Math.max(44, px + 8), minHeight: Math.max(44, px + 8) }}
                >
                  {/* The chosen star pops, the others fill in a quick run from the start */}
                  <span key={value === v ? `p${popped}` : 'n'} className={cn('grid', value === v && popped > 0 && 'kitfx-rt-pop')}>
                    <Star px={px} fill={shown >= v ? 1 : 0} tone={tone} fast delay={hover !== null ? 0 : i * 40} />
                  </span>
                </label>
              </span>
            );
          })}
        </div>
        {max === 5 && (
          <span className={cn('min-w-[7rem] text-sm font-medium', shown ? 'text-foreground' : 'text-subtle-foreground')} aria-live="polite">
            <style href="kitfx-rt" precedence="default">
              {STYLES}
            </style>
            <span key={shown} className="kitfx-rt-word">
              {shown ? words[shown - 1] : t.choose}
            </span>
          </span>
        )}
      </div>
    );
  }

  const text = `${num(value)} ${t.outOf} ${max} ${t.stars}${count !== undefined ? `, ${new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(count)} ${t.reviews}` : ''}`;
  return (
    <span ref={ref} role="img" aria-label={label ? `${label}: ${text}` : text} className={cn('inline-flex items-center gap-2 align-middle', className)}>
      {/* The stars fill one after another when they scroll into view */}
      <span className={cn('flex', GAP[size])}>
        {Array.from({ length: max }, (_, i) => (
          <Star key={i} px={px} fill={value - i} tone={tone} lit={inView} delay={entering ? 120 + i * 110 : 0} />
        ))}
      </span>
      {(showValue || count !== undefined) && (
        <span className={cn('tabular-nums', size === 'lg' ? 'text-base' : 'text-[13px]')} aria-hidden>
          {showValue && <span className="font-semibold text-foreground">{num(value)}</span>}
          {count !== undefined && <span className="text-muted-foreground"> ({new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(count)})</span>}
        </span>
      )}
    </span>
  );
}

export interface RatingSummaryProps {
  average: number;
  /** Count per star, highest first: [5★, 4★, 3★, 2★, 1★] */
  distribution: number[];
  tone?: RatingTone;
  /** Called with the star value when a row is chosen, turns rows into filter buttons */
  onSelect?: (stars: number) => void;
  /** Currently selected star filter */
  selected?: number;
  labels?: Partial<RatingTexts>;
  className?: string;
}

/** Average, stars and a bar per star value. Rows can filter reviews. */
export function RatingSummary({ average, distribution, tone = 'accent', onSelect, selected, labels, className }: RatingSummaryProps) {
  const t = useTexts(TEXTS, labels);
  const num = useNumber();
  const locale = useLocale();
  const int = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
  const total = distribution.reduce((a, b) => a + b, 0);
  const max = distribution.length;
  const [ref, inView, entering] = useEntrance<HTMLDivElement>();
  const counted = useCount(average, entering);
  return (
    <div ref={ref} className={cn('grid gap-6 sm:grid-cols-[auto_1fr] sm:items-center sm:gap-10', className)}>
      <div>
        <p className="text-[3.25rem] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">{num(entering ? counted : average)}</p>
        <Rating value={average} max={max} tone={tone} className="mt-3" labels={labels} />
        <p className="mt-2 text-[13px] text-muted-foreground">
          {t.basedOn} {int.format(total)} {t.reviews}
        </p>
      </div>
      <ul className="space-y-1">
        {distribution.map((n, i) => {
          const stars = max - i;
          const share = total ? n / total : 0;
          const row = (
            <>
              <span className="w-3 text-right text-[13px] tabular-nums text-muted-foreground">{stars}</span>
              <Star px={13} fill={1} tone={tone} />
              <span className="relative h-2 flex-1 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_8%,transparent)]">
                <span
                  data-clip-ok
                  className="absolute inset-0 rounded-full transition-transform duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none"
                  style={{ transform: `translateX(${inView ? round((share - 1) * 100) : -100}%)`, background: TONE[tone], transitionDelay: `${entering ? i * 90 : 0}ms` }}
                />
              </span>
              <span className="w-12 text-right text-[13px] tabular-nums text-muted-foreground">{int.format(n)}</span>
            </>
          );
          return (
            <li key={stars}>
              {onSelect ? (
                <button
                  type="button"
                  onClick={() => onSelect(stars)}
                  aria-pressed={selected === stars}
                  aria-label={`${stars} ${t.stars}: ${int.format(n)} ${t.reviews}`}
                  className={cn('flex min-h-11 w-full items-center gap-2.5 rounded-md px-2 transition-[background-color,translate] duration-200 hover:bg-accent active:translate-x-px sm:min-h-9', selected === stars && 'bg-accent')}
                >
                  {row}
                </button>
              ) : (
                <div className="flex min-h-9 items-center gap-2.5 px-2">
                  <span className="sr-only">
                    {stars} {t.stars}: {int.format(n)} {t.reviews}
                  </span>
                  <span aria-hidden className="contents">
                    {row}
                  </span>
                </div>
              )}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Frequent questions

How do I install Rating?

With aiw add @ai-website/rating. The command puts components/ui/rating.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Rating have?

3 variants, Display, Input and Summary.

Can I translate the texts of Rating?

Yes. Rating carries 8 texts of its own in English and German, such as “Rating”, “out of” and “stars”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Rating?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Rating with aiw_components and install it with aiw_add @ai-website/rating.

Often searched

Jump to

Copied to the clipboard