Zum Inhalt springen
UI Kit
Accent

Bewertung

Sterne, die eine Wertung zeigen oder abfragen, und eine Übersicht mit Balken pro Stern.

Eine von 5 Components in Data Display

3 Variants

Variants

Anzeige

Genaue Bruchteile mit Wert und Anzahl, drei Größen und Töne.

Eingabe

Ein Rückmeldeformular mit tastaturfreundlicher Sternwahl.

Übersicht

Durchschnitt, Verteilung und Stimmen, Zeilen filtern die Liste.

Details

Was Bewertung mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Data Display
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/rating.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 8 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle RatingProps aus components/ui/rating.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • valuePflicht
    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"

Eingebaute Texte

Diese Texte schreibt Bewertung selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 8 Texte anzeigen
ratingEnglisch RatingDeutsch Bewertung
outOfEnglisch out ofDeutsch von
starsEnglisch starsDeutsch Sternen
starEnglisch starDeutsch Stern
reviewsEnglisch reviewsDeutsch Bewertungen
basedOnEnglisch Based onDeutsch Aus
wordsEnglisch Poor,Fair,Good,Very good,ExcellentDeutsch Schlecht,Geht so,Gut,Sehr gut,Ausgezeichnet
chooseEnglisch Choose a ratingDeutsch Bewertung wählen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img und radiogroup.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 266 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Bewertung?

Mit aiw add @ai-website/rating. Der Befehl legt die Datei components/ui/rating.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Bewertung?

3 Varianten, Anzeige, Eingabe und Übersicht.

Kann ich die Texte von Bewertung übersetzen?

Ja. Bewertung bringt 8 eigene Texte auf Deutsch und Englisch mit, etwa „Bewertung“, „von“ und „Sternen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Bewertung einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bewertung mit aiw_components und installieren sie mit aiw_add @ai-website/rating.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert