GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

이미지 슬라이드 css 9종 — GSAP 없이 반응형까지

이미지 슬라이드 css 는 사진 자리 여러 장을 한 줄로 이어 붙여 화살표·점·손가락으로 한 장씩 보여 주는 화면 부품입니다. GSAP 없이 아홉 개를 직접 짰는데 자바스크립트는 인덱스 숫자 하나만 바꾸고 거리·색·짝 맞춤은 calc() 와 transition 에 맡겼고, 아래

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서의 축은 두 개입니다. 화면을 만드는 값이 단순해지는 쪽으로 01 부터 04 까지(밀기 → 따라오기 → 회전 → 겹침) 놓고, 입력이 화살표에서 휠·타이머·손가락으로 넓어지는 쪽으로 05 부터 07 까지 놓았으며, 08·09 는 슬라이더가 사는 자리(창 안, 화면 폭)를 바꾸는 묶음입니다. 아홉 개 모두 인덱스 하나를 구동축으로 삼아 돌아가고, 같은 화면을 스크롤 위치로 푸는 CSS 슬라이더 캐러셀과 자바스크립트 0줄로 도는 CSS 전용 캐러셀이 이 축의 반대쪽 이웃입니다.

01화면 가득 채우는 히어로 슬라이더

사진 자리 네 장이 한 줄로 이어 붙은 트랙이 창을 한 칸씩 비켜 가는 랜딩 첫 화면용 판으로, 화살표·점·키보드가 전부 같은 인덱스 하나를 봅니다.

화면 가득 채우는 히어로 슬라이더
새 창에서 열기

01 · 화면 가득 채우는 히어로 슬라이더 — 측정 누적면적 72.751% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translate3daria-roledescriptiontransition

인덱스 하나만 바꾸는 이동 함수

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');
  }
}

다음 화살표를 누르면 인덱스가 0에서 1로 바뀌고 트랙이 matrix(1, 0, 0, 1, 0, 0) 에서 matrix(1, 0, 0, 1, -420, 0) 로 정확히 한 칸을 가며, 네 번째 점을 누르면 세 칸을 한 번에 건너뛰어 -1260 자리에 섭니다.

거리 계산을 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%; }

칸마다 flex: 0 0 100% 로 창 폭을 꽉 채우므로 화면 폭이 바뀌어도 곱셈이 바뀐 장 폭을 그대로 다시 읽고, 측정에서는 움직인 프레임 20/23(찍은 24장을 앞뒤로 견준 23칸 기준) · 누적면적 72.751% 로 아홉 개 중 두 번째로 넓게 움직였습니다.

02썸네일 줄과 서로 맞추는 슬라이더

큰 사진을 넘기면 아래 썸네일 줄이 활성 칸을 가운데로 끌어오고 썸네일을 누르면 큰 사진이 그 장으로 가는, 상품 상세 사진 묶음에 붙이는 양방향 판입니다.

썸네일 줄과 서로 맞추는 슬라이더
새 창에서 열기

02 · 썸네일 줄과 서로 맞추는 슬라이더 — 측정 누적면적 62.812% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scrollIntoViewaria-selectedtranslateX

양방향으로 맞추는 썸네일 동기

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');
  }
  // 본문 → 썸네일 방향. block:'nearest' 라 줄만 움직이고 페이지는 그대로 있다
  thumbs[index].scrollIntoView({ block: 'nearest', inline: 'center', behavior: soft ? 'smooth' : 'auto' });
}

다섯 번째 썸네일을 누르면 큰 사진의 인덱스가 0에서 4로 바뀌고 트랙이 -1680 까지 밀리며 줄의 scrollLeft 도 0에서 84 로 따라 움직이고, 화살표로 한 칸만 넘겼을 때는 scrollLeft 가 0 그대로였습니다.

무대 열을 못 박는 한 줄

