Design with tokens
Every colour in this app is a CSS custom property, so one switch changes the whole theme.
Open Dark ModeSlides from SQL Server with autoplay, dots, arrows, swipe and keyboard, using CSS scroll-snap.
slideCREATE 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
);
<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>
.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; } }
@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; }
}
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(); }
}