GODRICH

Testimonial Section Design: 9 Social Proof Blocks

Testimonial section design is the band of a page that shows proof from real customers — quotes, star ratings, client marks, hard numbers — and these nine

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows how much proof you actually have, not how loud the motion is. It opens with the three-or-four-review case (01), moves to the pile that has outgrown a single row (02), then to the one customer worth quoting at full size (03). Numbers take over next: the score breakdown (04), then the row of client marks that answers who else is already here (05). After that come the two blocks that argue a result rather than a feeling — the metric that changed (06) and the faces on camera (07). The last two close a page, with a wall of one-liners pulled from social (08) and the band of counts that sits beside a price (09). Every block below was rendered at 480×300 and sampled over 24 frames, and not one of them overflowed its stage at desktop or at phone width.

01Auto-rotating testimonial carousel

Three review cards share one slot: each slides in from the right, holds while its dot stretches into a mint pill, then leaves to the left. Use it on a landing band that holds three or four reviews and will never hold more. Nothing is duplicated here — the three cards run one keyframe with negative delays a third of a cycle apart, and the jump back to the right edge happens at 70%, which is off-screen; the loop registered 9 animations and 8.119% cumulative changed area.

translateXanimation-play-statearia-live
.pc.is-demo .pc__card {
  animation-name: pcSlide;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}
.pc.is-demo .pc__card--1 { animation-delay: -.16s; }
.pc.is-demo .pc__card--2 { animation-delay: -1.4933s; }
.pc.is-demo .pc__card--3 { animation-delay: -.8267s; }
@keyframes pcSlide {
  0%      { transform: translateX(100%); }
  8%      { transform: translateX(0); }
  33.33%  { transform: translateX(0); }
  41.33%  { transform: translateX(-100%); }
  70%     { transform: translateX(-100%); }
  70.01%  { transform: translateX(100%); }
  100%    { transform: translateX(100%); }
}

02Ragged-height quote grid

Reviews of uneven length are laid on a grid of 8px rows and keep their own heights, and one card at a time rises 6px, scales to 1.04, and settles. Reach for it once a product page carries more reviews than one tidy row can hold. It is the calmest block in the set by intensity at 57.4, yet five cards taking turns still moved 28.994% of the frame across 14 of the 24 sampled frames.

grid-rowscale:focus-visible
.qm {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  grid-auto-rows: 8px;
  grid-auto-flow: row dense;
  column-gap: $sp-2;
  row-gap: 0;
  width: clamp(288px, 94vw, 420px);
  margin: 0;
}
.qm__card--1 { grid-row: span 12; }
.qm__card--2 { grid-row: span 14; }
.qm__card--3 { grid-row: span 9; }
.qm__card--4 { grid-row: span 12; }
.qm__card--5 { grid-row: span 9; }

03Big quote with avatar switcher

One quote fills the block at up to 22px while four faces wait beneath it, and the ring around the chosen face slides exactly one cell sideways just before the quote is cut to the next one. Pick it when a single flagship customer should carry the whole section. Seventeen animations run here, more than in any other block, and 20 of 24 frames showed movement.

steps(1, end)translateXaria-pressed
.qs__ring {
  grid-row: 1;
  grid-column: 1;
  border: 3px solid $pf-ink;
  border-radius: 50%;
  pointer-events: none;
  transform: translateX(calc(var(--i) * 100%));
  transition: transform $dur-base $easing;
}
@keyframes qsRing {
  0%, 16%    { transform: translateX(0); }
  24.5%, 41% { transform: translateX(100%); }
  49.5%, 66% { transform: translateX(200%); }
  74.5%, 79% { transform: translateX(300%); }
  88%, 100%  { transform: translateX(0); }
}
@keyframes qsCut {
  0%      { opacity: 1; }
  25%     { opacity: 0; }
  100%    { opacity: 0; }
}

04Rating distribution bars

Five rows, five stars down to one, fill from the left on a scaleX, while the average score and the review count beside them cut to the next value instead of fading into it. Use it where a store page owes the visitor a breakdown rather than another sentence. Only the bars and two small numbers ever change, which makes this the quietest block of the nine: 1.737% cumulative changed area, with a per-frame peak of 0.449%.

