25 CSS Blockquotes23 / 25

CSS + 18-line JSMIT licensed

JavaScript Testimonial Quote Slider CSS

A testimonial slider where CSS does the sliding and JS only advances the clock: the track is a scroll-snap container (overflow-x + scroll-snap-type: x mandatory), so swipe, trackpad, keyboard and scrollbar all work natively with momentum and rubber-banding no JS carousel reproduces. The dots are real anchor-free buttons wired by an 18-line IIFE that also autoplays — pausing on hover, on focus within, and permanently for prefers-reduced-motion users. Each slide is a full figure/blockquote card; the region carries the carousel ARIA pattern.

Published Updated

Live Demo
Try it

The code

<section class="bq-23" aria-label="Testimonial quote slider demo">
  <div class="bq-23__stage">
    <div class="bq-23__slider" role="region" aria-roledescription="carousel" aria-label="Customer testimonials" id="bq23-slider">
      <div class="bq-23__track" tabindex="0" aria-label="Testimonials, scroll or use dots">
        <figure class="bq-23__slide" role="group" aria-label="1 of 3">
          <blockquote><p>“We replaced a 2,000-line carousel plugin with scroll-snap and a dozen lines of glue. Support tickets about the slider: zero since.”</p></blockquote>
          <figcaption><strong>Imogen Hart</strong> · Frontend Lead, Lumen Retail</figcaption>
        </figure>
        <figure class="bq-23__slide" role="group" aria-label="2 of 3">
          <blockquote><p>“The native swipe physics are the feature. No JS carousel ever matched the trackpad feel our users kept complaining about.”</p></blockquote>
          <figcaption><strong>Dario Fontaine</strong> · Product Engineer, Atlas Bank</figcaption>
        </figure>
        <figure class="bq-23__slide" role="group" aria-label="3 of 3">
          <blockquote><p>“Autoplay that actually pauses for hover, focus and reduced motion — our accessibility audit finally has an empty carousel section.”</p></blockquote>
          <figcaption><strong>Sofia Meloni</strong> · Design Systems, Fernwood</figcaption>
        </figure>
      </div>
      <div class="bq-23__dots" role="tablist" aria-label="Choose testimonial">
        <button type="button" class="bq-23__dot" aria-label="Show testimonial 1" aria-current="true"></button>
        <button type="button" class="bq-23__dot" aria-label="Show testimonial 2"></button>
        <button type="button" class="bq-23__dot" aria-label="Show testimonial 3"></button>
      </div>
    </div>
    <p class="bq-23__chip" aria-hidden="true">scroll-snap-type:x mandatory · JS only advances the clock · WCAG 2.2.2 pauses</p>
  </div>
</section>
.bq-23,
.bq-23 *,
.bq-23 *::before,
.bq-23 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.bq-23 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --bg: oklch(0.955 0.01 285);
  --card: oklch(1 0 0);
  --ink: oklch(0.26 0.02 290);
  --mut: oklch(0.52 0.02 290);
  --acc: oklch(0.55 0.18 295);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--ink);
}

.bq-23__stage {
  width: 100%;
  height: 100vh;
  height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 24px;
  padding: clamp(20px,5vw,56px);
  background: radial-gradient(70% 55% at 50% 100%,oklch(0.9 0.04 295/.6),transparent),var(--bg);
  overflow-y: auto;
}

.bq-23__slider {
  width: min(100%,560px);
}

.bq-23__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  gap: 18px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  padding: 6px;
  border-radius: 24px;
}

.bq-23__track::-webkit-scrollbar {
  display: none;
}

.bq-23__track:focus-visible {
  outline: 2px solid var(--acc);
  outline-offset: 3px;
}

.bq-23__slide {
  scroll-snap-align: center;
  background: var(--card);
  border-radius: 20px;
  border: 1px solid oklch(0 0 0/.07);
  box-shadow: 0 3px 8px oklch(0.3 0.04 290/.07),0 18px 40px oklch(0.3 0.04 290/.1);
  padding: clamp(24px,4vw,34px);
  display: grid;
  align-content: center;
  gap: 18px;
  min-height: 230px;
}

