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.
cmp_testimonial_card_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_testimonial_card_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-7a934186e0caf76d9737611d75420d7df1e2d6cd2f5391eee27d2bdb614c45a2
<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>
Usage#
On this pagePresentational 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
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-fitparent 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
<!-- 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
sourceto where the quote first appeared when there is such a place. An absolute URL is also written to the blockquote'sciteattribute.
A grid of cards#
<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#
.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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
quote | string | Yes | None | The quotation exactly as approved. A blank line starts a new paragraph; nothing is truncated. |
author | TestimonialAuthor | Yes | None | { 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 | None | { 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 | None | { label, href }. Link to where the quote first appeared. An absolute href is also set as the blockquote's cite attribute. |
disclosure | string | No | None | 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#
On this pageChange 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-accentcolours 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-inksets the quote, the name and the source link;--testimonial-card-mutedsets the role, company, initials and disclosure. Keep muted at 4.5:1 against the surface. - Surface:
--testimonial-card-surfacefills the card variant;--testimonial-card-hairlinedraws 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.mdhas the snippet. - Width: the card caps at
max-w-xland the featured quote atmax-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 thetext-2xl/8sm:text-3xl/9lg:text-4xl/11ladder (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.altempty 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
| Variable | Token |
|---|---|
--testimonial-card-accent | accent |
--testimonial-card-ink | ink |
--testimonial-card-muted | muted |
--testimonial-card-hairline | hairline |
--testimonial-card-surface | surface |
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.