GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Image Slider — 9 Responsive Sliders, No GSAP

A css image slider lines up several photo slots in a row and shows them one at a time with arrows, dots, or a finger — No GSAP, just an index number in

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

Two axes set the order: 01 through 04 move toward simpler screen-building values (push → follow → rotate → stack), 05 through 07 widen the input from arrows to the wheel, a timer, and a finger, and 08 and 09 change where the slider itself lives (a dialog window, the viewport width). All nine run on one number: the index. The neighbors on the far side of that axis are CSS Slider Carousel: No JavaScript, which solves the same screen with scroll position, and 9 CSS-Only Carousel Patterns, which spin with zero lines of JavaScript.

01Full-bleed hero slider

Four photo slots sit in one row on a track that slides past the window one photo at a time — the landing-page hero panel where the arrows, the dots, and the keyboard all watch a single index.

Full-bleed hero slider
Open in a new tab

01 · Full-bleed hero slider — Measured area 72.751% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

translate3daria-roledescriptiontransition

The move function that only changes the index

function go(n) {
  index = (n + total) % total;
  track.style.setProperty('--i', index);
  for (var k = 0; k < total; k++) {
    slides[k].setAttribute('aria-hidden', k === index ? 'false' : 'true');
    dots[k].setAttribute('aria-current', k === index ? 'true' : 'false');
  }
}

Press the next arrow and the index goes from 0 to 1 while the track travels exactly one slot, from matrix(1, 0, 0, 1, 0, 0) to matrix(1, 0, 0, 1, -420, 0) — press the fourth dot and it jumps three slots at once and stands at -1260.

The track that delegates distance to calc

.hero__track {
  display: flex;
  width: 100%;
  height: 100%;
  transform: translate3d(calc(-100% * var(--i, 0)), 0, 0);
  transition: transform $duration $easing;
  will-change: transform;
}

.hero__slide { position: relative; flex: 0 0 100%; height: 100%; }

Each slide claims flex: 0 0 100% of the window, so when the viewport width changes the multiplication re-reads the new slide width on its own — in the measurement this one moved in 20 of 23 frames (24 captured frames compared in adjacent pairs) with 72.751% cumulative moved area, the second widest of the nine.

02Slider synced with a thumbnail strip

Step the big photo and the thumbnail strip below draws the active cell toward the center; press a thumbnail and the big photo jumps to that slide — a two-way panel for a product detail gallery.

Slider synced with a thumbnail strip
Open in a new tab

02 · Slider synced with a thumbnail strip — Measured area 62.812% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollIntoViewaria-selectedtranslateX

The thumbnail sync that runs both ways

function go(n) {
  index = (n + total) % total;
  track.style.setProperty('--i', index);
  for (var k = 0; k < total; k++) {
    slides[k].setAttribute('aria-hidden', k === index ? 'false' : 'true');
    thumbs[k].setAttribute('aria-selected', k === index ? 'true' : 'false');
  }
  // main → thumbnail direction. block:'nearest' keeps the row moving, not the page
  thumbs[index].scrollIntoView({ block: 'nearest', inline: 'center', behavior: soft ? 'smooth' : 'auto' });
}

Press the fifth thumbnail and the big photo's index goes from 0 to 4 while the track pushes out to -1680 and the strip's scrollLeft follows from 0 to 84; step one slot with the arrows instead and the strip's scrollLeft stays at 0.

The one line that stops the stage column from locking

// The stage's auto column opens to the content's max-content — the thumbnail strip (496px) became
// the column width itself and even a 480px window grew a horizontal scrollbar (measured 2026-09-14, demo.py overflow). Pin the column to minmax(0,1fr)
.stage { grid-template-columns: minmax(0, 1fr); }

// ── thumbnail strip. this is the only horizontal scroll, and the pusher is scrollIntoView
.gal__strip {
  display: flex;
  gap: $sp-2;
  padding: $sp-1;
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
}

The strip is the only thing on screen that scrolls horizontally, and what pushes it is not a person but scrollIntoView — so the active cell drifts toward the center without anyone touching it (21 of 23 frames in motion, 62.812% cumulative area).

03Depth slider with the center card in front

The active cover faces the viewer head-on while both neighbors lean back — an album-cover picker whose stage is a 3D space with perspective, where every card carries its own angle and scale.

Depth slider with the centre card in front
Open in a new tab