.bq-23__slide blockquote p {
  font-size: clamp(16.5px,2.2vw,20px);
  line-height: 1.6;
  font-weight: 550;
  letter-spacing: -.008em;
  text-wrap: pretty;
}

.bq-23__slide figcaption {
  font-size: 13px;
  color: var(--mut);
}

.bq-23__slide figcaption strong {
  color: var(--acc);
  font-weight: 700;
}

.bq-23__dots {
  display: flex;
  justify-content: center;
  gap: 6px;
  margin-top: 16px;
}

.bq-23__dot {
  width: 28px;
  height: 28px;
  border: 0;
  background: none;
  border-radius: 99px;
  cursor: pointer;
  display: grid;
  place-items: center;
}

.bq-23__dot::before {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 99px;
  background: oklch(0.75 0.03 290);
  transition: width .3s cubic-bezier(.2,.7,.2,1),background .3s;
}

.bq-23__dot[aria-current="true"]::before {
  width: 26px;
  background: var(--acc);
}

.bq-23__dot:hover::before {
  background: var(--acc);
}

.bq-23__dot:focus-visible {
  outline: 2px solid var(--acc);
  outline-offset: 2px;
}

.bq-23__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--mut);
  padding: 7px 13px;
  border: 1px solid oklch(0 0 0/.1);
  border-radius: 99px;
  background: var(--card);
}

@media (prefers-reduced-motion: reduce) {
  .bq-23 * {
    transition-duration: .01ms !important;
  }

  .bq-23__track {
    scroll-behavior: auto;
  }
}
(() => {
  const root = document.getElementById('bq23-slider');
  if (!root || root.dataset.wired) return;
  root.dataset.wired = '1';
  const track = root.querySelector('.bq-23__track');
  const dots = [...root.querySelectorAll('.bq-23__dot')];
  const n = dots.length;
  let i = 0, raf = 0;
  const go = (k) => track.scrollTo({ left: track.clientWidth * ((k + n) % n), behavior: 'smooth' });
  dots.forEach((d, k) => d.addEventListener('click', () => go(k)));
  track.addEventListener('scroll', () => {
    cancelAnimationFrame(raf);
    raf = requestAnimationFrame(() => {
      i = Math.round(track.scrollLeft / track.clientWidth);
      dots.forEach((d, k) => k === i ? d.setAttribute('aria-current', 'true') : d.removeAttribute('aria-current'));
    });
  });
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  setInterval(() => { if (!root.matches(':hover, :focus-within')) go(i + 1); }, 5000);
})();
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 Blockquote 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: JavaScript Testimonial Quote Slider CSS
Source: https://codefronts.com/snippets/css-blockquotes/testimonial-carousel/