// 무대의 자동 열은 내용의 max-content 로 벌어진다 — 썸네일 줄(496px)이 그대로 열 너비가 되어
// 480px 창에도 가로 스크롤바가 생겼다 (실측 2026-09-14, demo.py 넘침). 열을 minmax(0,1fr) 로 못 박는다
.stage { grid-template-columns: minmax(0, 1fr); }

// ── 썸네일 줄. 여기만 가로로 스크롤되고, 미는 사람은 scrollIntoView 다
.gal__strip {
  display: flex;
  gap: $sp-2;
  padding: $sp-1;
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
}

화면에서 가로로 스크롤되는 곳은 이 줄 하나뿐이고 미는 주체도 사람이 아니라 scrollIntoView 라서 손을 대지 않아도 활성 칸이 줄 가운데 쪽으로 따라옵니다(움직인 프레임 21/23 · 누적면적 62.812%).

03가운데가 앞으로 나오는 깊이 슬라이더

활성 표지가 정면에 서고 양옆이 뒤로 눕는 앨범 표지 고르기용 판으로, 무대가 perspective 를 건 3D 공간이고 카드마다 각도와 크기가 붙습니다.

가운데가 앞으로 나오는 깊이 슬라이더
새 창에서 열기

03 · 가운데가 앞으로 나오는 깊이 슬라이더 — 측정 누적면적 33.322% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

perspectiverotateYtransform-style

활성 장에서의 거리를 세는 함수

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');
  }
}

스크롤 위치를 읽지 않고 거리 d 와 절댓값 --ad 두 숫자만 카드에 얹으므로, 오른쪽에서 두 칸 떨어진 카드를 누르면 인덱스가 3에서 1로 옮겨 가고 카드의 transform 이 matrix3d 로 갈아입습니다(움직인 프레임 22/23 · 누적면적 33.322%).

perspective 를 건 무대와 카드 변환

// ── 깊이가 생기는 무대. 여기 perspective 가 없으면 rotateY 는 납작한 가로 눌림으로만 보인다
.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;
}

카드의 옆걸음·회전·축소는 두 숫자의 calc() 로 정해지는데 translate3d 에 #{$step} 을 곱하고 rotateY 는 한 칸에 -34deg 씩이며, 멀어질수록 어두워지는 것은 투명도가 아니라 카드 위에 덮는 먹 장막이라 주황 무대가 비쳐 탁해지지 않습니다.

04겹쳐 두고 서로 녹는 페이드 슬라이드쇼

네 장이 한자리에 포개져 opacity 만 서로 반대로 움직이는 소개 섹션 배경용 이미지 슬라이드쇼 판으로, 트랙이 밀리는 거리가 0이라 좁은 화면에서 가로 스크롤이 생길 여지가 없습니다.

겹쳐 두고 서로 녹는 페이드 슬라이드쇼
새 창에서 열기

04 · 겹쳐 두고 서로 녹는 페이드 슬라이드쇼 — 측정 누적면적 48.004% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

opacityaria-hiddentransition

한 격자 칸에 포개는 구조

.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; }

네 장이 같은 격자 칸 1 / 1 에 전부 올라가 있어 무대의 transform 은 실측에서도 none 으로 그대로였고, 이동 거리가 0이라 장이 바뀌어도 레이아웃을 다시 계산하지 않습니다.

opacity 한 값으로 장면을 갈아끼우는 함수

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');
  });
}

다음 화살표를 누르면 켜진 장의 opacity 배열이 0,1,0,0 에서 0,0,1,0 로 자리를 바꾸고 첫 점을 누르면 0번 장으로 돌아오며, 화면에서 사라진 장은 접근성 트리에서도 함께 빠집니다(움직인 프레임 20/23 · 누적면적 48.004%).

05휠을 한 칸씩 받는 세로 슬라이더