03 · Depth slider with the centre card in front — Measured area 33.322% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

perspectiverotateYtransform-style

The function that counts distance from the active slide

function go(n) {
  index = (n + total) % total;
  for (var k = 0; k < total; k++) {
    var d = k - index;
    var far = Math.abs(d);
    cards[k].style.setProperty('--d', d);
    cards[k].style.setProperty('--ad', far);
    cards[k].style.zIndex = String(total - far);
    cards[k].style.pointerEvents = far > 2 ? 'none' : 'auto';
    cards[k].setAttribute('aria-current', d === 0 ? 'true' : 'false');
    dots[k].setAttribute('aria-current', d === 0 ? 'true' : 'false');
  }
}

It reads no scroll position, putting just two numbers on each card — the signed distance d and its absolute value --ad — so pressing the card two slots to the right moves the index from 3 to 1 while that card's transform re-seats itself in matrix3d (22 of 23 frames in motion, 33.322% cumulative area).

The perspective stage and the card transform

// ── the stage where depth happens. without this perspective, rotateY reads as a flat horizontal squish only
.cf__box {
  position: relative;
  width: 100%;
  height: clamp(96px, 46vh, 140px);
  perspective: 900px;
  perspective-origin: 50% 50%;
  overflow: hidden;
}
.cf__track {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

Each card's sidestep, rotation, and shrink all come out of calc() on those two numbers: translate3d multiplies #{$step}, rotateY bends each slot by -34deg, and the darkening with distance comes from an ink veil laid over the card rather than from opacity, so the orange stage never shines through and muddies it.

04Stacked crossfade slideshow

Four slides sit stacked in one cell and nothing moves but opacity, one shot fading up as the other fades down — an image slideshow panel for a section background whose track travels zero distance, so it can't grow a horizontal scrollbar on a narrow screen.

Stacked crossfade slideshow
Open in a new tab

04 · Stacked crossfade slideshow — Measured area 48.004% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

opacityaria-hiddentransition

The structure that piles everything into one grid cell

.xf__view {
  position: relative; display: grid;
  height: clamp(104px, 38vw, 172px);
  border-radius: $r-card; overflow: hidden;
  background: $subject-ink;
}
.xf__shot {
  grid-area: 1 / 1; position: relative;
  opacity: 0;
  transition: opacity $duration $easing;
}
.xf__shot.is-on { opacity: 1; }

All four shots live in the same grid cell 1 / 1, and the stage's transform stayed exactly none in the measurement — zero travel means no layout recalculation when the slide changes.

The function that swaps scenes on one opacity value

function show(next) {
  index = (next + shots.length) % shots.length;
  shots.forEach(function (shot, i) {
    var on = i === index;
    shot.classList.toggle('is-on', on);
    shot.setAttribute('aria-hidden', on ? 'false' : 'true');
  });
  dots.forEach(function (dot, i) {
    dot.setAttribute('aria-current', i === index ? 'true' : 'false');
  });
}

Press the next arrow and the lit slide's opacity array trades 0,1,0,0 for 0,0,1,0, pressing the first dot returns it to slide 0, and a shot that leaves the screen leaves the accessibility tree at the same moment (20 of 23 frames in motion, 48.004% cumulative area).

05Vertical slider that takes one wheel step at a time

The track moves only up and down, and one flick of the wheel advances exactly one slide — a tall photo-strip panel whose window is set to overflow hidden and is not a scroll container.

Vertical slider that takes one wheel step at a time
Open in a new tab

05 · Vertical slider that takes one wheel step at a time — Measured area 41.397% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYwheelaria-orientation

The wheel lock that accepts a single step

// During the lock window, every wheel event that follows is swallowed. Inertial scrolling fires
// dozens of them in a row after one flick, and without this gate a single flick runs to the end
view.addEventListener('wheel', function (e) {
  e.preventDefault();
  if (locked) { return; }
  locked = true;
  setTimeout(function () { locked = false; }, LOCK);
  stopPreview();
  show(index + (e.deltaY > 0 ? 1 : -1));
}, { passive: false });

The lock window is 420ms (LOCK), and in the measurement a second wheel event that arrived inside the lock was swallowed while the active index moved a single step, from 1 to 2; the window's overflowY stayed hidden, so the slider never mixes with the page scroll.

The track that only shakes vertically

.vw__track {
  --i: 0;
  display: flex; flex-direction: column; height: 100%;
  transform: translateY(calc(var(--i) * -100%));
  transition: transform $duration $easing;
  will-change: transform;
}
.vw__shot { position: relative; flex: 0 0 100%; }

Each slot takes flex: 0 0 100%, so a slot is exactly as tall as the stage and one translateY(-100%) is exactly one slide — measure that percentage against the whole four-slide run instead and all four fly past at once (14 of 23 frames in motion, 41.397% cumulative area).

06Autoplay slideshow with a progress bar and a pause button

A shrinking bar shows the time left, and one pause button stops the timer — an in-store display panel that holds each slide for 1200ms.

Autoplay slideshow with a progress bar and a pause button
Open in a new tab

06 · Autoplay slideshow with a progress bar and a pause button — Measured area 42.569% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXtransform-originvisibilitychange

The frame that draws remaining time as scaleX

function frame(now) {
  var passed = Math.min(1, (now - startAt) / HOLD);
  fill.style.setProperty('--p', (1 - passed).toFixed(3));
  if (passed >= 1) { startAt = now; show(index + 1); }
  raf = requestAnimationFrame(frame);
}
function play() {
  if (playing) { return; }
  playing = true;
  startAt = performance.now() - (HOLD - remain);
  root.classList.remove('is-paused');
  toggle.setAttribute('aria-label', PAUSE_LABEL);
  raf = requestAnimationFrame(frame);
}

The bar shrinks with scaleX alone rather than width, so no layout is redone per frame — though without transform-origin: left it drains from the center toward both edges — and 1.6 seconds after the pause button was pressed, the bar's matrix(0.139, 0, 0, 1, 0, 0) and the track's -400 position had not moved at all.

The timer that stops when the tab is left

// If slides turn by themselves while the tab is away, there is no telling where you are on return
document.addEventListener('visibilitychange', function () {
  if (document.hidden) { pause(); } else if (wanted) { play(); }
});
// When the system asks for less motion, autoplay never starts at all (WCAG 2.2.2)
if (wanted) { play(); } else {
  root.classList.add('is-paused');
  toggle.setAttribute('aria-label', PLAY_LABEL);
}

The pause button and visibilitychange tend the same timer, so no slide turns by itself while the tab is away, and under a reduced-motion setting autoplay never begins — the button label swaps between the two states, and the slider stays on the first slide (23 of 23 frames in motion, 42.569% cumulative area).

07Drag slider that decides the rest by release speed

While the finger is down the track follows it exactly, and the speed at the moment of release decides between the next slide and springing home — the mobile photo-swiping panel.

Drag slider that decides the rest by release speed
Open in a new tab

07 · Drag slider that decides the rest by release speed — Measured area 40.266% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointerdownsetPointerCapturetranslate3d

The finger position is the track position

function onDown(e) {
  stopDemo(); measure();
  dragging = true; startX = e.clientX; lastX = e.clientX; lastT = e.timeStamp; velocity = 0;
  track.setPointerCapture(e.pointerId);
}
function onMove(e) {
  if (!dragging) { return; }
  var dt = e.timeStamp - lastT;
  if (dt > 0) { velocity = (e.clientX - lastX) / dt; }
  lastX = e.clientX; lastT = e.timeStamp;
  speed.textContent = Math.abs(velocity).toFixed(2) + ' px/ms';
  place(e.clientX - startX, false);
}

During the drag the transition is off, so the finger coordinate is the track coordinate; setPointerCapture keeps the coordinates coming even after the finger leaves the cell, and touch-action: pan-y hands horizontal drags to the slider while vertical scrolling stays with the browser (measured touch_action value pan-y).

The verdict from release-moment speed

// Release-moment speed (px/ms) comes first; a slow long drag falls through to distance (28%) as the second test
function onUp(e) {
  if (!dragging) { return; }
  dragging = false;
  if (track.hasPointerCapture(e.pointerId)) { track.releasePointerCapture(e.pointerId); }
  var moved = e.clientX - startX;
  var carry = Math.abs(velocity) > 0.4 || Math.abs(moved) > width * 0.28;
  show(index + (carry && moved < 0 ? 1 : 0) - (carry && moved > 0 ? 1 : 0), true);
}

A fast swipe reaches the next slide from a 70px drag at 1.04 px/ms; a slow, short pull of 40px at 0.07 px/ms misses both tests and springs home; a slow, long drag of 200px is too slow for the speed test but still clears the distance test, so it settles on the next slide at matrix(1, 0, 0, 1, -640, 0) (19 of 23 frames in motion, 40.266% cumulative area).

08Lightbox slider opening from an album grid

A grid cell opens a dialog with showModal, and inside that window the photo becomes a slider again — an event album panel where you page through the rest of the shots without closing the window.

Lightbox slider opening from an album grid
Open in a new tab

08 · Lightbox slider opening from an album grid — Measured area 99.915% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

showModaldialoggrid-template-columns

The grid cell opens the window, and focus comes home

// A grid cell opens the dialog. The window stays put after opening — only the track moves, which is this item's axis
function open(at, from) {
  opener = from || cells[at];
  show(at);
  if (!lb.open) { lb.showModal(); }
}
function stopDemo() { ab.classList.remove('is-demo'); clearInterval(timer); }
cells.forEach(function (cell, i) {
  cell.addEventListener('click', function () { stopDemo(); open(i, cell); });
});

Press the third cell and the window opens with the counter starting at 3 / 6 and the track standing at -568; close it with Esc and focus returns to the pressed grid cell — the measurement watched it land back on the "Enlarge: a city alley after the rain" button.

The track that runs inside the window

function show(next) {
  index = (next + total) % total;
  lbtrack.style.transform = 'translateX(' + (-index * 100) + '%)';
  count.textContent = (index + 1) + ' / ' + total;
  [].slice.call(lbtrack.children).forEach(function (slide, i) {
    slide.setAttribute('aria-hidden', i === index ? 'false' : 'true');
  });
}

The window is a real dialog on the top layer, so it adds nothing to the stage's scroll height — the next button takes the counter to 4 / 6 and the arrow keys to 5 / 6 while the window stays open the whole time (18 of 23 frames in motion, 99.915% cumulative area — the widest of the nine).

09Slider that changes how many photos show by width

matchMedia reads the width and fixes how many slides fill a screen — 1, 2, or 3 — a related-products row panel whose photo slots are pinned by aspect-ratio, so the height is always computed from the width instead of from the image.

Slider that changes how many photos show by width
Open in a new tab

09 · Slider that changes how many photos show by width — Measured area 22.367% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

aspect-ratiomatchMediaflex-basis

The matchMedia that watches the width boundary, and the repaint

// Width is read by matchMedia. Unlike re-measuring on every resize, it fires once when a boundary is crossed
var wide = window.matchMedia('(min-width: 640px)');
var mid = window.matchMedia('(min-width: 420px)');
function maxIndex() { return Math.max(0, total - per); }
function paint() {
  root.style.setProperty('--per', per);
  chip.textContent = 'perView ' + per;
  index = Math.min(index, maxIndex());
  track.style.transform = 'translate3d(' + (-index * (100 / per)) + '%, 0, 0)';
  dots.forEach(function (dot, i) {
    dot.hidden = i > maxIndex();
    dot.setAttribute('aria-current', i === index ? 'true' : 'false');
  });
}
function measure() {
  per = wide.matches ? 3 : (mid.matches ? 2 : 1);
  paint();
}

The repaint sets --per and the chip text together, clamps the index inside the new maximum (which also thins the dot count), and only the two matchMedia queries re-read the width at the 640px and 420px boundaries — in the measurement flex-basis read 100% at a 320px viewport, 50% at 480px, and 33.3333% at 900px.

--per, the single source of width

// --per is the single source of width. The gap lives as inner padding on the slide, not as gap — the flex-basis math never wobbles
.rp__slide {
  box-sizing: border-box;
  flex-grow: 0; flex-shrink: 0;
  flex-basis: calc(100% / var(--per));
  padding-right: $sp-1;
}
.rp__ph {
  display: block; width: 100%; aspect-ratio: 16 / 9;
  border-radius: $r-sm;
}

Because one --per fixes the cell width, the one-step travel (100% / per) changes with it, and the photo slots hold aspect-ratio 16 / 9 at all three widths, so the measured track height followed the width in proportion (14 of 23 frames in motion, 22.367% cumulative area).

How much of a css image slider animation do you write yourself?

The answer first: up to the code that guards one state number. Unfold all nine scripts and you get an index wrapped in modulo, arrows, dots, keyboard, and pointer events wired to that number, plus three exceptions — the wheel lock in 05, the timer stop in 06, and the release-speed arithmetic in 07. That is the whole of it. Distance, color, and pairing are computed by CSS calc() and variables, so the image slider html structure ends at one window, one track, and a pair of control buttons. The line you should not cross is just as clear: hand-rolling inertia physics or rebuilding virtual scroll only breeds bugs, and 07 shows the honest ceiling — a speed threshold of 0.4 px/ms plus a distance threshold of 28% settles the verdict. Autoplay belongs with a pause button and tab-visibility detection, which is what WCAG 2.2.2 asks for. Open the downloaded folder and all nine sit there exactly as shown.

Where it breaks — the pitfalls

All three of these were hit for real while building the set.

Depth through translateZ leaks clicks to the track

The first cut of 03 pushed the receding cards back with translateZ. An element pushed away by translateZ inside a perspective space drops out of Chrome's hit testing — visible on screen, yet the press landed on the track, not the card, and elementFromPoint over the neighboring card answered .cf__track. Only after depth was rebuilt on rotateY and scale could a distant card be pressed and called to the center, and the interaction measurement then watched a click on the card two slots to the right move the index from 3 to 1.

The thumbnail strip widened the stage column on its own

The strip in 02 carries content wider than the screen, and the stage's auto column followed the content width, so the demo window grew a horizontal scrollbar. Pinning the column to minmax(0, 1fr) removed it — now the horizontal scroll lives only inside the strip (press the fifth thumbnail and the strip's scrollLeft moves from 0 to 84).