A testimonial slider where CSS does the sliding and JS only advances the clock: the track is a scroll-snap container (overflow-x + scroll-snap-type: x mandatory), so swipe, trackpad, keyboard and scrollbar all work natively with momentum and rubber-banding no JS carousel reproduces. The dots are real anchor-free buttons wired by an 18-line IIFE that also autoplays — pausing on hover, on focus within, and permanently for prefers-reduced-motion users. Each slide is a full figure/blockquote card; the region carries the carousel ARIA pattern.
## HTML
```html
<section class="bq-23" aria-label="Testimonial quote slider demo">
  <div class="bq-23__stage">
    <div class="bq-23__slider" role="region" aria-roledescription="carousel" aria-label="Customer testimonials" id="bq23-slider">
      <div class="bq-23__track" tabindex="0" aria-label="Testimonials, scroll or use dots">
        <figure class="bq-23__slide" role="group" aria-label="1 of 3">
          <blockquote><p>“We replaced a 2,000-line carousel plugin with scroll-snap and a dozen lines of glue. Support tickets about the slider: zero since.”</p></blockquote>
          <figcaption><strong>Imogen Hart</strong> · Frontend Lead, Lumen Retail</figcaption>
        </figure>
        <figure class="bq-23__slide" role="group" aria-label="2 of 3">
          <blockquote><p>“The native swipe physics are the feature. No JS carousel ever matched the trackpad feel our users kept complaining about.”</p></blockquote>
          <figcaption><strong>Dario Fontaine</strong> · Product Engineer, Atlas Bank</figcaption>
        </figure>
        <figure class="bq-23__slide" role="group" aria-label="3 of 3">
          <blockquote><p>“Autoplay that actually pauses for hover, focus and reduced motion — our accessibility audit finally has an empty carousel section.”</p></blockquote>
          <figcaption><strong>Sofia Meloni</strong> · Design Systems, Fernwood</figcaption>
        </figure>
      </div>
      <div class="bq-23__dots" role="tablist" aria-label="Choose testimonial">
        <button type="button" class="bq-23__dot" aria-label="Show testimonial 1" aria-current="true"></button>
        <button type="button" class="bq-23__dot" aria-label="Show testimonial 2"></button>
        <button type="button" class="bq-23__dot" aria-label="Show testimonial 3"></button>
      </div>
    </div>
    <p class="bq-23__chip" aria-hidden="true">scroll-snap-type:x mandatory · JS only advances the clock · WCAG 2.2.2 pauses</p>
  </div>
</section>
```
## CSS
```css
.bq-23,
.bq-23 *,
.bq-23 *::before,
.bq-23 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.bq-23 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --bg: oklch(0.955 0.01 285);
  --card: oklch(1 0 0);
  --ink: oklch(0.26 0.02 290);
  --mut: oklch(0.52 0.02 290);
  --acc: oklch(0.55 0.18 295);
  font-family: 'Segoe UI',system-ui,sans-serif;
  color: var(--ink);
}

.bq-23__stage {
  width: 100%;
  height: 100vh;
  height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 24px;
  padding: clamp(20px,5vw,56px);
  background: radial-gradient(70% 55% at 50% 100%,oklch(0.9 0.04 295/.6),transparent),var(--bg);
  overflow-y: auto;
}

.bq-23__slider {
  width: min(100%,560px);
}

.bq-23__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  gap: 18px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
  padding: 6px;
  border-radius: 24px;
}

.bq-23__track::-webkit-scrollbar {
  display: none;
}

.bq-23__track:focus-visible {
  outline: 2px solid var(--acc);
  outline-offset: 3px;
}

.bq-23__slide {
  scroll-snap-align: center;
  background: var(--card);
  border-radius: 20px;
  border: 1px solid oklch(0 0 0/.07);
  box-shadow: 0 3px 8px oklch(0.3 0.04 290/.07),0 18px 40px oklch(0.3 0.04 290/.1);
  padding: clamp(24px,4vw,34px);
  display: grid;
  align-content: center;
  gap: 18px;
  min-height: 230px;
}

.bq-23__slide blockquote p {
  font-size: clamp(16.5px,2.2vw,20px);
  line-height: 1.6;
  font-weight: 550;
  letter-spacing: -.008em;
  text-wrap: pretty;
}

.bq-23__slide figcaption {
  font-size: 13px;
  color: var(--mut);
}

.bq-23__slide figcaption strong {
  color: var(--acc);
  font-weight: 700;
}

.bq-23__dots {
  display: flex;
  justify-content: center;
  gap: 6px;
  margin-top: 16px;
}

.bq-23__dot {
  width: 28px;
  height: 28px;
  border: 0;
  background: none;
  border-radius: 99px;
  cursor: pointer;
  display: grid;
  place-items: center;
}

.bq-23__dot::before {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 99px;
  background: oklch(0.75 0.03 290);
  transition: width .3s cubic-bezier(.2,.7,.2,1),background .3s;
}

.bq-23__dot[aria-current="true"]::before {
  width: 26px;
  background: var(--acc);
}

.bq-23__dot:hover::before {
  background: var(--acc);
}

.bq-23__dot:focus-visible {
  outline: 2px solid var(--acc);
  outline-offset: 2px;
}

.bq-23__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--mut);
  padding: 7px 13px;
  border: 1px solid oklch(0 0 0/.1);
  border-radius: 99px;
  background: var(--card);
}

@media (prefers-reduced-motion: reduce) {
  .bq-23 * {
    transition-duration: .01ms !important;
  }

  .bq-23__track {
    scroll-behavior: auto;
  }
}
```