scaleXgrid-areaaria-label
.rd__row--5 { --p: .68; }
.rd__row--4 { --p: .21; }
.rd__row--3 { --p: .07; }
.rd__row--2 { --p: .03; }
.rd__row--1 { --p: .01; }

.rd__track { display: grid; }
.rd__bg, .rd__fill {
  grid-area: 1 / 1;
  height: 8px;
  border-radius: $r-pill;
}
.rd__bg { background: rgba(28, 27, 34, .12); }
.rd__fill {
  background: $color;
  transform: scaleX(var(--p));
  transform-origin: left center;
}
@keyframes rdFill {
  0%        { transform: scaleX(0); }
  46%, 100% { transform: scaleX(var(--p)); }
}

05Logo marquee band

Eight client marks drift left at a constant speed, and both edges of the band dissolve into the stage color through a mask so no mark is ever chopped at the boundary. Drop it in when the argument you want to make is simply who else uses this. A single animation drives the entire band, and it moved in 23 of 24 frames — the steadiest loop in the set.

translateXmargin-rightmask-image
.lm__band {
  box-sizing: border-box;
  width: 100%;
  padding-top: $sp-4;
  padding-bottom: $sp-4;
  border-top: 1px solid rgba(28, 27, 34, .14);
  border-bottom: 1px solid rgba(28, 27, 34, .14);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 12%, #000 88%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 12%, #000 88%, transparent 100%);
}
.lm__mark {
  flex: 0 0 auto;
  margin-right: $sp-6;
}
@keyframes lmRun {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

06Before-and-after flip card

Press a card carrying a before number and it turns over to the after number, while a bar under the card fills and a mint badge rises into place. Use it in a case-study row where the whole point is a metric that moved. The preview loop cuts between the two faces instead of rotating through the edge, and the bar and badge still pushed it to 28.832% cumulative changed area across 22 moving frames.

rotateYbackface-visibilityaria-pressed
.ba__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
}
.ba__face--front { background: $pf-cream; color: $pf-ink; }
.ba__face--back  { background: $pf-ink; color: $pf-cream; transform: rotateY(180deg); }
.ba__card[aria-pressed="true"] .ba__inner { transform: rotateY(180deg); }
@keyframes baFlip {
  0%, 29%   { transform: rotateY(0deg); }
  30%, 79%  { transform: rotateY(180deg); }
  80%, 100% { transform: rotateY(0deg); }
}
@keyframes baBar {
  0%        { transform: scaleX(0); }
  30%, 72%  { transform: scaleX(1); }
  80%, 100% { transform: scaleX(0); }
}

07Video testimonial thumbnail grid

Four thumbnails hold equal columns until one grows to 2.2fr and squeezes the other three to 0.6fr, and inside the widened cell a one-line quote wipes in from the left beneath the name. Reach for it when your reviews are video interviews rather than text. This is the widest-moving block of the nine: 36.411% cumulative changed area, with a single frame accounting for 28.555% of it.

grid-template-columnsaria-expandedclip-path
.vg__grid {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  gap: $sp-2;
  transition: grid-template-columns $dur-base $easing;
}
.vg__grid.is-open-0 { grid-template-columns: 2.2fr .6fr .6fr .6fr; }
@keyframes vgCols {
  0%, 6%    { grid-template-columns: 1fr 1fr 1fr 1fr; animation-timing-function: cubic-bezier(.7, 0, .9, .3); }
  33%, 66%  { grid-template-columns: 2.2fr .6fr .6fr .6fr; animation-timing-function: linear; }
  90%, 100% { grid-template-columns: 1fr 1fr 1fr 1fr; }
}
@keyframes vgWipe {
  0%, 33%      { clip-path: inset(0 100% 0 0); }
  33.01%, 66%  { clip-path: inset(0 0 0 0); }
  66.01%, 100% { clip-path: inset(0 100% 0 0); }
}

08Short-post card wall

Three one-line cards float at staggered heights on an ink stage: the first wears a conic-gradient border light that makes one full turn, the second a neon edge that brightens and comes back down. Use it to gather short social reactions without embedding a third-party widget. It moved in 23 of 24 frames at an average intensity of 69.4, which is what an unhurried drift looks like once it is measured.