트랙이 위아래로만 움직이고 휠 한 번에 정확히 한 장을 넘기는 세로로 긴 사진 모음용 판으로, 창은 overflow hidden 이라 스크롤 컨테이너가 아닙니다.

휠을 한 칸씩 받는 세로 슬라이더
새 창에서 열기

05 · 휠을 한 칸씩 받는 세로 슬라이더 — 측정 누적면적 41.397% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateYwheelaria-orientation

휠 잠금으로 한 칸만 받기

// 잠금 시간 동안 뒤따라 오는 wheel 이벤트를 삼킨다. 관성 스크롤은 한 번 굴려도
// 수십 개가 이어서 날아오므로, 이 문이 없으면 한 번에 끝까지 지나간다
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 });

잠금 시간은 420ms(LOCK)이고 실측에서도 잠금 안에 들어온 두 번째 휠이 삼켜져 활성 인덱스가 1에서 2로 한 칸만 움직였으며, 창의 overflowY 는 hidden 이라 페이지 스크롤과 섞이지 않습니다.

세로로만 흔들리는 트랙

.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%; }

칸마다 flex: 0 0 100% 라 트랙 높이가 무대 높이와 같아지므로 translateY(-100%) 한 번이 정확히 한 장이 되고, 트랙 전체 높이를 기준으로 잡으면 네 장이 한 번에 지나갑니다(움직인 프레임 14/23 · 누적면적 41.397%).

06진행 막대와 멈춤 단추가 있는 자동 슬라이드쇼

남은 시간을 줄어드는 막대가 보여 주고 멈춤 단추 하나로 타이머가 서는 매장 안내 화면용 판으로, 장마다 1200ms 동안 머뭅니다.

진행 막대와 멈춤 단추가 있는 자동 슬라이드쇼
새 창에서 열기

06 · 진행 막대와 멈춤 단추가 있는 자동 슬라이드쇼 — 측정 누적면적 42.569% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scaleXtransform-originvisibilitychange

남은 시간을 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);
}

막대는 폭이 아니라 scaleX 하나로 줄어들어 매 프레임 레이아웃을 다시 짜지 않는데 transform-origin: left 가 빠지면 가운데에서 양쪽으로 빨려 들어가며, 멈춤 단추를 누른 뒤 1.6초를 기다려도 막대 matrix(0.139, 0, 0, 1, 0, 0) 와 트랙 -400 자리가 그대로였습니다.

탭을 떠나면 서는 타이머

// 탭을 떠나 있는 동안 사진이 혼자 넘어가면 돌아왔을 때 어디였는지 알 수 없다
document.addEventListener('visibilitychange', function () {
  if (document.hidden) { pause(); } else if (wanted) { play(); }
});
// 움직임을 줄이라는 설정이면 자동재생을 아예 시작하지 않는다 (WCAG 2.2.2)
if (wanted) { play(); } else {
  root.classList.add('is-paused');
  toggle.setAttribute('aria-label', PLAY_LABEL);
}

멈춤 단추와 visibilitychange 가 같은 타이머를 세워 탭을 떠나 있는 동안 장이 혼자 넘어가지 않고, 단추 라벨은 '자동재생 멈춤'과 '자동재생 시작'을 오가며, 움직임을 줄이라는 설정에서는 자동재생을 시작하지 않아 첫 장에 머뭅니다(움직인 프레임 23/23 · 누적면적 42.569%).

07손을 뗀 속도로 남은 거리를 정하는 드래그 슬라이더

끄는 동안 트랙이 손가락을 그대로 따라오고 놓는 순간의 속도가 다음 장과 제자리를 가르는 모바일 사진 넘기기용 판입니다.

손을 뗀 속도로 남은 거리를 정하는 드래그 슬라이더
새 창에서 열기

07 · 손을 뗀 속도로 남은 거리를 정하는 드래그 슬라이더 — 측정 누적면적 40.266% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointerdownsetPointerCapturetranslate3d

손가락 위치가 곧 트랙 위치

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);
}