## JavaScript
```js
(() => {
  const root = document.getElementById('bq23-slider');
  if (!root || root.dataset.wired) return;
  root.dataset.wired = '1';
  const track = root.querySelector('.bq-23__track');
  const dots = [...root.querySelectorAll('.bq-23__dot')];
  const n = dots.length;
  let i = 0, raf = 0;
  const go = (k) => track.scrollTo({ left: track.clientWidth * ((k + n) % n), behavior: 'smooth' });
  dots.forEach((d, k) => d.addEventListener('click', () => go(k)));
  track.addEventListener('scroll', () => {
    cancelAnimationFrame(raf);
    raf = requestAnimationFrame(() => {
      i = Math.round(track.scrollLeft / track.clientWidth);
      dots.forEach((d, k) => k === i ? d.setAttribute('aria-current', 'true') : d.removeAttribute('aria-current'));
    });
  });
  if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
  setInterval(() => { if (!root.matches(':hover, :focus-within')) go(i + 1); }, 5000);
})();
```

How this works

The layout is a one-rule carousel — every input method works before a byte of JS loads:

.track{
  display:grid; grid-auto-flow:column;
  grid-auto-columns:100%;
  overflow-x:auto;
  scroll-snap-type:x mandatory;
  scrollbar-width:none;
}
.track > *{ scroll-snap-align:center; }

That's the whole slider: a horizontal grid where each column is the full width, scrolling with mandatory snap. Users can already swipe it, arrow-key it (the track is focusable), and momentum-flick it. The JS layer adds exactly two conveniences: dot navigation (scrollTo with behavior:'smooth') and an autoplay interval — and because scrolling is the source of truth, a scroll listener (throttled by requestAnimationFrame) keeps the dots honest even when the user swipes manually.

The accessibility contract for carousels is specific and this demo ships it: the container is role='region' with aria-roledescription='carousel' and a label; each slide is role='group' with aria-label='1 of 3'; the active dot carries aria-current='true'; and autoplay respects the three pause conditions — hover, focus-within, reduced-motion — that separate a slider from an accessibility complaint. Smooth programmatic scrolling itself collapses to an instant jump under reduced motion because the CSS sets scroll-behavior:auto there.

Make it yours

  • Peek layout: grid-auto-columns:85% with scroll-snap-align:center shows a sliver of the neighbors — the strongest 'there's more' affordance a slider can have.
  • Autoplay tempo: 5000ms here; anything under 4s fails usability for slow readers. Or delete the setInterval line — everything else still works.
  • Arrows: add prev/next buttons calling the same go() with index ±1 — the dots code already handles wrapping.
  • Vertical testimonial wall: scroll-snap-type:y mandatory + grid-auto-flow:row turns the same CSS into a snapping vertical feed.

Gotchas — read before shipping

  • scroll-snap-type MANDATORY on content taller than the viewport can trap scrolling — it's right here because slides are uniform; use proximity when heights vary.
  • Never transform-animate a snap container's children — snap positions are computed from layout, and transforms desynchronize them; scroll the container, period.
  • Autoplay without pause-on-hover/focus is a WCAG 2.2.2 failure — the pause conditions are not polish, they're the requirement.
  • iOS needs -webkit-overflow-scrolling to be left alone (it's default now) but hides scrollbars differently — scrollbar-width:none plus ::-webkit-scrollbar{display:none} covers both engines.

Browser support

ChromeSafariFirefoxEdge
114+17.5+121+114+

Floor set by oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

Scroll snap is Chrome 69 / Safari 11 / Firefox 68 — universal. The JS is optional enhancement; without it the slider still swipes and snaps. oklch cosmetics as usual.

Search CodeFronts

Loading…