Rating display
A supplied rating as stars or a bar, the score with its scale in words, and an optional label, review count link and source. Floors partial fills, clamps to the scale, and shows text when there are no reviews.
cmp_rating_display_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_rating_display_01 version 1.0.0 with variant "amber", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Amber stars
- Version
- 1.0.0
- Digest
Full digest
sha256-435408f9b3f7820f563a40523e8f1cde2a80754744a6dadf2b7f094d68bfc760
<script lang="ts" module>
export type RatingDisplaySize = 'sm' | 'md' | 'lg';
export type RatingDisplayVisual = 'stars' | 'bar';
export type RatingDisplayPrecision = 0.5 | 0.1;
/**
* Every string the component writes. {value}, {max} and {count} are replaced with numbers
* formatted for the locale; {count} in rated* is the whole reviews phrase ("128 reviews").
*/
export interface RatingDisplayLabels {
/** Visible scale after the score. */
scale: string;
/** Screen-reader sentence without a count. */
rated: string;
/** Screen-reader sentence with a count. */
ratedWithCount: string;
/** Reviews phrase per plural category (Intl.PluralRules); `other` is the fallback. */
reviews: Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };
/** Shown in place of the rating when count is 0. */
noReviews: string;
/** Mark between the label, count and source. Hidden where a line wraps. */
separator: string;
}
export const DEFAULT_LABELS: RatingDisplayLabels = {
scale: 'out of {max}',
rated: 'Rated {value} out of {max}',
ratedWithCount: 'Rated {value} out of {max} from {count}',
reviews: { one: '{count} review', other: '{count} reviews' },
noReviews: 'No reviews yet',
separator: '·'
};
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** The supplied average, e.g. 4.6. Clamped to 0..max; never rounded up. */
value: number;
/** Top of the scale. 5 by default; 10 for ten-point scores. */
max?: number;
/** BCP 47 locale for the score, scale and count, e.g. 'en-GB'. */
locale: string;
/** Number of reviews behind the rating. 0 shows the no-reviews text instead of a rating. */
count?: number;
/** Link to the reviews. Wraps the count; ignored without a count. */
href?: string;
/** Descriptive word for the score, e.g. 'Excellent'. */
label?: string;
/** Where the ratings come from, e.g. 'Verified buyers'. */
source?: string;
/** Stars or a bar as the gauge. */
visual?: RatingDisplayVisual;
/** Steps the gauge fills in. Always rounds down, so 4.9 at 0.5 fills four and a half. */
precision?: RatingDisplayPrecision;
/** sm and md sit in a line of text; lg sets the score as a large numeral above the gauge. */
size?: RatingDisplaySize;
/** Replace any of the strings, for another language or wording. */
labels?: Partial<RatingDisplayLabels>;
/** Extra classes on the root, for margins or alignment. */
class?: string;
}
let {
value,
max = 5,
locale,
count,
href,
label,
source,
visual = 'stars',
precision = 0.5,
size = 'sm',
labels,
class: className
}: Props = $props();
/*
* One entry per size, as complete class strings. sm and md are one wrapping row led by the
* gauge, for cards and lists. lg breaks the score and the gauge onto their own lines, so the
* numeral anchors a review summary and its scale shares the numeral's baseline.
*/
const SIZES: Record<
RatingDisplaySize,
{
root: string;
run: string;
head: string;
figure: string;
score: string;
gauge: string;
star: string;
bar: string;
empty: string;
}
> = {
sm: {
root: 'inline-flex text-sm/5',
run: 'gap-y-1',
head: 'inline-flex items-center gap-x-2',
figure: '',
score: '',
gauge: 'order-first',
star: 'size-4',
bar: 'h-1.5 w-16',
empty: ''
},
md: {
root: 'inline-flex text-base/6',
run: 'gap-y-1',
head: 'inline-flex items-center gap-x-2',
figure: '',
score: '',
gauge: 'order-first',
star: 'size-4.5',
bar: 'h-2 w-20',
empty: ''
},
lg: {
root: 'flex text-sm/5',
run: 'gap-y-3',
head: 'flex basis-full flex-col items-start gap-y-3',
figure: '',
score: 'text-5xl/none tracking-[-0.022em] rtl:tracking-normal',
gauge: '',
star: 'size-5',
bar: 'h-2 w-40',
empty: 'text-base/6'
}
};
/** Locale used when the one passed is not supported, so the host's default never leaks in. */
const FALLBACK_LOCALE = 'en';
/** Guards the floors against 4.6 / 0.1 landing on 45.99999. */
const EPSILON = 1e-9;
const text = $derived({ ...DEFAULT_LABELS, ...labels });
const step = $derived(SIZES[size] ?? SIZES.sm);
const resolvedLocale = $derived.by(() => {
try {
return Intl.NumberFormat.supportedLocalesOf(locale).length ? locale : FALLBACK_LOCALE;
} catch {
return FALLBACK_LOCALE;
}
});
const top = $derived(Number.isFinite(max) && max > 0 ? max : 5);
/** The value inside 0..max. Anything outside is a data error, reported in development. */
const clamped = $derived.by(() => {
const safe = Number.isFinite(value) ? value : 0;
const result = Math.min(Math.max(safe, 0), top);
const dev = (import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV;
if (result !== value && dev) {
console.warn(`RatingDisplay: value ${value} is outside 0..${top}; showing ${result}.`);
}
return result;
});
/** One decimal, cut rather than rounded, so 4.96 reads 4.9 and never 5.0. */
const shown = $derived(Math.floor(clamped * 10 + EPSILON) / 10);
/*
* ICU versions disagree about which space groups digits (U+0020, U+00A0, U+2009, U+202F), so
* a server and a browser could print different bytes for "1 284". Every space is pinned to a
* narrow no-break space.
*/
const SPACES = /[ \u00a0\u2009\u202f]/g;
const formatNumber = (n: number, minDigits: number, maxDigits = minDigits) => {
try {
return new Intl.NumberFormat(resolvedLocale, {
minimumFractionDigits: minDigits,
maximumFractionDigits: maxDigits
})
.format(n)
.replace(SPACES, '\u202f');
} catch {
return n.toFixed(maxDigits);
}
};
const fill = (template: string, values: Record<string, string>) =>
template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match);
const hasCount = $derived(typeof count === 'number' && Number.isFinite(count) && count >= 0);
const noReviews = $derived(hasCount && count === 0);
const reviewsPhrase = $derived.by(() => {
if (!hasCount) return '';
const whole = Math.floor(count as number);
let rule: Intl.LDMLPluralRule = 'other';
try {
// The built-in English forms follow English plural rules, whatever the number locale.
const language = labels?.reviews ? resolvedLocale : 'en';
rule = new Intl.PluralRules(language).select(whole);
} catch {
// Unknown locale: the 'other' form is always present.
}
return fill(text.reviews[rule] ?? text.reviews.other, { count: formatNumber(whole, 0) });
});
const numbers = $derived({
value: formatNumber(shown, 1),
max: formatNumber(top, 0, 2)
});
/** The rating as one sentence: "Rated 4.6 out of 5 from 128 reviews". */
const sentence = $derived(
hasCount
? fill(text.ratedWithCount, { ...numbers, count: reviewsPhrase })
: fill(text.rated, numbers)
);
/*
* The gauge: whole stars up to ten, otherwise five stars standing for the whole scale. The
* filled amount is floored to the precision step, never rounded up, so a 4.9 never shows
* five full stars beside reviews that say otherwise.
*/
const starCount = $derived(Number.isInteger(top) && top <= 10 ? top : 5);
const stepSize = $derived(precision === 0.1 ? 0.1 : 0.5);
const units = $derived(Math.floor(((clamped / top) * starCount) / stepSize + EPSILON) * stepSize);
const stars = $derived(
Array.from({ length: starCount }, (_, i) => Math.min(Math.max(units - i, 0), 1))
);
const percent = (share: number) => `${Math.round(share * 10000) / 100}%`;
const linked = $derived(Boolean(href) && hasCount);
</script>
{#snippet star(className: string)}
<svg
class={className}
viewBox="0 0 20 20"
fill="currentColor"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M10 2.4 12.09 7.73 17.8 8.07 13.38 11.7 14.82 17.23 10 14.15 5.18 17.23 6.62 11.7 2.2 8.07 7.91 7.73Z"
/>
</svg>
{/snippet}
<!--
Each part after the score carries its separator in the gap before it. The run is pulled back
by one gap and the root clips sideways, so a part that wraps to the start of a line loses its
separator: no line ever starts or ends on a dot.
-->
{#snippet separated(className: string, content: Snippet)}
<span class={['rating-display__part relative ms-4 min-w-0', className]}
><span class="absolute end-full w-4 text-center text-(--_muted)" aria-hidden="true"
>{text.separator}</span
>{@render content()}</span
>
{/snippet}
{#snippet labelText()}{label}{/snippet}
{#snippet sourceText()}{source}{/snippet}
{#snippet countText()}
{#if linked}
<a
class="rating-display__link relative rounded-sm font-medium whitespace-nowrap text-(--_ink) tabular-nums decoration-1 underline-offset-4 outline-offset-1 hover:underline focus-visible:outline-2 focus-visible:outline-(--_accent)"
{href}>{reviewsPhrase}</a
>
{:else}
<span class="whitespace-nowrap text-(--_muted) tabular-nums" aria-hidden="true"
>{reviewsPhrase}</span
>
{/if}
{/snippet}
<span
class={['rating-display max-w-full text-(--_ink)', step.root, className]}
data-size={size}
data-visual={visual}
>
{#if noReviews}
<span
class={['rating-display__empty min-w-0 text-pretty break-words text-(--_muted)', step.empty]}
>{text.noReviews}</span
>
{:else}
<span class="sr-only">{sentence}</span>
<span class={['rating-display__run -ms-4 flex min-w-0 flex-wrap items-center', step.run]}>
<span class={['rating-display__head ms-4', step.head]}>
<span
class={['rating-display__figure inline-flex items-baseline gap-x-2', step.figure]}
aria-hidden="true"
>
<span
class={[
'rating-display__score font-semibold whitespace-nowrap tabular-nums [unicode-bidi:isolate]',
step.score
]}
lang={resolvedLocale}>{numbers.value}</span
>
<span class="rating-display__scale whitespace-nowrap text-(--_muted)"
>{fill(text.scale, numbers)}</span
>
</span>
<span class={['rating-display__gauge inline-flex', step.gauge]} aria-hidden="true">
{#if visual === 'bar'}
<span
class={['relative block overflow-hidden rounded-full bg-(--_track)', step.bar]}
data-fill={percent(units / starCount)}
>
<span
class="absolute inset-y-0 start-0 rounded-full bg-(--_fill)"
style="width: {percent(units / starCount)}"
></span>
</span>
{:else}
<span class="inline-flex gap-1">
{#each stars as share, index (index)}
<span class={['relative shrink-0', step.star]} data-fill={percent(share)}>
{@render star('absolute inset-0 size-full text-(--_track)')}
{#if share > 0}
<span
class="absolute inset-y-0 start-0 overflow-hidden"
style="width: {percent(share)}"
>
{@render star(
['block h-full max-w-none text-(--_fill)', step.star].join(' ')
)}
</span>
{/if}
</span>
{/each}
</span>
{/if}
</span>
</span>
{#if label}
{@render separated('rating-display__label font-medium text-pretty break-words', labelText)}
{/if}
{#if hasCount}
{@render separated('rating-display__count', countText)}
{/if}
{#if source}
{@render separated(
'rating-display__source text-pretty break-words text-(--_muted)',
sourceText
)}
{/if}
</span>
{/if}
</span>
<style>
/*
* Public tokens: set them on the component or any ancestor. Ink and muted follow the
* surrounding text colour, and the empty stars are a 22% mix of it, so the rating sits in
* a white card, on a cream page or on a dark band unchanged. Fill is the stars' colour.
*/
.rating-display {
/*
* Sideways only: the run's first gap and any separator that wraps to a line start fall
* outside and are cut, while the focus ring (3 px) and the link's tall hit area survive.
*/
overflow-x: clip;
overflow-clip-margin: 3px;
--_fill: var(--rating-display-fill, #f59e0b);
--_track: var(--rating-display-track, color-mix(in srgb, currentColor 22%, transparent));
--_accent: var(--rating-display-accent, currentColor);
--_ink: var(--rating-display-ink, currentColor);
--_muted: var(--rating-display-muted, color-mix(in srgb, currentColor 72%, transparent));
}
/*
* The review link's hit area: a 44 px box centred on the link, for a touch, without moving the
* text. A box of its own, so it lives here rather than in utilities.
*/
.rating-display__link::after {
content: '';
position: absolute;
inset-inline: -0.25rem;
top: 50%;
height: 2.75rem;
translate: 0 -50%;
}
/* CJK labels break at phrases rather than mid-word where the browser supports it. */
.rating-display:lang(ko) :is(.rating-display__label, .rating-display__source) {
word-break: keep-all;
}
@supports (word-break: auto-phrase) {
.rating-display:lang(ja) :is(.rating-display__label, .rating-display__source) {
word-break: auto-phrase;
}
}
</style>
Usage#
On this pagePass the average your review source gives you, the locale and, ideally, the count and a link to the reviews. It renders static text and SVG only. It does not collect ratings, compute averages, fetch reviews, output schema.org markup or round a score up; the numbers must come from real reviews.
- Suggested location
src/lib/components/rating-display-01
Limitations
- The score is cut to one decimal (4.96 shows 4.9), and the gauge floors to its precision. Pass a value already rounded the way your review source states it if you need to match it exactly.
- Up to ten whole stars are drawn for an integer max of 10 or less; any other max (100, 7.5) is shown on five stars in proportion. A bar suits ten-point and percentage scales better.
- Plural forms come from the
labels.reviewsmap keyed by Intl.PluralRules categories. For languages with more forms than one and other (Arabic, Polish, Russian), supply each form. - Light pages, cream pages and dark bands work through
currentColor; the amber fill is fixed until you set--rating-display-fill. Dark mode is not declared as a separate design. - No
schema.orgAggregateRating output. Add it in your page if search results should show the rating, and only for reviews you actually collected.
Example
<script lang="ts">
import RatingDisplay from '$lib/components/rating-display-01/RatingDisplay.svelte';
</script>
<RatingDisplay value={4.6} locale="en-US" count={128} href="#reviews" />
<RatingDisplay
value={4.6}
locale="en-US"
count={128}
href="#reviews"
label="Excellent"
source="Verified buyers"
size="lg"
/>
<RatingDisplay value={8.4} max={10} locale="en-GB" visual="bar" precision={0.1} label="Very good" />The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Rating display#
A rating display shows a score someone else collected. Pass the average and the count exactly as your review source gives them; the component formats them, but it never computes, rounds up or invents a rating.
Picking a size#
- sm (default): one 14 px row for product cards, list rows and search results.
- md: the same row at 16 px, for a card header or a product page's title area.
- lg: the score as a 48 px numeral with its scale beside it, the gauge and count beneath. Use it once, at the top of a reviews section.
Stars or a bar#
Stars are what shoppers expect on a 5-point scale. Ten-point scores (hotels, venues) read better as a bar, since ten stars are hard to count and star rows on a hotel page get confused with its official classification.
Precision#
precision={0.5} (default) fills half stars; 0.1 fills tenths. Both round down: 4.9 at 0.5 shows
four and a half stars, and the score beside it still says 4.9. Five full stars appear only for a
perfect score.
No reviews#
count={0} shows "No reviews yet" instead of the stars and score, so a new item never looks rated
zero. Leave count out entirely when you have a score but no count to show.
Another language#
<RatingDisplay
value={4.6}
locale="de-DE"
count={2318}
labels={{
scale: 'von {max}',
rated: 'Bewertet mit {value} von {max}',
ratedWithCount: 'Bewertet mit {value} von {max} aus {count}',
reviews: { one: '{count} Bewertung', other: '{count} Bewertungen' },
noReviews: 'Noch keine Bewertungen'
}}
/>reviews is keyed by Intl.PluralRules category; give every form your language uses.
Rich results#
The component outputs no structured data. If you want search engines to show the rating, add
schema.org AggregateRating JSON-LD to the page from the same numbers, for reviews you collected
yourself.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
value | number | Yes | None | The supplied average, e.g. 4.6. Clamped to 0..max with a development warning; never rounded up. |
max | number | No | 5 | Top of the scale. 5 by default; 10 for ten-point scores. |
locale | string | Yes | None | BCP 47 locale for the score, scale and count, e.g. 'en-GB'. Explicit, so the server and the browser format with the same rules; digit-group spaces are pinned to one character. |
count | number | No | None | Number of reviews behind the rating. 0 shows the no-reviews text instead of stars and score; omitted, no count shows. |
href | string | No | None | Link to the reviews. The count becomes the link; ignored when there is no count or the count is 0. |
label | string | No | None | Descriptive word for the score, e.g. 'Excellent'. Supply it from your own score bands. |
source | string | No | None | Where the ratings come from, e.g. 'Verified buyers'. |
visual | 'stars' | 'bar' | No | 'stars' | Stars or a bar as the gauge. |
precision | 0.5 | 0.1 | No | 0.5 | Steps the gauge fills in. Always rounds down, so 4.9 at 0.5 fills four and a half stars. |
size | 'sm' | 'md' | 'lg' | No | 'sm' | sm (14 px) and md (16 px) are one row for cards and lists; lg sets the score as a 48 px numeral above the gauge, for review summaries. |
labels | Partial<RatingDisplayLabels> | No | English: 'out of {max}', 'Rated {value} out of {max}', 'Rated {value} out of {max} from {count}', '{count} review(s)', 'No reviews yet', separator '·' | Every string the component writes, with {value}, {max} and {count} placeholders, and the separator between label, count and source. Pass only the keys you want to change. |
class | string | No | None | Extra classes on the root, for margins or alignment. |
Customization#
On this pagePick visual, precision and size through props, and the wording through labels. The rating follows the surrounding text colour; --rating-display-fill colours the stars and bar, and --rating-display-track, -ink, -muted and -accent retone the rest. Edit the SIZES map to change the scale.
- Star colour: set
--rating-display-fillon the component or any ancestor. The default amber (#f59e0b) is the convention shoppers recognise; the neutral palette uses near-black for a monochrome site. - Empty stars:
--rating-display-trackdefaults to an 18% mix of the surrounding text colour, so it stays visible on white, cream and dark pages. Raise the mix if your page is busier. - Dark or tinted page: set the container's text colour (
text-zinc-50onbg-zinc-950, or a warm brown on cream) and the score, notes and empty stars follow. Set--rating-display-mutedonly if the notes need a different tone, and check it clears 4.5:1. - Focus ring: the review link's ring is
--rating-display-accent, the text colour by default. Set it to your brand colour if that clears 3:1 against the page. - Wording and language: pass labels with the strings in your language. reviews is keyed by plural category (zero, one, two, few, many, other), so supply the forms your language needs.
- Score bands: label is plain text you choose. Map your own bands, such as 9+ Superb and 8+ Very good, before passing it; the component never invents one.
- Sizes: the SIZES map at the top of the script holds each size's classes as complete Tailwind strings. lg's numeral is
text-5xl; change it there and keep the tracking tightening as it grows. - Search results: add
schema.orgAggregateRating JSON-LD in your page from the same numbers if you want rich results. It is not output by default.
Public CSS variables
| Variable | Token |
|---|---|
--rating-display-fill | fill |
--rating-display-track | track |
--rating-display-accent | accent |
--rating-display-ink | ink |
--rating-display-muted | muted |
Accessibility#
On this page- Stars, bar, score and scale are
aria-hidden. One visually hidden sentence stands in forthem: 'Rated 4.6 out of 5 from 128 reviews', in the labels you pass. - Read-only text, not a slider or radio group: nothing is focusable except the optional review link.
- The review link's name is the count phrase ('128 reviews'), so it makes sense out of context in a links list. Its hit area is extended to 44 px tall without moving the text.
- The label and source are read as plain text after the sentence. Write the label as a word a listener understands without the stars.
- No reviews renders the no-reviews text alone, so an unrated item is never announced as rated zero.
- The score clears 4.5:1 wherever the surrounding text does; notes are a 72% mix of it, which clears 4.5:1 on a strong text colour (about 7:1 or better). Check retoned pages.
Known limitations
- The amber fill is decorative: the score in text carries the rating, so the fill is not held to 3:1 against the page.
- Contrast is checked for the default colours on white, cream and
zinc-950pages; other retones are the consumer's to check.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.