끌고 있는 동안에는 transition 을 꺼 두어 손가락 좌표가 곧 트랙 위치가 되고 setPointerCapture 로 손가락이 칸을 벗어나도 좌표를 붙잡으며, 트랙에 touch-action: pan-y 를 걸어 가로 끌기는 슬라이더가 받고 세로 스크롤은 브라우저에 남겼습니다(실측 touch_action 값 pan-y).

놓는 순간의 속도로 판정

// 놓는 순간의 속도(px/ms)가 먼저고, 느리게 끌었을 땐 이동 거리(28%)가 두 번째 기준이다
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);
}

빠른 스와이프는 70px 만 끌어도 속도 1.04 px/ms 로 다음 장으로 가고 느리고 짧은 끌기는 40px · 0.07 px/ms 로 두 기준에 못 미쳐 제자리로 돌아가며, 느리고 길게 200px 를 끌면 속도가 느려도 거리 기준을 넘겨 다음 장인 matrix(1, 0, 0, 1, -640, 0) 자리에 섭니다(움직인 프레임 19/23 · 누적면적 40.266%).

08앨범 격자에서 열리는 라이트박스 슬라이더

격자에서 고른 사진 자리가 showModal 로 열린 dialog 안에서 다시 슬라이더가 되는 행사 사진첩용 판으로, 창을 닫지 않고 좌우로 다음 장을 봅니다.

앨범 격자에서 열리는 라이트박스 슬라이더
새 창에서 열기

08 · 앨범 격자에서 열리는 라이트박스 슬라이더 — 측정 누적면적 99.915% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

showModaldialoggrid-template-columns

격자 칸이 창을 열고 초점은 돌아온다

// 격자 칸이 dialog 를 연다. 열린 뒤에도 창은 그대로 두고 트랙만 움직이는 것이 이 항목의 축이다
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); });
});

세 번째 칸을 누르면 창이 열리며 카운터가 3 / 6 으로 시작해 트랙이 -568 자리에 서고, Esc 로 창을 닫으면 초점이 눌렀던 '비 온 뒤 도심 골목 크게 보기' 칸으로 돌아오는 것을 실측으로 확인했습니다.

창 안에서 도는 트랙

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');
  });
}

창은 top layer 에 뜨는 진짜 dialog 라 무대의 스크롤 높이에 더해지지 않고, 다음 버튼으로 카운터가 4 / 6 으로, 화살표 키로 5 / 6 으로 가도 창은 그대로 열려 있습니다(움직인 프레임 18/23 · 누적면적 99.915% 로 아홉 개 중 가장 넓습니다).

09폭에 따라 보이는 장수가 바뀌는 슬라이더

matchMedia 가 폭을 읽어 한 화면에 보이는 장수를 1·2·3장으로 정하는 관련 상품 줄용 판으로, 사진 자리마다 aspect-ratio 가 묶여 있어 장수가 바뀌어도 높이가 튀지 않습니다.

폭에 따라 보이는 장수가 바뀌는 슬라이더
새 창에서 열기

09 · 폭에 따라 보이는 장수가 바뀌는 슬라이더 — 측정 누적면적 22.367% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

aspect-ratiomatchMediaflex-basis

폭 경계를 지켜보는 matchMedia 와 다시 칠하기

// 폭은 matchMedia 가 읽는다. resize 마다 재는 것과 달리 경계를 넘을 때만 한 번 불린다
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();
}

다시 칠하는 함수가 --per 와 칩 글자를 함께 바꾸고 인덱스를 최댓값 안으로 묶어 점 개수도 줄이며, 폭은 matchMedia 두 개가 640px·420px 경계를 넘을 때만 다시 읽어 실측에서도 320px 폭에서 flex-basis 100%, 480px 에서 50%, 900px 에서 33.3333% 로 바뀌었습니다.

폭의 유일한 출처인 --per

