Skip to content
Download ZIP

Warm accent palette · 7.0 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_card_01 · version 1.0.0 · Warm accent palette
Using the PageSugar MCP server, fetch component cmp_testimonial_card_01 version 1.0.0 with variant "warm", 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
Warm accent
Version
1.0.0
Digest
Full digest
sha256-1da69dda0a0c695f616e61bb055d9d5a51a20b960c9f597e0cd20fb4a8f8dacd
TestimonialCard.svelte Svelte · 10.2 KB Raw
<script module lang="ts">
	export interface TestimonialAuthor {
		/** The speaker's name, exactly as they approved it. */
		name: string;
		/** Job title or relationship, e.g. "Head of delivery" or "Patient since 2021". */
		role?: string;
		/** Organisation, shown after the role. */
		company?: string;
		/** Portrait. The alt defaults to empty because the name sits beside it. */
		avatar?: { src: string; alt?: string };
		/** Overrides the initials drawn when there is no avatar, e.g. "AO" for "Dr Amara Okafor". */
		initials?: string;
	}

	export interface TestimonialLogo {
		src: string;
		/** The organisation's name, or "" when the caption already names it. */
		alt: string;
		width: number;
		height: number;
	}

	export interface TestimonialSource {
		/** Link text that says where it goes, e.g. "Read the full review". */
		label: string;
		href: string;
	}
</script>

