25 CSS Testimonials & Reviews08 / 25

Pure CSSMIT licensed

Mobile-First Swipeable Card Deck

A phone-native card deck where the cards physically stack, rotate and settle as you drag — and every frame of it is driven by scroll position, not JavaScript. animation-timeline: view(inline) maps each card's own visibility to its transform, so the deck behaves identically on a trackpad, a touchscreen and a keyboard.

Published

Live Demo
Try it

The code

<section class="tr-08" aria-label="Mobile-first swipeable testimonial card deck demo">
  <div class="tr-08__stage">
    <div class="tr-08__phone">
      <div class="tr-08__bezel" aria-hidden="true"><span class="tr-08__island"></span></div>
      <div class="tr-08__screen">
        <header class="tr-08__top">
          <p class="tr-08__eyebrow">What members say</p>
          <h2 class="tr-08__title">Swipe the deck</h2>
        </header>

        <div class="tr-08__deck" tabindex="0" role="group" aria-label="Testimonials, swipe or use arrow keys">
          <figure class="tr-08__card" style="--tr-08-h:265">
            <div class="tr-08__stars" role="img" aria-label="Rated 5 out of 5">★★★★★</div>
            <blockquote>Six weeks in and I have run further than the whole of last year. The plan just… fits around the school run.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1541101767792-f9b2b1c4f127?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Robyn Fletcher" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Robyn Fletcher</b>Leeds · Member since 2025</span>
            </figcaption>
          </figure>
          <figure class="tr-08__card" style="--tr-08-h:25">
            <div class="tr-08__stars" role="img" aria-label="Rated 5 out of 5">★★★★★</div>
            <blockquote>The coach replies in hours, not days. I have paid four times this for less.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1519345182560-3f2917c472ef?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Ethan Park" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Ethan Park</b>Vancouver · Member since 2024</span>
            </figcaption>
          </figure>
          <figure class="tr-08__card" style="--tr-08-h:150">
            <div class="tr-08__stars" role="img" aria-label="Rated 4 out of 5">★★★★<span>★</span></div>
            <blockquote>Wish the watch sync was faster, but the strength block is the best I have followed anywhere.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Erin McAllister" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Erin McAllister</b>Dublin · Member since 2026</span>
            </figcaption>
          </figure>
          <figure class="tr-08__card" style="--tr-08-h:320">
            <div class="tr-08__stars" role="img" aria-label="Rated 5 out of 5">★★★★★</div>
            <blockquote>Cancelled two other subscriptions the week I joined. Everything I actually used is in here.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1463453091185-61582044d556?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Samuel Idris" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Samuel Idris</b>Auckland · Member since 2025</span>
            </figcaption>
          </figure>
        </div>

        <p class="tr-08__hint">Drag, flick, or press <kbd>←</kbd> <kbd>→</kbd></p>
      </div>
    </div>
    <p class="tr-08__chip" aria-hidden="true">animation-timeline:view(inline) · animation-range:entry/exit · scroll-snap-stop:always</p>
  </div>
</section>
.tr-08,
.tr-08 *,
.tr-08 *::before,
.tr-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tr-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --tr-08-bg: oklch(0.94 0.02 90);
  --tr-08-screen: oklch(0.99 0.006 90);
  --tr-08-ink: oklch(0.23 0.02 60);
  --tr-08-mut: oklch(0.56 0.02 60);
  --tr-08-acc: oklch(0.62 0.2 20);
  --tr-08-star: oklch(0.72 0.17 55);
  background: var(--tr-08-bg);
  color: var(--tr-08-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  -webkit-font-smoothing: antialiased;
}

.tr-08__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 20px;
  padding: clamp(16px,4vw,44px);
  background: radial-gradient(70% 50% at 50% 10%,color-mix(in oklch,var(--tr-08-acc) 14%,transparent),transparent 60%);
}

.tr-08__phone {
  position: relative;
  width: min(100%,392px);
  border-radius: 46px;
  padding: 11px;
  background: linear-gradient(160deg,oklch(0.32 0.02 60),oklch(0.16 0.01 60));
  box-shadow: 0 3px 0 oklch(1 0 0/.14) inset,0 40px 80px -40px oklch(0 0 0/.75),0 0 0 1px oklch(0 0 0/.3);
}