@propertyconic-gradienttranslateY
@property --pw-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.pw__card--a {
  border: 2px solid transparent;
  background:
    linear-gradient($pf-slate, $pf-slate) padding-box,
    conic-gradient(from var(--pw-angle), $pf-violet, $pf-mint, $pf-teal, $pf-violet) border-box;
}
@keyframes pwSpin {
  from { --pw-angle: 0deg; }
  to   { --pw-angle: 360deg; }
}
@keyframes pwFloat {
  0%, 100% { transform: translateY(0); }
  30%      { transform: translateY(-10px); }
  66%      { transform: translateY(3px); }
}

09Trust metrics band

Customer count, average score, and first-response time step up to their final values one cut at a time, and the three certification pills underneath stamp down in turn. Put it directly above or below a pricing table as the last piece of reassurance before the decision. Fifteen animations run inside a band that disturbs only 8.078% of the frame, because most of that work is opacity swapping in a stack where nothing else has to move.

steps(1, end)grid-areaanimation-fill-mode
.tb__stack {
  display: grid;
  margin: 0;
}
.tb__n {
  grid-area: 1 / 1;
  opacity: 0;
}
.tb__n:last-child { opacity: 1; }
@keyframes tbSlot1 { 0% { opacity: 1; } 22%, 100% { opacity: 0; } }
@keyframes tbSlot2 { 0% { opacity: 0; } 22% { opacity: 1; } 44%, 100% { opacity: 0; } }
@keyframes tbSlot3 { 0% { opacity: 0; } 44% { opacity: 1; } 66%, 100% { opacity: 0; } }
@keyframes tbSlot4 { 0% { opacity: 0; } 66%, 100% { opacity: 1; } }
@keyframes tbStamp {
  0%        { transform: translateY(-8px) scale(.6); opacity: 0; }
  5%        { transform: translateY(-4px) scale(.82); opacity: 1; }
  14%       { transform: translateY(0) scale(1.08); opacity: 1; }
  22%, 100% { transform: translateY(0) scale(1); opacity: 1; }
}

Where it breaks — the trap

The first break was geometric, and it belongs to the flip in 06. A rotated face is drawn at a width of |cos θ|, so the rate at which its area changes per frame is |sin θ|, which always peaks around 90 degrees. The thumbnail is chosen as the frame that differs most from the one before it, so no easing curve saves you: the edge-on view, a single vertical line, wins every time. The fix was to split the two paths — the preview loop flips on steps(1, end) while a real press keeps transition: transform and turns smoothly — and then to pay for the frames that cutting threw away, which is why the bar under the card runs a scaleX and the badge a translateY. Those two together are what carried 06 to 22 moving frames out of 24.

The second is arithmetic, and it decides whether the marquee in 05 has a visible seam. Re-measured with Playwright while writing this, the track is 1858.00px wide across 16 items, and the eight marks of one set add up to 928.75px once getBoundingClientRect().width and margin-right are summed; half the track reads 929.00px, the 0.25px difference being offsetWidth rounding to a whole pixel. Screenshots taken with the animation clock pinned at 0% and at 100% of the two-second cycle came back with a maximum per-pixel difference of 0, and zero pixels above the threshold of 12. That only works because the spacing lives on margin-right, which applies to the last item too; a flex gap would give 16 marks only 15 gaps, and -50% would then land half a gap short on every lap.

The third break is a sampling problem. A 24-frame render puts one sample every 4.1667% of the loop, and the quote wipe in 07 was originally held open from 30% to 54% of the cycle, which guaranteed several frames with the sentence sliced through the middle of a word — and one of them was duly picked as the poster. Compressing the wipe into a single tick, 33% to 33.01%, pushing the column-width change late with an animation-timing-function declared inside the keyframe, and stretching the close out to 90% put a fully opened cell with a complete one-line quote in the winning frame instead.

The fourth never appeared in the loop at all. In 03 the four quotes and the four bylines were first stacked into the same grid-area: 1 / 1, and the moment a two-line quote was selected the byline landed on top of its second line. Every gate reads the .is-demo state, so nothing flagged it; it only surfaced in a Playwright snapshot taken with .is-demo removed, on the click path a real visitor uses. Quotes moved to row one and bylines to row two. The archive behind 2htnhppu ships all nine at these corrected values, keyframes and markup included, so none of it has to be rediscovered by hand.

