Skip to content
Download ZIP

Neutral palette · 7.3 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_testimonial_grid_01 · version 1.0.0 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_testimonial_grid_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
Version
1.0.0
Digest
Full digest
sha256-8d22fbdb3cad2087a7dd6cf6acdbabc5c7e1b0b7ee68d33e96b1dc6ff78a6540
TestimonialGrid.svelte Svelte · 11.1 KB Raw
<script module lang="ts">
	export interface TestimonialAuthor {
		/** The person quoted, as they agreed to be named. */
		name: string;
		/** Job title or relationship, e.g. "Head of Delivery" or "Patient since 2019". */
		role?: string;
		/** Organisation or place, e.g. "Northfield Labs" or "Brunswick". */
		company?: string;
		/** A portrait you have permission to use. Without one, the author's initials stand in. */
		avatar?: { src: string };
	}

	export interface Testimonial {
		/** Unique within the list; used as the list key, so two quotes may not share one. */
		id: string;
		/** The quotation, without quotation marks. A blank line starts a new paragraph. */
		quote: string;
		author: TestimonialAuthor;
		/** Where and when the quote was given, e.g. "Customer interview, May 2026". */
		source?: string;
		/** Set the quote large across two columns. Only the first featured quote is featured. */
		featured?: boolean;
	}
</script>