.tr-08__bezel {
  position: absolute;
  inset-block-start: 11px;
  inset-inline: 11px;
  height: 34px;
  display: grid;
  place-items: center;
  z-index: 3;
  pointer-events: none;
}

.tr-08__island {
  width: 96px;
  height: 26px;
  border-radius: 99px;
  background: oklch(0.1 0 0);
}

.tr-08__screen {
  border-radius: 36px;
  background: var(--tr-08-screen);
  overflow: hidden;
  padding: 52px 0 22px;
  display: grid;
  gap: 16px;
}

.tr-08__top {
  padding-inline: 24px;
  display: grid;
  gap: 5px;
}

.tr-08__eyebrow {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--tr-08-acc);
}

.tr-08__title {
  font-size: 26px;
  font-weight: 680;
  letter-spacing: -.035em;
}

.tr-08__deck {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding: 26px 0;
  scrollbar-width: none;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}

.tr-08__deck::-webkit-scrollbar {
  display: none;
}

.tr-08__deck::before,
.tr-08__deck::after {
  content: '';
  flex: 0 0 calc(50% - 118px);
  min-width: 0;
}

.tr-08__deck:focus-visible {
  outline: 3px solid var(--tr-08-acc);
  outline-offset: -4px;
  border-radius: 20px;
}

.tr-08__card {
  flex: 0 0 236px;
  scroll-snap-align: center;
  display: grid;
  align-content: start;
  gap: 13px;
  padding: 22px 20px;
  border-radius: 22px;
  background: linear-gradient(165deg,oklch(0.99 0.01 var(--tr-08-h,20)),oklch(0.95 0.035 var(--tr-08-h,20)));
  border: 1px solid oklch(0 0 0/.07);
  box-shadow: 0 18px 36px -22px oklch(0 0 0/.5);
  transform-origin: 50% 60%;
}

@supports (animation-timeline: view()) {
  .tr-08__card {
    animation: tr-08-deck linear both;
    animation-timeline: view(inline);
    animation-range: entry 5% exit 95%;
  }

  @keyframes tr-08-deck {
    0% {
      scale: .82;
      rotate: 8deg;
      opacity: .3;
    }

    50% {
      scale: 1;
      rotate: 0deg;
      opacity: 1;
    }

    100% {
      scale: .82;
      rotate: -8deg;
      opacity: .3;
    }
  }
}

.tr-08__stars {
  font-size: 14px;
  letter-spacing: 2px;
  color: var(--tr-08-star);
}

.tr-08__stars span {
  color: oklch(0 0 0/.15);
}

.tr-08__card blockquote {
  font-size: 15px;
  line-height: 1.5;
  letter-spacing: -.015em;
  text-wrap: pretty;
}

.tr-08__card figcaption {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-top: 13px;
  border-top: 1px solid oklch(0 0 0/.08);
}

.tr-08__card figcaption img {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  object-fit: cover;
  flex: none;
  background: oklch(0.88 0.03 30);
}

.tr-08__card figcaption span {
  display: grid;
  font-size: 11.5px;
  line-height: 1.35;
  color: var(--tr-08-mut);
}

.tr-08__card figcaption b {
  font-size: 13.5px;
  font-weight: 650;
  color: var(--tr-08-ink);
  letter-spacing: -.012em;
}

.tr-08__hint {
  text-align: center;
  font-size: 12px;
  color: var(--tr-08-mut);
}

.tr-08__hint kbd {
  display: inline-grid;
  place-items: center;
  min-width: 22px;
  height: 22px;
  padding: 0 5px;
  border-radius: 6px;
  border: 1px solid oklch(0 0 0/.14);
  border-block-end-width: 2px;
  background: oklch(1 0 0);
  font: inherit;
  font-size: 11px;
}

.tr-08__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--tr-08-mut);
  padding: 8px 15px;
  border: 1px solid oklch(0 0 0/.13);
  border-radius: 99px;
  text-align: center;
  background: oklch(1 0 0/.5);
}