// 폭의 유일한 출처는 --per 하나다. 간격은 gap 이 아니라 슬라이드 안쪽 여백으로 — flex-basis 계산이 흔들리지 않는다
.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;
}

칸 폭이 --per 하나로 정해지므로 장수가 바뀌면 한 칸 이동량(100% / per) 도 같이 바뀌고, 사진 자리는 세 폭에서 모두 aspect-ratio 16 / 9 를 그대로 지켜 높이가 폭을 따라 비례로 정해졌습니다(움직인 프레임 14/23 · 누적면적 22.367%).

이미지 슬라이드 js 는 어디까지 직접 짜야 하나?

답부터 쓰면 상태 하나를 지키는 코드까지입니다. 아홉 개의 자바스크립트를 전부 펼치면 인덱스 숫자를 모듈로로 감싸고, 화살표·점·키보드·포인터 이벤트를 그 숫자로 연결하고, 예외로 05 의 휠 잠금·06 의 타이머 정지·07 의 놓는 순간 속도 계산이 붙는 것이 전부입니다. 거리·색·짝 맞춤은 CSS 의 calc() 와 변수가 계산하므로 이미지 슬라이드 html 구조도 창 하나·트랙 하나·조작 단추면 끝납니다. 손대지 않는 편이 나은 선도 분명합니다. 관성 물리를 직접 적분하거나 가상 스크롤을 재구현하는 일은 07 처럼 속도 임계값 0.4 px/ms 와 거리 임계값 28% 두 개로 판정을 끝내는 쪽이 버그가 적고, 자동 넘김은 멈춤 단추와 탭 이탈 감지를 함께 두어야 WCAG 2.2.2 를 지킵니다. 받은 폴더를 열면 이 아홉 개가 그대로 들어 있습니다.

어디서 깨지는가 — 함정

제작하면서 실제로 부딪힌 것 세 가지입니다.

깊이를 translateZ 로 물리면 클릭이 트랙으로 샌다

03 의 첫 판은 뒤로 물러나는 카드에 translateZ 를 썼습니다. perspective 공간 안에서 translateZ 로 밀려난 요소는 Chrome 이 적중 판정에서 빼 버려서 화면에는 보이는데 누르면 카드가 아니라 트랙이 클릭을 받았고, elementFromPoint 로 옆 카드 자리를 찍어 보니 .cf__track 이 돌아왔습니다. 깊이를 rotateY 와 축소로 다시 짠 뒤에야 먼 카드를 눌러 가운데로 부를 수 있었고, 조작 실측에서도 오른쪽에서 두 칸 떨어진 카드 클릭으로 인덱스가 3에서 1로 움직였습니다.

썸네일 줄이 무대 열을 저절로 벌렸다

02 의 썸네일 줄은 화면보다 넓은 내용을 품고 있는데, 무대의 자동 열이 내용 폭을 그대로 따라 벌어지면서 데모 창에 가로 스크롤바가 생겼습니다. 열을 minmax(0, 1fr) 로 못 박은 뒤에야 스크롤바가 사라졌고, 지금은 썸네일 줄 안에서만 가로 스크롤이 삽니다(다섯 번째 썸네일을 누르면 줄의 scrollLeft 가 0에서 84 로 움직입니다).

휠 한 번에 네 장이 한꺼번에 지나갔다

05 를 처음 돌렸을 때는 휠을 한 번 굴렸을 뿐인데 관성 스크롤이 수십 개의 wheel 이벤트를 이어서 보냈고, 잠금이 없는 판은 그 전부를 한 칸씩 받아들여 끝까지 달아났습니다. 420ms 잠금 문을 세운 뒤 실측에서는 잠금 안에 들어온 두 번째 휠이 삼켜지고 트랙이 matrix(1, 0, 0, 1, 0, -156) 에서 한 장 폭인 -312 로만 내려갔습니다.