<script lang="ts">
	interface Props {
		/** The quotation exactly as approved. A blank line starts a new paragraph. */
		quote: string;
		author: TestimonialAuthor;
		logo?: TestimonialLogo;
		/** Where the quote first appeared: a review, a case study, a post. */
		source?: TestimonialSource;
		/** A material connection to disclose, e.g. "Received a free year of Team for this feedback." */
		disclosure?: string;
		/** md is the card for grids and rows; lg is the featured quote set in display type. */
		size?: 'md' | 'lg';
		/** card sits on a hairline surface; plain has no container. */
		variant?: 'card' | 'plain';
	}

	let { quote, author, logo, source, disclosure, size = 'md', variant = 'card' }: Props = $props();

	const uid = $props.id();

	/* A blank line in the quote is a paragraph break; nothing is ever truncated. */
	const paragraphs = $derived(
		quote
			.split(/\n\s*\n/)
			.map((paragraph) => paragraph.trim())
			.filter(Boolean)
	);

	/* A word's first grapheme, so accents and combining marks stay with their letter. */
	const firstGrapheme = (word: string): string => {
		if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
			const [first] = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(word);
			return first?.segment ?? '';
		}
		return Array.from(word)[0] ?? '';
	};

	/* First and last word's first letter. toUpperCase, not the locale form, so server and browser agree. */
	const initials = $derived.by(() => {
		if (author.initials) return author.initials;
		const words = author.name.trim().split(/\s+/).filter(Boolean);
		const first = firstGrapheme(words[0] ?? '');
		/* Chinese, Japanese and Korean names take the family name's first character alone. */
		if (/[\p{sc=Han}\p{sc=Hiragana}\p{sc=Katakana}\p{sc=Hangul}]/u.test(first)) return first;
		const last = words.length > 1 ? firstGrapheme(words[words.length - 1]) : '';
		/* In joining scripts a zero-width non-joiner keeps the initials as two letters, not one word. */
		const joiner = /[\p{sc=Arabic}\p{sc=Syriac}]/u.test(first) ? '\u200c' : '';
		return (first + (last ? joiner + last : '')).toUpperCase();
	});

	/* Spaced middle dot between role and company; an expression so formatting cannot collapse it. */
	const SEPARATOR = ' · ';
	const meta = $derived([author.role, author.company].filter(Boolean) as string[]);

	/* The blockquote's cite attribute only takes an absolute URL. */
	const citeUrl = $derived(source && /^https?:\/\//i.test(source.href) ? source.href : undefined);

	const lg = $derived(size === 'lg');
</script>

<figure
	class={[
		'testimonial-card flex w-full min-w-0 flex-col text-start',
		lg ? 'testimonial-card--lg max-w-4xl' : 'max-w-xl',
		variant === 'card' &&
			(lg
				? 'rounded-2xl bg-[var(--_surface)] p-8 ring-1 ring-[var(--_hairline)] ring-inset sm:p-12'
				: 'rounded-2xl bg-[var(--_surface)] p-6 ring-1 ring-[var(--_hairline)] ring-inset sm:p-8')
	]}
>
	{#if logo}
		<img
			src={logo.src}
			alt={logo.alt}
			width={logo.width}
			height={logo.height}
			class={['block w-auto max-w-48 self-start object-contain', lg ? 'mb-12 h-8' : 'mb-8 h-6']}
		/>
	{/if}

	<blockquote
		cite={citeUrl}
		class={[
			'testimonial-card__quote text-pretty break-words text-[var(--_ink)]',
			lg
				? 'testimonial-card__tracked space-y-6 text-2xl/8 font-medium tracking-[-0.02em] sm:text-3xl/9 lg:text-4xl/11'
				: 'testimonial-card__tracked space-y-6 text-lg/7 tracking-[-0.01em]'
		]}
	>
		{#each paragraphs as paragraph, index (index)}
			<p
				class={[
					index === 0 && 'testimonial-card__open',
					index === paragraphs.length - 1 && 'testimonial-card__close'
				]}
			>
				{paragraph}
			</p>
		{/each}
	</blockquote>

	<figcaption class={['flex min-w-0 flex-col gap-3', lg ? 'mt-8' : 'mt-6']}>
		<div class="flex min-w-0 flex-wrap items-center justify-between gap-x-6 gap-y-3">
			<div class="flex min-w-0 items-start gap-3">
				<span
					class={[
						'testimonial-card__avatar relative flex shrink-0 items-center justify-center overflow-hidden rounded-full font-medium text-[var(--_muted)] select-none',
						lg ? 'size-12 text-base' : 'size-10 text-sm'
					]}
					aria-hidden={author.avatar?.alt ? undefined : 'true'}
				>
					{#if author.avatar}
						<img
							src={author.avatar.src}
							alt={author.avatar.alt ?? ''}
							class="absolute inset-0 size-full object-cover"
						/>
					{:else}
						{initials}
					{/if}
				</span>
				<!-- Centred beside the portrait while the text is short; top-aligned with it once the text wraps. -->
				<div
					class={[
						'flex min-w-0 flex-col justify-center',
						lg ? 'min-h-12 text-base/6' : 'min-h-10 text-sm/5'
					]}
				>
					<p id="{uid}-author" class="font-semibold break-words text-[var(--_ink)]">
						{author.name}
					</p>
					{#if meta.length > 0}
						<p class="testimonial-card__meta break-words text-[var(--_muted)]">
							{#each meta as part, index (index)}{#if index > 0}<span class="sr-only">,</span><span
										class="testimonial-card__separator"
										aria-hidden="true">{SEPARATOR}</span
									>{/if}<span class="testimonial-card__part">{part}</span>{/each}
						</p>
					{/if}
				</div>
			</div>

			{#if source}
				<a
					href={source.href}
					aria-describedby="{uid}-author"
					class={[
						'testimonial-card__source -mx-2 inline-flex min-h-11 max-w-[calc(100%+1rem)] min-w-11 items-center gap-2 rounded-sm px-2 font-medium break-words text-[var(--_ink)] underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)]',
						lg ? 'text-base/6' : 'text-sm/5'
					]}
				>
					<span class="min-w-0">{source.label}</span>
					<svg
						class="testimonial-card__arrow size-4 shrink-0"
						viewBox="0 0 16 16"
						fill="none"
						aria-hidden="true"
					>
						<path
							d="M3 8h9.5M8.5 4l4 4-4 4"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
							stroke-linejoin="round"
						/>
					</svg>
				</a>
			{/if}
		</div>

		{#if disclosure}
			<p class="text-xs/4 text-pretty break-words text-[var(--_muted)]">{disclosure}</p>
		{/if}
	</figcaption>
</figure>

<style>
	/* Public tokens: set --testimonial-card-* on this figure or any ancestor to retone it. */
	.testimonial-card {
		container: testimonial-card / inline-size;
		--_accent: var(--testimonial-card-accent, #c2410c);
		--_ink: var(--testimonial-card-ink, #18181b);
		--_muted: var(--testimonial-card-muted, #52525b);
		--_hairline: var(--testimonial-card-hairline, #00000014);
		--_surface: var(--testimonial-card-surface, #fdf8f3);
	}

	/*
	 * Hanging punctuation: the opening mark sits outside the text's start edge, so every line of the
	 * quote and the attribution below share one edge. The marks follow the page language through
	 * open-quote and close-quote, and the empty alt text keeps them out of the accessibility tree.
	 */
	.testimonial-card__open {
		position: relative;
	}
	.testimonial-card__open::before {
		content: open-quote;
		content: open-quote / '';
		position: absolute;
		inset-inline-end: 100%;
		padding-inline-end: 0.06em;
		color: var(--_accent);
	}
	.testimonial-card__close::after {
		content: close-quote;
		content: close-quote / '';
		color: var(--_accent);
	}

	/*
	 * In a narrow figure the role and the company each take a line of their own, so neither is
	 * broken across two lines behind a dangling separator.
	 */
	@container testimonial-card (max-width: 26rem) {
		.testimonial-card__part {
			display: block;
		}
		.testimonial-card__separator {
			display: none;
		}
	}
	/* CJK names and organisations wrap as whole words, never after a single character. */
	.testimonial-card__meta:is(:lang(zh), :lang(ja), :lang(ko)) {
		word-break: keep-all;
		overflow-wrap: anywhere;
	}

	/* The initials disc is a faint tint of the ink, so it sits on any surface the page has. */
	.testimonial-card__avatar {
		background-color: color-mix(in oklab, var(--_ink) 6%, transparent);
	}
	/* An inner hairline keeps a pale portrait from bleeding into a pale page. */
	.testimonial-card__avatar::after {
		content: '';
		position: absolute;
		inset: 0;
		border-radius: inherit;
		box-shadow: inset 0 0 0 1px var(--_hairline);
	}

	.testimonial-card__source {
		text-decoration-color: color-mix(in oklab, currentColor 40%, transparent);
	}
	.testimonial-card__arrow {
		transition: transform 150ms cubic-bezier(0.2, 0, 0, 1);
	}
	.testimonial-card__source:hover .testimonial-card__arrow {
		transform: translateX(2px);
	}
	.testimonial-card__source:dir(rtl) .testimonial-card__arrow {
		transform: scaleX(-1);
	}
	.testimonial-card__source:dir(rtl):hover .testimonial-card__arrow {
		transform: scaleX(-1) translateX(2px);
	}

	/* Arabic and Hebrew are never letter-spaced, and CJK sets solid. */
	.testimonial-card__tracked:dir(rtl),
	.testimonial-card__tracked:is(:lang(zh), :lang(ja), :lang(ko)) {
		letter-spacing: 0;
	}
	/* CJK breaks by strict rules; Korean keeps its words whole. */
	.testimonial-card__quote:is(:lang(zh), :lang(ja)) {
		line-break: strict;
	}
	/* Japanese wraps at phrase boundaries where the browser can find them. */
	.testimonial-card__quote:lang(ja) {
		word-break: auto-phrase;
	}
	.testimonial-card__quote:lang(ko) {
		word-break: keep-all;
	}

	@media (prefers-reduced-motion: reduce) {
		.testimonial-card__arrow {
			transition: none;
		}
		.testimonial-card__source:hover .testimonial-card__arrow {
			transform: none;
		}
		.testimonial-card__source:dir(rtl):hover .testimonial-card__arrow {
			transform: scaleX(-1);
		}
	}
</style>

Presentational only: renders the quote and attribution exactly as supplied. It does not verify that a quote is genuine, that you have permission to publish it, or that a rating exists; it does not fetch reviews and has no star rating. Publish only quotes the speaker approved, and use disclosure for any material connection.

Suggested location
src/lib/components/testimonial-card-01
Required props
quoteauthor

Limitations

  • Does not verify, source or moderate quotes, and renders no star rating; a rated review from a review platform is a different component.
  • The opening quotation mark hangs outside the text edge by about half an em. Give the component at least that much room at its start (the card's padding does; a plain quote needs the page gutter) or an ancestor with overflow: hidden will clip it.
  • The figure is a CSS size container (container-type: inline-size), so it takes its width from its parent: fine in a block, grid cell or stretched flex item, but give it an explicit width inside a shrink-to-fit parent such as an inline-flex row. Below 26rem the role and company stack on separate lines.
  • Quotation marks come from the page language through CSS open-quote and close-quote. Set lang on the page or an ancestor; older browsers without alt text for generated content may announce the marks.
  • The logo is an image drawn as supplied: it is not recoloured to match the palette, so supply a monochrome mark for the quiet look.
  • Light appearance only. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.

Example

Svelte
<!-- Illustrative content: replace with a quote the speaker has approved for publication. -->
<script lang="ts">
	import TestimonialCard from '$lib/components/testimonial-card-01/TestimonialCard.svelte';
</script>

<TestimonialCard
	size="lg"
	variant="plain"
	quote="Monday planning used to mean rebuilding the roadmap in a spreadsheet. Now the board is the roadmap."
	author={{ name: 'Sam Rivera', role: 'Head of delivery', company: 'Larkspur Housing Co-op' }}
	source={{ label: 'Read the case study', href: '/customers/larkspur' }}
/>

Testimonial card#

One quote, one speaker. Use size="lg" with variant="plain" for a featured quote between sections, and the default md card inside your own grid, row or carousel.

Honest content#

The component renders whatever you pass it and checks none of it. Before publishing:

  • Use the speaker's words as approved. Blank lines become paragraphs; nothing is shortened, so edit length with the speaker, not with CSS.
  • If the speaker received anything for the quote (a discount, a free plan, payment), say so in disclosure. It renders small and muted under the attribution.
  • Link source to where the quote first appeared when there is such a place. An absolute URL is also written to the blockquote's cite attribute.

A grid of cards#

Svelte
<ul class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3" role="list">
	{#each testimonials as t, i (i)}
		<li class="flex">
			<TestimonialCard quote={t.quote} author={t.author} source={t.source} />
		</li>
	{/each}
</ul>

Cards in a row keep their own height. To make a row of cards equal in height, give the list items flex as above and the card stretches; the attribution then sits under its own quote, not at the bottom of the cell.

Dark or tinted page#

CSS
.testimonials-on-dark {
	--testimonial-card-surface: #18181b;
	--testimonial-card-ink: #fafafa;
	--testimonial-card-muted: #a1a1aa;
	--testimonial-card-hairline: rgb(255 255 255 / 0.1);
	--testimonial-card-accent: #fafafa;
}

Quotation marks#

The marks come from CSS open-quote and close-quote, so they follow the page's lang: “ ” in English, « » in French, 「 」 in Japanese. The opening mark hangs just outside the text's start edge, so leave that much room at the start of a plain quote (the page gutter is enough).

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
quotestringYesNoneThe quotation exactly as approved. A blank line starts a new paragraph; nothing is truncated.
authorTestimonialAuthorYesNone{ name, role?, company?, avatar?: { src, alt? }, initials? }. Without an avatar, initials from the first and last word of the name are drawn; initials overrides them.
sourceTestimonialSourceNoNone{ label, href }. Link to where the quote first appeared. An absolute href is also set as the blockquote's cite attribute.
disclosurestringNoNoneA material connection to disclose under the attribution, e.g. a free subscription given for the feedback.
size'md' | 'lg'No'md'md sets the quote at 18 px for grids and rows; lg sets it at 24 to 36 px as a featured quote.
variant'card' | 'plain'No'card'card sits on the surface colour inside a one-pixel hairline with a 16 px radius; plain has no container or padding.

Customization#

On this page

Change content through props, retone through five --testimonial-card-* variables (accent, ink, muted, hairline, surface), and edit Tailwind classes in the source for type size, spacing or width.

  • Accent: --testimonial-card-accent colours the quotation marks and the source link's focus ring. It is the one place the brand colour appears; keep it at 3:1 or better against the surface for the focus ring.
  • Text: --testimonial-card-ink sets the quote, the name and the source link; --testimonial-card-muted sets the role, company, initials and disclosure. Keep muted at 4.5:1 against the surface.
  • Surface: --testimonial-card-surface fills the card variant; --testimonial-card-hairline draws its one-pixel edge and the avatar's inner ring. The plain variant paints no background.
  • Dark page retone: surface #18181b, ink #fafafa, muted #a1a1aa, hairline rgb(255 255 255 / 0.1), accent #fafafa (all --testimonial-card-*). usage.md has the snippet.
  • Width: the card caps at max-w-xl and the featured quote at max-w-4xl. In a grid, the cell width wins; remove the cap in the source to let a single card fill a wider column.
  • Quote size: change text-lg/7 (md) or the text-2xl/8 sm:text-3xl/9 lg:text-4xl/11 ladder (lg) on the blockquote; keep the negative tracking with the size.
  • Portraits: pass a square image of at least 96 px; it is cropped to a circle. Leave avatar.alt empty unless the portrait adds something the name does not.
  • Grids and carousels: render several cards in your own list; each figure is self-contained and its ids are unique per instance.

Public CSS variables

VariableToken
--testimonial-card-accentaccent
--testimonial-card-inkink
--testimonial-card-mutedmuted
--testimonial-card-hairlinehairline
--testimonial-card-surfacesurface

Accessibility#

On this page
  • A figure holds the quote in a blockquote and the attribution in a figcaption, so the speaker is never read as part of the quotation.
  • Quotation marks are generated content with empty alternative text, so screen readers read the words without announcing the marks.
  • The portrait's alt is empty by default and the initials disc is hidden from assistive technology, because the name sits beside them.
  • The source link is described by the speaker's name (aria-describedby), so repeated labels such as "Read the full review" stay distinguishable in a grid. Its target is at least 44 px tall.
  • The link shows a two-pixel focus outline in the accent colour, offset by two pixels, on :focus-visible only. The arrow's hover nudge is removed under prefers-reduced-motion.
  • Muted text (#52525b) measures 7.7:1 on white; ink (#18181b) measures 17.7:1. Under the warm palette the focus ring (#c2410c) measures about 5:1 on its surface.
  • Logical properties throughout: under dir="rtl" the mark hangs on the right, the arrow flips and negative tracking resets to 0.
  • Consumer's responsibility: supply the logo's alt text (or "" when the caption names the company), and publish only quotes the speaker approved, with any material connection disclosed.

Known limitations

  • Contrast is checked for the shipped palettes only; re-check any changed token (4.5:1 for text, 3:1 for the focus ring).
  • The hanging mark sits outside the component's box; an ancestor with overflow: hidden and no padding will clip it.

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.