Bewertung
Sterne, die eine Wertung zeigen oder abfragen, und eine Übersicht mit Balken pro Stern.
Eine von 5 Components in Data Display
Variants
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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle RatingProps aus components/ui/rating.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
valuePflichtnumberCurrent value, may be fractional for display (4.6)
maxoptionalnumbersizeoptionalRatingSizesmmdlg
toneoptionalRatingToneaccentgoldneutral
onChangeoptional(value: number) => voidMakes it an input. Without it the rating only shows.
nameoptionalstringForm field name for the input mode
showValueoptionalbooleanShow the number next to the stars
countoptionalnumberNumber of reviews, shown after the value
labeloptionalstringAccessible 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| rating | Englisch Rating | Deutsch Bewertung |
| outOf | Englisch out of | Deutsch von |
| stars | Englisch stars | Deutsch Sternen |
| star | Englisch star | Deutsch Stern |
| reviews | Englisch reviews | Deutsch Bewertungen |
| basedOn | Englisch Based on | Deutsch Aus |
| words | Englisch Poor,Fair,Good,Very good,Excellent | Deutsch Schlecht,Geht so,Gut,Sehr gut,Ausgezeichnet |
| choose | Englisch Choose a rating | Deutsch 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
'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.