Accessibility (reduced-motion)

Every color pairing used across the nine was computed with the WCAG 2.1 relative-luminance formula first, and the ones that failed were kept out of the text layers rather than quietly shipped.

Foreground Background Measured ratio Body-text bar
Cream #fbf7ef Ink #1c1b22 15.99:1 Pass
Ink #1c1b22 Amber #ffd23f 11.83:1 Pass
Mint #6ee7d7 Ink #1c1b22 11.45:1 Pass
Mint #6ee7d7 Slate #242230 10.45:1 Pass
Stone #565062 Cream #fbf7ef 7.23:1 Pass
Violet #5b3df5 White #ffffff 6.12:1 Pass
White #ffffff Teal #0b7269 5.79:1 Pass
Ink #1c1b22 Orange #ff4d1f 5.15:1 Pass
Orange #ff4d1f Cream #fbf7ef 3.1:1 Fails — shapes only
Violet #5b3df5 Slate #242230 2.55:1 Fails — border light only
White #ffffff Amber #ffd23f 1.44:1 Fails — never used

That last row is why every word on the amber stages of 03 and 07 is ink, and why the violet in 08 appears in the spinning border and the aurora but never as a letter. The threshold itself and the way these ratios are derived are set out in the W3C's contrast guidance.

Under prefers-reduced-motion: reduce each block drops its motion and keeps its information. In 01 the cards and pips stop, and the .is-on card with its widened pip stays on screen; the window is marked aria-live="off" so the automatic rotation makes no announcements to a screen reader, while the pause control is a real aria-pressed toggle and each dot carries aria-current. In 02 only the animation is switched off, so hovering or tabbing to a <figure tabindex="0"> still lifts it. In 03 the quotes and bylines are forced back to hidden except the .is-on pair, and the four faces stay aria-pressed buttons inside a role="group" rail with an aria-live="polite" quote. In 04 the bars keep transform: scaleX(var(--p)), so the distribution is still drawn, the fourth number and count are pinned visible, and the star summary stays a role="img" with an aria-label that spells the score out. In 05 the track stops and wraps onto two lines, the mask is removed, and the duplicated half is dropped with display: none, which matters because those eight copies are already aria-hidden="true" and tabindex="-1". In 06 both animation and transition go, so pressing a card still flips it through aria-pressed, just instantly. In 07 the grid stops animating but aria-expanded="true" and the is-open-N class still widen the chosen cell and reveal its quote. In 08 the cards settle and the aurora border rests at the @property initial value of 0deg. In 09 the stack stops on its last child, which is the final number, and the badges stay stamped. Each of those blocks needs !important, because the autoplay selectors carry a (0, 3, 0) specificity a bare rule inside a media query cannot beat; the mask syntax used in 05 is documented in MDN's mask-image reference. Neighboring bands of the same page are in the footer pattern set and the rest of the template collection, and the about page explains how each demo is measured before it ships.

FAQ

Which of the nine need JavaScript?

Two of them need none at all: 05 is a pure CSS marquee and 02 runs on hover, focus, and one keyframe — their demos carry only a few lines that stop the autoplay the first time you interact. The other seven use script only to toggle a class or an ARIA attribute — aria-pressed in 01, 03, 04, and 06, aria-expanded and is-open-N in 07, is-picked in 04, is-count in 09 — so the visible states are all CSS and you can wire them to whatever framework you already use.

Do the column animation in 07 and the border light in 08 work everywhere?

Animating grid-template-columns needs Chrome 107, Firefox 66, or Safari 16.0, and @property, which is what lets the conic-gradient angle in 08 animate at all, needs Chrome 85, Firefox 128, or Safari 16.4 — both figures from caniuse. Older browsers simply land on the end state without the tween, which is why the demos also define a static path.

How many reviews should a section actually show?

Match the block to the count rather than padding the count to fit the block. Three or four reviews belong in 01, a dozen or more in 02, exactly one in 03, and when you have no quotes worth printing at all, 04, 05, and 09 let numbers and client marks carry the section on their own.

Enter the archive password

The password is inside this article. You will find it as you read.