Skip to content
Download ZIP

Neutral monochrome palette · 8.1 KB ZIP File receipt View as Markdown View code

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.
cmp_rating_display_01 · version 1.0.0 · Neutral monochrome palette
Using the PageSugar MCP server, fetch component cmp_rating_display_01 version 1.0.0 with variant "neutral", 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
Neutral monochrome
Version
1.0.0
Digest
Full digest
sha256-87cb8f6de6131b2eb6e8284e0090e0445395ee97712f7a64a8a19d2866c2b59d
RatingDisplay.svelte Svelte · 12.6 KB Raw
<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, #27272a);
		--_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>

Pass 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
Required props
valuelocale

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.reviews map 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.org AggregateRating output. Add it in your page if search results should show the rating, and only for reviews you actually collected.

Example

Svelte
<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#

Svelte
<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
NameTypeRequiredDefaultDescription
valuenumberYesNoneThe supplied average, e.g. 4.6. Clamped to 0..max with a development warning; never rounded up.
maxnumberNo5Top of the scale. 5 by default; 10 for ten-point scores.
localestringYesNoneBCP 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.
countnumberNoNoneNumber of reviews behind the rating. 0 shows the no-reviews text instead of stars and score; omitted, no count shows.
hrefstringNoNoneLink to the reviews. The count becomes the link; ignored when there is no count or the count is 0.
labelstringNoNoneDescriptive word for the score, e.g. 'Excellent'. Supply it from your own score bands.
sourcestringNoNoneWhere the ratings come from, e.g. 'Verified buyers'.
visual'stars' | 'bar'No'stars'Stars or a bar as the gauge.
precision0.5 | 0.1No0.5Steps 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.
labelsPartial<RatingDisplayLabels>NoEnglish: '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.
classstringNoNoneExtra classes on the root, for margins or alignment.

Customization#

On this page

Pick 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-fill on 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-track defaults 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-50 on bg-zinc-950, or a warm brown on cream) and the score, notes and empty stars follow. Set --rating-display-muted only 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.org AggregateRating JSON-LD in your page from the same numbers if you want rich results. It is not output by default.

Public CSS variables

VariableToken
--rating-display-fillfill
--rating-display-tracktrack
--rating-display-accentaccent
--rating-display-inkink
--rating-display-mutedmuted

Accessibility#

On this page
  • Stars, bar, score and scale are aria-hidden. One visually hidden sentence stands in for them: '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-950 pages; 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.