One wheel flick sent all four slides past at once

When 05 first ran, a single wheel flick sent dozens of wheel events in a row from inertial scrolling, and the unlocked build took every one of them as a step and ran to the end. After the 420ms gate went up, the measurement watched the second event inside the lock get swallowed and the track come down only one slide height, from matrix(1, 0, 0, 1, 0, -156) to -312.

The password for the nine demo zip files is 7up3thqw, and unpacking it lays out each item's original index.html and style.scss in full.

Three variations and accessibility

Swapping three values changes the impression of all nine at once.

name changed value feel
duration $dur-enter 520ms → 260ms a slide settles in half the time, so the next one is already standing the moment the finger leaves
easing $ease-out → $ease-spring one extra push-and-return on landing makes the photo feel like it has weight
color $subject-blue → $stage-orange active dots, the progress bar, and thumbnail borders all shift at once, turning the same layout warm

Accessibility comes from the document structure. All nine are named regions with aria-roledescription="carousel"; only the visible slide keeps aria-hidden="false", dots announce their place with aria-current, and thumbnails with aria-selected. Arrow keys move one slide in eight of the nine — 06, which runs on autoplay alone, is the exception, and 05 listens for the up and down keys — and in 08 the measurement confirmed focus returning to the pressed grid cell after Esc closes the window. Under prefers-reduced-motion: reduce the transition time drops to zero and 06 never starts autoplay — it stays on the first slide. Text contrast was measured from rendered pixels: cream on the blue panel #0a4fe8 at 5.98:1, the slide counter in 06 at 12.63:1 over #ffd23f, and the 05 hint line at 10.65:1 over #17141a — all 15 text samples across the nine screens passed AA.

Next door, Before/After Image Slider pushes a boundary line with the same pointer events, 3D Cube Carousel spends perspective at a different angle, and CSS Image Gallery Effects and Portfolio Gallery UI Patterns handle the photo slot from the grid side. The reference docs are MDN translate3d, MDN Pointer events, MDN dialog, the WAI carousel pattern, and MDN prefers-reduced-motion.

FAQ

Will the image slider html css code run if I paste all of it into another page

Yes. Each demo's script looks only at its own element ids and carries no outside dependencies, so copying the markup and the styles together starts it as is. To put several on one page, rename the ids per item so they don't collide.

How do I put real photos into the slots

The slots are scene gradients built from the token palette. Drop a photo URL into the background-image that holds the gradient, or turn the role="img" element into an <img> — the track's distance math runs on slide-width percentages, so nothing needs to change when the photos do.

On mobile, does dragging sideways drag the page too

No — the track in 07 carries touch-action: pan-y, so horizontal drags go to the slider and vertical scrolling stays with the browser (measured touch_action value pan-y). setPointerCapture holds the coordinates even after the finger leaves the cell, up to the moment of release, so the verdict is always made from that moment's speed.

Enter the archive password

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