# Testimonial card

> One customer quote with name, role, company, portrait or initials, optional logo and source link. A card for grids and rows, or a large featured quote set in display type mid-page.

- ID: `cmp_testimonial_card_01`
- Slug: `testimonial-card-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `social-proof`
- Detail page: https://pagesugar.com/components/testimonial-card-01?variant=neutral
- Preview: https://pagesugar.com/preview/testimonial-card-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2` |
| `warm` | Warm accent | no | `sha256-1da69dda0a0c695f616e61bb055d9d5a51a20b960c9f597e0cd20fb4a8f8dacd` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: no
- Integration level: presentational
- Appearance modes: light
- Suggested directory: `src/lib/components/testimonial-card-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

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.

Required props: `quote`, `author`

```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' }}
/>
```

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.

## Usage guide

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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `quote` | `string` | yes |  | The quotation exactly as approved. A blank line starts a new paragraph; nothing is truncated. |
| `author` | `TestimonialAuthor` | yes |  | { 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. |
| `logo` | `TestimonialLogo` | no |  | { src, alt, width, height }. Organisation logo above the quote, 24 px tall (32 px at lg). Use alt "" when the caption already names the company. |
| `source` | `TestimonialSource` | no |  | { label, href }. Link to where the quote first appeared. An absolute href is also set as the blockquote's cite attribute. |
| `disclosure` | `string` | no |  | A 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

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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--testimonial-card-accent` |
| `ink` | `--testimonial-card-ink` |
| `muted` | `--testimonial-card-muted` |
| `hairline` | `--testimonial-card-hairline` |
| `surface` | `--testimonial-card-surface` |

## Accessibility

- 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `TestimonialCard.svelte`
- Suggested directory: `src/lib/components/testimonial-card-01`
- Files: 1
- Artifact digest: `sha256-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `TestimonialCard.svelte`

Role: entry · 10472 bytes · SHA-256 `e73a01ec6274f571ed3182a33caaa4e0974b6140f4efadbdc439a6e1035874ad`

```svelte
<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, #18181b);
		--_ink: var(--testimonial-card-ink, #18181b);
		--_muted: var(--testimonial-card-muted, #52525b);
		--_hairline: var(--testimonial-card-hairline, rgb(0 0 0 / 0.08));
		--_surface: var(--testimonial-card-surface, #ffffff);
	}

	/*
	 * 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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2`
- Entry: `TestimonialCard.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_testimonial_card_01/1.0.0/neutral/sha256-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_testimonial_card_01/1.0.0/neutral/sha256-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2/bundle.zip (7151 bytes, sha256 `3523af99e4bd644e1b1c9eaddfbcba9a69eb4ca5f5b61fee27d2b27036fecfce`)

Files:

- `TestimonialCard.svelte` (entry, 10472 bytes): https://pagesugar.com/artifacts/cmp_testimonial_card_01/1.0.0/neutral/sha256-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2/source/TestimonialCard.svelte