이 아홉 데모의 zip 비밀번호는 7up3thqw 이고, 압축을 풀면 각 항목의 index.html 과 style.scss 원본이 통째로 들어 있습니다.

변형 세 가지와 접근성

값 세 곳만 갈아 끼우면 아홉 개의 인상이 한꺼번에 바뀝니다.

이름 바꾼 값 느낌
duration $dur-enter 520ms → 260ms 슬라이드가 자리를 잡는 시간이 절반으로 줄어 손가락을 떼자마자 다음 장이 서 있는 느낌이 된다
easing $ease-out → $ease-spring 멈출 때 한 번 더 밀렸다 돌아와 사진이 무게를 가진 것처럼 보인다
color $subject-blue → $stage-orange 활성 점·진행 막대·썸네일 테두리의 강조색이 한꺼번에 바뀌어 같은 배치가 따뜻한 톤이 된다

접근성은 문서 구조에서 나옵니다. 아홉 개 전부 region 과 aria-roledescription="carousel" 로 이름을 붙였고, 화면에 보이는 장만 aria-hidden="false" 로 두며, 점은 aria-current · 썸네일은 aria-selected 로 제자리를 알립니다. 화살표 키는 자동 넘김만 두는 06 을 뺀 여덟 개가 받아 한 칸씩 움직이고(05 는 위아래 키), 08 은 Esc 로 창을 닫아도 초점이 눌렀던 격자 칸으로 돌아오는 것을 실측으로 확인했습니다. prefers-reduced-motion: reduce 에서는 전환 시간이 0초가 되고 06 은 자동재생을 아예 시작하지 않아 첫 장에 머뭅니다. 글자 대비는 파란 면 #0a4fe8 위 크림 글자 5.98:1, 06 의 장 번호는 #ffd23f 위 12.63:1, 05 의 안내문은 #17141a 위 10.65:1 로 아홉 개 화면의 글자 15곳이 전부 AA 를 통과했습니다.

이웃으로 이미지 비교 슬라이더는 같은 포인터 이벤트로 경계선을 미는 축을, 3D 큐브 캐러셀은 perspective 를 다른 각도로 쓰는 축을, 이미지 갤러리 효과와 포트폴리오 갤러리 UI 패턴은 격자에서 사진 자리를 다루는 축을 각각 이어 줍니다. 근거 문서는 MDN translate3d, MDN Pointer events, MDN dialog, WAI 캐러셀 패턴, MDN prefers-reduced-motion 을 보면 됩니다.

FAQ

이미지 슬라이드 js 코드를 통째로 옮겨도 다른 페이지에서 돌아가나요

돌아갑니다. 각 데모의 스크립트는 요소 id 만 보고 움직이고 바깥 의존이 0이라, 마크업과 스타일을 함께 옮기면 그대로 시작합니다. 여러 개를 한 페이지에 두려면 id 가 겹치지 않게 항목별로 이름을 바꿔 주는 편이 안전합니다.

사진 자리에 진짜 사진은 어떻게 넣나요

지금 사진 자리는 토큰 팔레트로 합성한 장면 그라디언트입니다. 그라디언트가 들어간 background-image 자리에 사진 주소를 넣거나 role="img" 요소를 <img> 로 바꾸면 되고, 트랙의 거리 계산은 장 폭 백분율로 되어 있어 사진이 바뀌어도 손댈 필요가 없습니다.

모바일에서 가로로 끌면 페이지까지 함께 밀리지 않나요

07 의 트랙에는 touch-action: pan-y 가 걸려 있어 가로 끌기는 슬라이더가 받고 세로 스크롤은 브라우저에 남습니다(실측 touch_action 값 pan-y). setPointerCapture 가 손가락이 칸을 벗어나도 놓는 순간까지 좌표를 붙잡아 두므로, 끝까지 끌다 놓아도 판정은 놓는 순간의 속도를 그대로 씁니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.