Feature 323 Content

Carousel สไลด์

Slides from SQL Server with autoplay, dots, arrows, swipe and keyboard, using CSS scroll-snap.

Live demo

Everything here talks to the real Spring Boot + SQL Server back end.
1current slide
playingautoplay
5active rows in slide

What to notice

  • scroll-snap does the sliding; JavaScript only scrolls to a slide.
  • Autoplay pauses on hover, on focus and when the tab is hidden.
  • prefers-reduced-motion turns autoplay off.

Try it

Source code

5 parts, read from the files in this project
src/main/resources/db/migration/V1__schema.sql slide table
CREATE TABLE slide (
    id         INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_slide PRIMARY KEY,
    title      NVARCHAR(120) NOT NULL,
    caption    NVARCHAR(300) NOT NULL,
    art_seed   INT           NOT NULL,
    cta_label  NVARCHAR(40)  NULL,
    cta_href   NVARCHAR(200) NULL,
    sort_order INT           NOT NULL,
    active     BIT           NOT NULL CONSTRAINT df_slide_active DEFAULT 1
);
src/main/resources/templates/demos/l323.html Carousel markup: a scrolling track of slides from the slide table
<div class="carousel" data-carousel data-autoplay="5000" aria-roledescription="carousel" aria-label="Lesson highlights" tabindex="0">
  <div class="carousel-progress"></div>
  <button class="btn btn-sm btn-light carousel-play" type="button" aria-label="Pause autoplay">
    <svg class="i"><use href="#i-pause"/></svg><span>Pause</span></button>
  <div class="carousel-track" aria-live="off">
    <article class="slide" th:each="s, st : ${slides}" aria-roledescription="slide" th:attr="aria-label=${st.count + ' of ' + st.size}">
      <img th:src="@{/art/{seed}.svg(seed=${s.artSeed},w=1400,h=700)}" alt="" width="1400" height="700">
      <div class="slide-caption">
        <h3 th:text="${s.title}">Title</h3>
        <p th:text="${s.caption}">Caption</p>
        <a th:if="${s.ctaHref}" class="btn btn-sm btn-light" th:href="${s.ctaHref}" th:text="${s.ctaLabel}">Open</a>
      </div>
    </article>
  </div>
  <button class="carousel-btn prev" type="button" aria-label="Previous slide"><svg class="i"><use href="#i-chevron-left"/></svg></button>
  <button class="carousel-btn next" type="button" aria-label="Next slide"><svg class="i"><use href="#i-chevron-right"/></svg></button>
  <div class="carousel-dots" role="group" aria-label="Choose slide"></div>