<script lang="ts">
	import type { Snippet } from 'svelte';

	interface Props {
		/** Quotes you have permission to publish, in reading order. An empty list renders nothing. */
		testimonials: Testimonial[];
		/** Short label above the title. */
		eyebrow?: string;
		/** Section heading; the section is labelled by it. */
		title?: string;
		/** Accessible name for the section when there is no visible title, e.g. "Customer quotes". */
		label?: string;
		/** One or two sentences under the title, held to a readable measure. */
		description?: string;
		/** `rows` keeps reading order across each row; `masonry` packs uneven quotes into columns. */
		layout?: 'rows' | 'masonry';
		/** Columns on wide screens. Every layout is a single column on phones. */
		columns?: 2 | 3;
		/** Level of the section heading. */
		headingLevel?: 2 | 3;
		/** A link or action under the grid, such as a link to every review. */
		footer?: Snippet;
	}

	let {
		testimonials,
		eyebrow,
		title,
		label,
		description,
		layout = 'rows',
		columns = 3,
		headingLevel = 2,
		footer
	}: Props = $props();

	const uid = $props.id();
	const heading = $derived(`h${headingLevel}`);

	/*
	 * The first featured quote leads, so it can span two columns without leaving a hole in the row
	 * above it; every other quote keeps its given order. Later featured flags are ignored.
	 */
	const featuredIndex = $derived(testimonials.findIndex((item) => item.featured));
	const ordered = $derived(
		featuredIndex > 0
			? [
					testimonials[featuredIndex],
					...testimonials.slice(0, featuredIndex),
					...testimonials.slice(featuredIndex + 1)
				]
			: testimonials
	);
	const hasFeatured = $derived(featuredIndex >= 0 && testimonials.length > 1);

	/*
	 * Rows: a featured quote takes two cells. Three columns fall back to two when the cells would
	 * leave a third column empty (two cells) or one quote alone on the last row where two columns
	 * would come out even (4, 10, 16 … cells).
	 */
	const cells = $derived(testimonials.length + (hasFeatured ? 1 : 0));
	const wide = $derived(
		testimonials.length === 1
			? 1
			: columns === 3 && cells !== 2 && !(cells % 3 === 1 && cells % 2 === 0)
				? 3
				: 2
	);

	/*
	 * Masonry: the featured quote spans every column, so the columns are sized by the quotes
	 * below it. One quote left keeps a readable measure instead of a stranded narrow column.
	 */
	const flowing = $derived(testimonials.length - (hasFeatured ? 1 : 0));

	/*
	 * One quote beside a heading reads as a rail: the heading block takes two fifths of the frame
	 * and the quote the rest at a short measure, instead of one narrow column under the title.
	 */
	const hasHeader = $derived(Boolean(eyebrow || title || description));
	const rail = $derived(testimonials.length === 1 && hasHeader);

	const listClass = $derived(
		rail
			? 'grid lg:col-span-3 lg:mt-0'
			: layout === 'masonry'
				? flowing <= 1
					? 'max-w-3xl'
					: columns === 3 && flowing >= 3
						? 'gap-x-8 sm:columns-2 lg:columns-3'
						: 'gap-x-8 sm:columns-2'
				: wide === 1
					? 'grid max-w-2xl'
					: wide === 3
						? 'grid gap-x-8 gap-y-8 sm:grid-cols-2 lg:grid-cols-3'
						: 'grid gap-x-8 gap-y-8 sm:grid-cols-2'
	);

	/*
	 * Two initials for alphabetic names. Scripts without case, where two letters would join into
	 * a word (Arabic, Hebrew) or where one character already names the family (CJK), take one.
	 */
	const singleInitial =
		/[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;

	function initials(name: string): string {
		const words = name.trim().split(/\s+/).filter(Boolean);
		if (words.length === 0) return '';
		const first = Array.from(words[0])[0] ?? '';
		if (singleInitial.test(first) || words.length === 1) return first;
		return first + (Array.from(words[words.length - 1])[0] ?? '');
	}

	function paragraphs(quote: string): string[] {
		return quote
			.split(/\n\s*\n/)
			.map((part) => part.trim())
			.filter(Boolean);
	}
</script>

{#if testimonials.length > 0}
	<section
		class="testimonial-grid px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
		aria-labelledby={title ? `${uid}-title` : undefined}
		aria-label={title ? undefined : label}
	>
		<div class={['mx-auto max-w-6xl', rail && 'lg:grid lg:grid-cols-5 lg:gap-x-8']}>
			{#if hasHeader}
				<div class={['max-w-2xl text-start', rail && 'lg:col-span-2']}>
					{#if eyebrow}
						<p
							class="testimonial-grid__tracked testimonial-grid__eyebrow text-xs leading-none font-medium tracking-[0.06em] text-balance break-words text-[var(--_muted)] uppercase"
						>
							{eyebrow}
						</p>
					{/if}
					{#if title}
						<svelte:element
							this={heading}
							id="{uid}-title"
							class={[
								'testimonial-grid__tracked text-3xl leading-[1.15] font-semibold tracking-tight text-balance break-words text-[var(--_ink)] sm:text-4xl',
								eyebrow && 'mt-2'
							]}
						>
							{title}
						</svelte:element>
					{/if}
					{#if description}
						<p
							class={[
								'max-w-xl text-base leading-6 text-pretty break-words text-[var(--_muted)] sm:text-lg sm:leading-7',
								(eyebrow || title) && 'mt-4'
							]}
						>
							{description}
						</p>
					{/if}
				</div>
			{/if}

			<ul class={['testimonial-grid__list', hasHeader && 'mt-12 sm:mt-16', listClass]} role="list">
				{#each ordered as item, index (item.id)}
					{@const featured = hasFeatured && index === 0}
					<li
						class={[
							'min-w-0 border-t border-[var(--_hairline)] pt-6',
							layout === 'rows'
								? 'row-span-2 grid grid-rows-subgrid gap-y-6'
								: 'mb-8 break-inside-avoid last:mb-0',
							featured && layout === 'rows' && 'sm:col-span-2',
							featured && layout === 'masonry' && 'testimonial-grid__span'
						]}
					>
						<figure
							class={['grid min-w-0 gap-y-6', layout === 'rows' && 'row-span-2 grid-rows-subgrid']}
						>
							<blockquote
								class={[
									'testimonial-grid__quote testimonial-grid__tracked min-w-0 text-pretty break-words text-[var(--_ink)]',
									featured
										? 'max-w-3xl space-y-4 text-2xl leading-[1.3] tracking-[-0.02em] sm:text-3xl sm:leading-[1.25]'
										: 'space-y-4 text-lg leading-7 tracking-[-0.01em]',
									rail && 'max-w-lg'
								]}
							>
								{#each paragraphs(item.quote) as paragraph, p (p)}
									<p>{paragraph}</p>
								{/each}
							</blockquote>
							<!-- Captions start at the top of their shared row, so names sit on one line whatever follows them. -->
							<figcaption class="flex min-w-0 items-start gap-4 self-start">
								{#if item.author.avatar}
									<img
										class="testimonial-grid__avatar size-10 shrink-0 rounded-full object-cover"
										src={item.author.avatar.src}
										alt=""
										width="40"
										height="40"
										loading="lazy"
										decoding="async"
									/>
								{:else}
									<span
										class="testimonial-grid__monogram flex size-10 shrink-0 items-center justify-center rounded-full text-sm leading-none font-medium text-[var(--_ink)]"
										aria-hidden="true">{initials(item.author.name)}</span
									>
								{/if}
								<span class="min-w-0 text-sm leading-5 break-words">
									<span class="block font-semibold text-[var(--_ink)]">{item.author.name}</span>
									{#if item.author.role || item.author.company}
										<span class="block text-[var(--_muted)]">
											{#if item.author.role}{item.author.role}{/if}
											{#if item.author.role && item.author.company}<span aria-hidden="true">·</span
												>{/if}
											{#if item.author.company}{item.author.company}{/if}
										</span>
									{/if}
									{#if item.source}
										<span class="block text-[var(--_muted)]">{item.source}</span>
									{/if}
								</span>
							</figcaption>
						</figure>
					</li>
				{/each}
			</ul>

			{#if footer}
				<div class={['mt-12 sm:mt-16', rail && 'lg:col-span-3 lg:col-start-3']}>
					{@render footer()}
				</div>
			{/if}
		</div>
	</section>
{/if}

<style>
	/* Public tokens: set --testimonial-grid-* on this section or any ancestor to retone it. */
	.testimonial-grid {
		--_accent: var(--testimonial-grid-accent, #18181b);
		--_on-accent: var(--testimonial-grid-on-accent, #ffffff);
		--_ink: var(--testimonial-grid-ink, #18181b);
		--_muted: var(--testimonial-grid-muted, #52525b);
		--_hairline: var(--testimonial-grid-hairline, rgb(0 0 0 / 0.1));
	}

	/*
	 * Quotation marks come from the language (quotes: auto), in the accent. The opening mark hangs
	 * outside the text edge, so the words themselves start on the section's left edge.
	 */
	.testimonial-grid__quote > p:first-child {
		position: relative;
	}
	.testimonial-grid__quote > p:first-child::before {
		content: open-quote;
		position: absolute;
		inset-inline-end: 100%;
		color: var(--_accent);
	}
	.testimonial-grid__quote > p:last-child::after {
		content: close-quote;
		color: var(--_accent);
	}
	/* Full-width CJK brackets are too wide to hang; they sit in the line instead. */
	.testimonial-grid__quote:is(:lang(ja), :lang(zh), :lang(ko)) > p:first-child::before {
		position: static;
	}

	/* In masonry the featured quote runs across every column above the others. */
	.testimonial-grid__span {
		column-span: all;
	}

	/* The initials disc is a faint step of the ink, so it follows any retone of the text. */
	.testimonial-grid__monogram {
		background-color: color-mix(in oklab, var(--_ink) 6%, transparent);
	}
	.testimonial-grid__avatar {
		outline: 1px solid var(--_hairline);
		outline-offset: -1px;
	}

	/*
	 * Japanese and Chinese have no spaces to break at: wrap at phrase boundaries, never start a
	 * line with a small kana, and trim the blank half of a line-initial opening bracket so the
	 * first line of a quote starts on the same edge as the rest.
	 */
	.testimonial-grid:is(:lang(ja), :lang(zh)) {
		word-break: auto-phrase;
		line-break: strict;
		text-spacing-trim: trim-start;
	}

	/* Arabic and CJK scripts set small at 12 px and have no capitals to carry an eyebrow; use 14. */
	.testimonial-grid__eyebrow:is(:dir(rtl), :lang(ja), :lang(zh), :lang(ko)) {
		font-size: 0.875rem;
		line-height: 1.25rem;
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.testimonial-grid__tracked:dir(rtl) {
		letter-spacing: 0;
	}
</style>

Presentational only: it shows the quotes you pass, exactly as supplied. It does not collect, source, verify or moderate testimonials, and it does not paginate or load more; publish only quotes you have permission to use, attributed as the person agreed. No runtime packages beyond Svelte and Tailwind CSS.

Suggested location
src/lib/components/testimonial-grid-01
Required props
testimonials

Limitations

  • Every testimonial id must be unique within the list; it is the list key, and Svelte throws on duplicate keys in the browser.
  • Masonry fills each column top to bottom, so reading and focus order run down the first column before the second. Use the default rows layout when order matters.
  • The first quote marked featured moves to the front so it can span two columns; later featured flags are ignored.
  • Three columns fall back to two when the quotes (a featured one counts twice) would fill two cells, or leave one quote alone on the last row where two columns come out even (4, 10, 16 … cells). An odd count such as seven can still leave a short last row.
  • Quotes are plain text; a blank line starts a new paragraph. Do not include quotation marks, the component adds them for the page language.
  • Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.

Example

Svelte
<!-- Illustrative content: replace every quote with one you have permission to publish. -->
<script lang="ts">
	import TestimonialGrid, { type Testimonial } from '$lib/components/testimonial-grid-01/TestimonialGrid.svelte';

	const testimonials: Testimonial[] = [
		{
			id: 'marta',
			quote: 'The timeline is the deck now. When a date slips, the dependent work moves with it.',
			author: { name: 'Marta Oyelaran', role: 'Head of Delivery', company: 'Northfield Labs' },
			featured: true
		},
		{
			id: 'tomas',
			quote: 'Clients comment on the board itself instead of replying to a PDF.',
			author: { name: 'Tomás Reyes', role: 'Producer', company: 'Kiln & Co.' }
		},
		{
			id: 'aiko',
			quote: 'The morning Slack summary replaced our daily status thread.',
			author: { name: 'Aiko Brandt', role: 'Engineering Manager', company: 'Parcelwise' }
		}
	];
</script>

<TestimonialGrid eyebrow="Customer notes" title="What teams say about Halcyon" {testimonials}>
	{#snippet footer()}
		<a href="/customers">Read the customer stories</a>
	{/snippet}
</TestimonialGrid>

Testimonial grid#

Several customer quotes shown at once. Use the default rows layout when reading order matters: quotes read left to right, row by row, and the attributions in a row share a line. Switch to layout="masonry" when quotes vary a lot in length and order does not matter; masonry reads down each column.

Only publish quotes you can stand behind#

The component shows whatever you pass. It does not collect, verify or moderate testimonials. Use quotes you have permission to publish, attribute them the way the person agreed, and fill in source ("Customer interview, May 2026") so readers can tell a real quote from marketing copy. Portraits need permission too; without one, initials stand in.

Mark one testimonial featured: true to set it large across two columns (rows) or across the top (masonry). It moves to the front so it never leaves a hole in the grid.

Dark page#

Svelte
<div
	class="bg-zinc-950"
	style="--testimonial-grid-ink: #fafafa; --testimonial-grid-muted: #a1a1aa; --testimonial-grid-hairline: rgb(255 255 255 / 0.12); --testimonial-grid-accent: #fafafa;"
>
	<TestimonialGrid title="What teams say" {testimonials} />
</div>

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
testimonialsTestimonial[]YesNoneQuotes in reading order. Each has a unique id, quote (plain text, blank line between paragraphs), author { name, role?, company?, avatar?: { src } }, an optional source line and an optional featured flag. An empty list renders nothing.
eyebrowstringNoNoneShort uppercase label above the title. Omitted, no eyebrow renders.
titlestringNoNoneSection heading; the section is labelled by it.
labelstringNoNoneAccessible name for the section when no visible title is given, e.g. "Customer quotes". Ignored when title is set.
descriptionstringNoNoneOne or two sentences under the title, held to a readable measure.
layout'rows' | 'masonry'No'rows'rows keeps reading order across each row and lines up attributions; masonry packs quotes of uneven length into columns.
columns2 | 3No3Columns on wide screens. Two columns from sm, three from lg; always one column on phones.
headingLevel2 | 3No2Level of the section heading.

Customization#

On this page

Change content through props, retone the section through five --testimonial-grid-* CSS variables, and edit Tailwind classes in the source for spacing, type size or columns.

  • Accent: --testimonial-grid-accent colours the quotation marks. Keep it at 3:1 or better against the page; the warm palette uses #c2410c.
  • Text: --testimonial-grid-ink sets the title, quotes, names and initials; --testimonial-grid-muted sets the description, roles and source lines. Keep muted at 4.5:1 against the page.
  • Hairline: --testimonial-grid-hairline draws the rule above each quote and the edge of a portrait. The initials disc is mixed from the ink, so it follows a retone.
  • On-accent: --testimonial-grid-on-accent is declared for consistency with the other components; nothing is filled with the accent here, so it only matters if you add a filled action.
  • Dark page retone: ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.12), accent #fafafa (all --testimonial-grid-*), on a zinc-950 parent. usage.md has the snippet.
  • Layout: pass layout="masonry" when quotes vary a lot in length and order does not matter; keep rows otherwise. columns={2} suits a narrower page or fewer quotes.
  • Featured: mark one quote featured to set it large across two columns. It moves to the front of the list.
  • Portraits: pass author.avatar.src only for images you have permission to use; the image is decorative (the name is beside it). Without one, the author's initials show.
  • Provenance: use source for where and when the quote was given ("Customer interview, May 2026"), so readers can tell a real quote from marketing copy.

Public CSS variables

VariableToken
--testimonial-grid-accentaccent
--testimonial-grid-on-accentonAccent
--testimonial-grid-inkink
--testimonial-grid-mutedmuted
--testimonial-grid-hairlinehairline

Accessibility#

On this page
  • The section is labelled by its heading (aria-labelledby) when a title is given, or by the label prop when it is not. With neither, it is not a named region; give it a title or label on any page with more than one section.
  • Quotes are a list: each item is a figure holding a blockquote and a figcaption with the author, so the attribution is tied to the quotation.
  • The quotation marks are decorative punctuation drawn by CSS for the page language; the quotation itself is carried by the blockquote. Screen readers may or may not announce the marks, depending on their punctuation settings.
  • Portraits have empty alt text and initials are aria-hidden, because the author's name is written beside them.
  • In the rows layout, DOM order equals visual order, row by row. In masonry, columns read top to bottom, which is documented as a limitation.
  • Text tones are #18181b and #52525b on white (17.7:1 and 7.7:1). The warm accent #c2410c measures 5.2:1 on white. Ratios use the WCAG relative-luminance formula.
  • The layout uses logical properties and text-start, so it mirrors under dir="rtl"; letter-spacing resets to 0 for right-to-left scripts.
  • The component has no interactive elements of its own. Whatever you pass as footer needs its own focus style and a 44 px target on touch screens.
  • Element IDs come from $props.id(), so several grids on one page stay unique.

Known limitations

  • Contrast is computed for the shipped palettes only; re-check any changed token (4.5:1 for text).
  • Masonry reading order follows columns, not rows.

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 · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.