@media (prefers-reduced-motion: reduce) {
  .tr-08__card {
    animation: none;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Testimonials & Review from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Mobile-First Swipeable Card Deck
Source: https://codefronts.com/components/css-testimonials-reviews/mobile-first-swipeable-card-deck/

A phone-native card deck where the cards physically stack, rotate and settle as you drag — and every frame of it is driven by scroll position, not JavaScript. animation-timeline: view(inline) maps each card's own visibility to its transform, so the deck behaves identically on a trackpad, a touchscreen and a keyboard.
## HTML
```html
<section class="tr-08" aria-label="Mobile-first swipeable testimonial card deck demo">
  <div class="tr-08__stage">
    <div class="tr-08__phone">
      <div class="tr-08__bezel" aria-hidden="true"><span class="tr-08__island"></span></div>
      <div class="tr-08__screen">
        <header class="tr-08__top">
          <p class="tr-08__eyebrow">What members say</p>
          <h2 class="tr-08__title">Swipe the deck</h2>
        </header>

        <div class="tr-08__deck" tabindex="0" role="group" aria-label="Testimonials, swipe or use arrow keys">
          <figure class="tr-08__card" style="--tr-08-h:265">
            <div class="tr-08__stars" role="img" aria-label="Rated 5 out of 5">★★★★★</div>
            <blockquote>Six weeks in and I have run further than the whole of last year. The plan just… fits around the school run.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1541101767792-f9b2b1c4f127?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Robyn Fletcher" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Robyn Fletcher</b>Leeds · Member since 2025</span>
            </figcaption>
          </figure>
          <figure class="tr-08__card" style="--tr-08-h:25">
            <div class="tr-08__stars" role="img" aria-label="Rated 5 out of 5">★★★★★</div>
            <blockquote>The coach replies in hours, not days. I have paid four times this for less.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1519345182560-3f2917c472ef?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Ethan Park" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Ethan Park</b>Vancouver · Member since 2024</span>
            </figcaption>
          </figure>
          <figure class="tr-08__card" style="--tr-08-h:150">
            <div class="tr-08__stars" role="img" aria-label="Rated 4 out of 5">★★★★<span>★</span></div>
            <blockquote>Wish the watch sync was faster, but the strength block is the best I have followed anywhere.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Erin McAllister" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Erin McAllister</b>Dublin · Member since 2026</span>
            </figcaption>
          </figure>
          <figure class="tr-08__card" style="--tr-08-h:320">
            <div class="tr-08__stars" role="img" aria-label="Rated 5 out of 5">★★★★★</div>
            <blockquote>Cancelled two other subscriptions the week I joined. Everything I actually used is in here.</blockquote>
            <figcaption>
              <img src="https://images.unsplash.com/photo-1463453091185-61582044d556?q=80&w=120&h=120&fit=crop&auto=format" alt="Portrait of Samuel Idris" width="42" height="42" loading="lazy" decoding="async">
              <span><b>Samuel Idris</b>Auckland · Member since 2025</span>
            </figcaption>
          </figure>
        </div>

        <p class="tr-08__hint">Drag, flick, or press <kbd>←</kbd> <kbd>→</kbd></p>
      </div>
    </div>
    <p class="tr-08__chip" aria-hidden="true">animation-timeline:view(inline) · animation-range:entry/exit · scroll-snap-stop:always</p>
  </div>
</section>
```
## CSS
```css
.tr-08,
.tr-08 *,
.tr-08 *::before,
.tr-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.tr-08 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --tr-08-bg: oklch(0.94 0.02 90);
  --tr-08-screen: oklch(0.99 0.006 90);
  --tr-08-ink: oklch(0.23 0.02 60);
  --tr-08-mut: oklch(0.56 0.02 60);
  --tr-08-acc: oklch(0.62 0.2 20);
  --tr-08-star: oklch(0.72 0.17 55);
  background: var(--tr-08-bg);
  color: var(--tr-08-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
  -webkit-font-smoothing: antialiased;
}

.tr-08__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 20px;
  padding: clamp(16px,4vw,44px);
  background: radial-gradient(70% 50% at 50% 10%,color-mix(in oklch,var(--tr-08-acc) 14%,transparent),transparent 60%);
}

.tr-08__phone {
  position: relative;
  width: min(100%,392px);
  border-radius: 46px;
  padding: 11px;
  background: linear-gradient(160deg,oklch(0.32 0.02 60),oklch(0.16 0.01 60));
  box-shadow: 0 3px 0 oklch(1 0 0/.14) inset,0 40px 80px -40px oklch(0 0 0/.75),0 0 0 1px oklch(0 0 0/.3);
}

.tr-08__bezel {
  position: absolute;
  inset-block-start: 11px;
  inset-inline: 11px;
  height: 34px;
  display: grid;
  place-items: center;
  z-index: 3;
  pointer-events: none;
}

.tr-08__island {
  width: 96px;
  height: 26px;
  border-radius: 99px;
  background: oklch(0.1 0 0);
}

.tr-08__screen {
  border-radius: 36px;
  background: var(--tr-08-screen);
  overflow: hidden;
  padding: 52px 0 22px;
  display: grid;
  gap: 16px;
}

.tr-08__top {
  padding-inline: 24px;
  display: grid;
  gap: 5px;
}

.tr-08__eyebrow {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--tr-08-acc);
}

.tr-08__title {
  font-size: 26px;
  font-weight: 680;
  letter-spacing: -.035em;
}

.tr-08__deck {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding: 26px 0;
  scrollbar-width: none;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}

.tr-08__deck::-webkit-scrollbar {
  display: none;
}

.tr-08__deck::before,
.tr-08__deck::after {
  content: '';
  flex: 0 0 calc(50% - 118px);
  min-width: 0;
}

.tr-08__deck:focus-visible {
  outline: 3px solid var(--tr-08-acc);
  outline-offset: -4px;
  border-radius: 20px;
}

.tr-08__card {
  flex: 0 0 236px;
  scroll-snap-align: center;
  display: grid;
  align-content: start;
  gap: 13px;
  padding: 22px 20px;
  border-radius: 22px;
  background: linear-gradient(165deg,oklch(0.99 0.01 var(--tr-08-h,20)),oklch(0.95 0.035 var(--tr-08-h,20)));
  border: 1px solid oklch(0 0 0/.07);
  box-shadow: 0 18px 36px -22px oklch(0 0 0/.5);
  transform-origin: 50% 60%;
}

@supports (animation-timeline: view()) {
  .tr-08__card {
    animation: tr-08-deck linear both;
    animation-timeline: view(inline);
    animation-range: entry 5% exit 95%;
  }

  @keyframes tr-08-deck {
    0% {
      scale: .82;
      rotate: 8deg;
      opacity: .3;
    }

    50% {
      scale: 1;
      rotate: 0deg;
      opacity: 1;
    }

    100% {
      scale: .82;
      rotate: -8deg;
      opacity: .3;
    }
  }
}

.tr-08__stars {
  font-size: 14px;
  letter-spacing: 2px;
  color: var(--tr-08-star);
}

.tr-08__stars span {
  color: oklch(0 0 0/.15);
}

.tr-08__card blockquote {
  font-size: 15px;
  line-height: 1.5;
  letter-spacing: -.015em;
  text-wrap: pretty;
}

.tr-08__card figcaption {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-top: 13px;
  border-top: 1px solid oklch(0 0 0/.08);
}

.tr-08__card figcaption img {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  object-fit: cover;
  flex: none;
  background: oklch(0.88 0.03 30);
}

.tr-08__card figcaption span {
  display: grid;
  font-size: 11.5px;
  line-height: 1.35;
  color: var(--tr-08-mut);
}

.tr-08__card figcaption b {
  font-size: 13.5px;
  font-weight: 650;
  color: var(--tr-08-ink);
  letter-spacing: -.012em;
}

.tr-08__hint {
  text-align: center;
  font-size: 12px;
  color: var(--tr-08-mut);
}

.tr-08__hint kbd {
  display: inline-grid;
  place-items: center;
  min-width: 22px;
  height: 22px;
  padding: 0 5px;
  border-radius: 6px;
  border: 1px solid oklch(0 0 0/.14);
  border-block-end-width: 2px;
  background: oklch(1 0 0);
  font: inherit;
  font-size: 11px;
}

.tr-08__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--tr-08-mut);
  padding: 8px 15px;
  border: 1px solid oklch(0 0 0/.13);
  border-radius: 99px;
  text-align: center;
  background: oklch(1 0 0/.5);
}

@media (prefers-reduced-motion: reduce) {
  .tr-08__card {
    animation: none;
  }
}
```

How this works

The deck is a snap scroller where each card animates against its own position in the scrollport:

.card {
  animation: deck linear both;
  animation-timeline: view(inline);   /* this card's inline visibility */
  animation-range: entry 10% exit 90%;
}
@keyframes deck {
  0%   { scale: .84; rotate: 7deg;  opacity: .35; }
  50%  { scale: 1;   rotate: 0deg;  opacity: 1;   }
  100% { scale: .84; rotate: -7deg; opacity: .35; }
}

Because the timeline is the scroll offset, there is no inertia to simulate, no touchmove handler and no dropped frames — the animation runs off the main thread. Drag halfway and the card sits halfway through its keyframes, which is what makes it feel like a physical deck rather than a slideshow.

.deck { scroll-snap-type: x mandatory; scroll-padding-inline: 50%; }
.card { scroll-snap-align: center; scroll-snap-stop: always; }

Everything degrades cleanly: without scroll-driven animation support you still get a perfectly good snap carousel with static cards, because the transform only ever comes from the animation. That is the correct progressive-enhancement shape — the enhancement adds motion, never layout.

The phone shell is decorative and aria-hidden; the deck itself is a labelled scroll region with tabindex="0" so keyboard users can arrow through the cards, and each card is a real <figure>.

Make it yours

  • Tune the drama with one keyframe: raise rotate to 12deg for a playful shuffle, drop to 2deg with a translateY lift for something closer to enterprise.
  • Add depth with z-index bound to the same timeline — or simply let the centred card win via scale, which is cheaper and reads the same.
  • Vertical deck: change view(inline) to view(block) and the scroll axis to y. Nothing else changes.
  • Pair with demo 04's ::scroll-marker dots for a progress indicator that costs zero extra script.

Gotchas — read before shipping

  • Scroll-driven animations need a scrollable ancestor on the right axis. Inside a fixed-size playground, make the deck its own overflow-x: auto container — as here — or the timeline never resolves.
  • animation-timeline requires animation-duration to be effectively ignored, but some engines still need animation-fill-mode: both for the range ends to hold. Always ship both.
  • Rotated cards overflow their container. Give the deck vertical padding equal to the largest rotated corner or the tops will clip.
  • Do not combine scroll-padding-inline: 50% with centring spacer pseudo-elements — the deck then centres twice, every snap point lands half a card away from the finger, and swiping feels like it is fighting you. Pick one centring mechanism (spacers are the more predictable of the two).
  • Never lock the axis with touch-action: pan-x on a horizontal scroller. The browser's default already allows the horizontal swipe; pan-x adds nothing and silently blocks vertical page scrolling for any touch that starts on the deck — the user puts a finger on a card, drags up, and the page refuses to move.
  • scroll-snap-stop: always is right for a one-slide-at-a-time carousel and wrong for a deck: it blocks the multi-card flick users expect from a stack of cards, which reads as stickiness rather than control.
  • Never simulate swipe with pointermove and transforms when a scroller will do — you lose momentum, accessibility, keyboard support and the browser's own overscroll behaviour.

Browser support

ChromeSafariFirefoxEdge
115+26+144+115+

animation-timeline: view() ships in Chrome/Edge 115+, Safari 26+ and Firefox 144+. Everywhere else the deck falls back to a plain scroll-snap carousel — same markup, same content, no motion. Scroll snap itself is universal.

Techniques used in this demo

Search CodeFronts

Loading…