</div>
src/main/resources/static/css/app.css Carousel: CSS scroll-snap does the sliding
.carousel { position: relative; border-radius: var(--radius-lg); overflow: hidden; background: #0f172a; outline-offset: 3px; }
.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; scrollbar-width: none; overscroll-behavior-x: contain; }
.carousel-track::-webkit-scrollbar { display: none; }
.slide { flex: 0 0 100%; min-width: 0; scroll-snap-align: start; position: relative; aspect-ratio: 16 / 8; min-height: 15rem; }
.carousel-compact .slide { aspect-ratio: 16 / 10; }
.slide img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.slide-caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 3.5rem 1.5rem 2.6rem; color: #fff; background: linear-gradient(transparent, rgb(2 6 23 / .78)); }
.slide-caption h3 { font-size: 1.35rem; margin-bottom: .3rem; }
.slide-caption p { color: rgb(255 255 255 / .85); font-size: .9rem; max-width: 34rem; }
.carousel-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 2.6rem; height: 2.6rem; border-radius: 50%; border: 0; display: grid; place-items: center; cursor: pointer; background: rgb(255 255 255 / .9); color: #0f172a; box-shadow: var(--shadow); opacity: 0; transition: opacity .2s; }
.carousel:hover .carousel-btn, .carousel:focus-within .carousel-btn { opacity: 1; }
.carousel-btn.prev { left: .9rem; } .carousel-btn.next { right: .9rem; }
.carousel-dots { position: absolute; bottom: .9rem; left: 0; right: 0; display: flex; justify-content: center; gap: .45rem; }
.carousel-dots button { width: .55rem; height: .55rem; border-radius: 999px; border: 0; padding: 0; background: rgb(255 255 255 / .5); cursor: pointer; transition: width .3s var(--ease), background .3s; }
.carousel-dots button[aria-current="true"] { width: 1.6rem; background: #fff; }
.carousel-progress { position: absolute; top: 0; left: 0; height: 3px; background: var(--primary); width: 0; z-index: 2; }
.carousel-play { position: absolute; top: .8rem; right: .8rem; z-index: 2; }
@media (hover: none) { .carousel-btn { opacity: 1; width: 2.2rem; height: 2.2rem; } }
src/main/resources/static/css/app.css Respect "reduce motion": no animation, no smooth scrolling
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
  .spinner, .btn.is-loading::after { animation: spin 1.5s linear infinite !important; animation-duration: 1.5s !important; animation-iteration-count: infinite !important; }
}
src/main/resources/static/js/demos.js Carousel: scroll to a slide, track the current one, autoplay with pause
function initCarousel(root) {
  const track = $('.carousel-track', root), slides = $$('.slide', track), dots = $('.carousel-dots', root);
  const delay = Number(root.dataset.autoplay || 0), bar = $('.carousel-progress', root), playBtn = $('.carousel-play', root);
  let index = 0, timer = null, paused = UI.reducedMotion(), hover = false, started = 0;
  dots.innerHTML = slides.map((_, i) => `<button type="button" aria-label="Slide ${i + 1}"></button>`).join('');
  const dotBtns = $$('button', dots);
  function go(i, focusSlide = false) {
    index = (i + slides.length) % slides.length;
    track.scrollTo({ left: slides[index].offsetLeft, behavior: UI.reducedMotion() ? 'auto' : 'smooth' });
    if (focusSlide) slides[index].focus?.();
    restart();
  }
  function mark() {
    dotBtns.forEach((d, i) => d.setAttribute('aria-current', String(i === index)));
    slides.forEach((s, i) => s.setAttribute('aria-hidden', String(i !== index)));
    root.dispatchEvent(new CustomEvent('carousel:change', { detail: { index, paused } }));
  }
  // Which slide is visible? (works for swipes and trackpads too)
  const io = new IntersectionObserver((entries) => {
    entries.forEach((en) => { if (en.isIntersecting && en.intersectionRatio > 0.6) { index = slides.indexOf(en.target); mark(); } });
  }, { root: track, threshold: [0.6] });
  slides.forEach((s) => io.observe(s));
  function restart() {
    clearInterval(timer);
    if (!delay || paused || hover || document.hidden) { if (bar) bar.style.width = '0'; return; }
    started = performance.now();
    timer = setInterval(() => {
      const t = (performance.now() - started) / delay;
      if (bar) bar.style.width = Math.min(100, t * 100) + '%';
      if (t >= 1) go(index + 1);
    }, 50);
  }
  function setPaused(p) {
    paused = p;
    if (playBtn) playBtn.innerHTML = p ? `${icon('play')}<span>Play</span>` : `${icon('pause')}<span>Pause</span>`;
    playBtn?.setAttribute('aria-label', p ? 'Start autoplay' : 'Pause autoplay');
    restart(); mark();
  }
  $('.prev', root).addEventListener('click', () => go(index - 1));
  $('.next', root).addEventListener('click', () => go(index + 1));
  dotBtns.forEach((d, i) => d.addEventListener('click', () => go(i)));
  playBtn?.addEventListener('click', () => setPaused(!paused));
  root.addEventListener('keydown', (e) => {
    if (e.target.closest('a, input')) return;
    if (e.key === 'ArrowRight') { e.preventDefault(); go(index + 1); }
    if (e.key === 'ArrowLeft') { e.preventDefault(); go(index - 1); }
  });
  root.addEventListener('pointerenter', () => { hover = true; restart(); });
  root.addEventListener('pointerleave', () => { hover = false; restart(); });
  root.addEventListener('focusin', () => { hover = true; restart(); });
  root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget)) { hover = false; restart(); } });
  document.addEventListener('visibilitychange', restart);
  if (playBtn) setPaused(paused); else { mark(); restart(); }
}

Are you sure?

Rename

↑↓ moveEnter openEsc close