02Slider synced with a thumbnail strip
Tapping a thumbnail moves the large photo to that slide, and paging the large photo pulls the active thumbnail to the centre with scrollIntoView.
Best forA product detail photo set, a room photo gallery
scrollIntoViewaria-selectedtranslateX
<main class="stage">
<section class="gal is-demo" id="gal" tabindex="0" role="region" aria-roledescription="carousel" aria-label="Room photo slider">
<div class="gal__main">
<div class="gal__track" id="track" style="--i:0">
<article class="gal__slide" aria-roledescription="slide">
<div class="gal__shot gal__shot--a" role="img" aria-label="A bed by the window with white linens in long morning light"></div>
<p class="gal__cap">Window bed</p>
</article>
<article class="gal__slide" aria-roledescription="slide">
<div class="gal__shot gal__shot--b" role="img" aria-label="A solid wood desk under a round pool of lamp light"></div>
<p class="gal__cap">Wood desk</p>
</article>
<article class="gal__slide" aria-roledescription="slide">
<div class="gal__shot gal__shot--c" role="img" aria-label="A bathroom lined with teal tiles and a round mirror"></div>
<p class="gal__cap">Teal bathroom</p>
</article>
<article class="gal__slide" aria-roledescription="slide">
<div class="gal__shot gal__shot--d" role="img" aria-label="A small kitchen with cups lined up on wooden shelves"></div>
<p class="gal__cap">Kitchen shelf</p>
</article>
<article class="gal__slide" aria-roledescription="slide">
<div class="gal__shot gal__shot--e" role="img" aria-label="A narrow balcony with potted plants and an alley beyond the railing"></div>
<p class="gal__cap">Narrow balcony</p>
</article>
<article class="gal__slide" aria-roledescription="slide">
<div class="gal__shot gal__shot--f" role="img" aria-label="A night living room lit only by indirect lamps, with a low sofa"></div>
<p class="gal__cap">Night living room</p>
</article>
</div>
<button class="gal__nav gal__nav--prev" id="prev" type="button" aria-label="Previous photo"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2"/><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z"/></g></svg></button>
<button class="gal__nav gal__nav--next" id="next" type="button" aria-label="Next photo"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></button>
</div>
<div class="gal__strip" id="strip" role="group" aria-label="Thumbnail strip">
<button class="gal__thumb" type="button" aria-selected="false"><span class="gal__thumbArt gal__shot--a" role="img" aria-label="Window bed"></span></button>
<button class="gal__thumb" type="button" aria-selected="false"><span class="gal__thumbArt gal__shot--b" role="img" aria-label="Wood desk"></span></button>
<button class="gal__thumb" type="button" aria-selected="false"><span class="gal__thumbArt gal__shot--c" role="img" aria-label="Teal bathroom"></span></button>
<button class="gal__thumb" type="button" aria-selected="false"><span class="gal__thumbArt gal__shot--d" role="img" aria-label="Kitchen shelf"></span></button>
<button class="gal__thumb" type="button" aria-selected="false"><span class="gal__thumbArt gal__shot--e" role="img" aria-label="Narrow balcony"></span></button>
<button class="gal__thumb" type="button" aria-selected="false"><span class="gal__thumbArt gal__shot--f" role="img" aria-label="Night living room"></span></button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 썸네일 줄과 서로 맞추는 슬라이더 — 동기는 양방향이다.
// ① 썸네일 클릭 → 인덱스가 바뀌고 큰 사진 트랙이 translateX 로 그 장까지 밀린다
// ② 큰 사진이 넘어가면 scrollIntoView({inline:'center'}) 가 활성 썸네일을 줄 가운데로 끌어온다
// 큰 사진 쪽은 스크롤 컨테이너가 아니다(인덱스 구동). 가로 스크롤은 썸네일 줄 하나뿐이고,
// 그것도 scrollIntoView 가 움직이는 레일이라 사람이 직접 밀 필요가 없다.
// 사진 자리는 FLUX 키가 오면 배경만 갈아 끼운다 (run/082/_이미지후보.01.json).
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
$panel: $subject-blue-deep; // 크림 글자를 얹는 파란 면 — 5.98:1
$veil: rgba($subject-ink, .72); // 사진 위 조작 면
$cap-fs: clamp(11px, 3.2vw, 13px);
@include stage($stage-yellow);
// 무대의 자동 열은 내용의 max-content 로 벌어진다 — 썸네일 줄(496px)이 그대로 열 너비가 되어
// 480px 창에도 가로 스크롤바가 생겼다 (실측 2026-09-14, demo.py 넘침). 열을 minmax(0,1fr) 로 못 박는다
.stage { grid-template-columns: minmax(0, 1fr); }
.gal {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(100%, 420px);
min-width: 0;
justify-self: center;
}
.gal:focus-visible { outline: 3px solid $panel; outline-offset: 3px; }
// ── 큰 사진: 창은 고정, 트랙만 움직인다
.gal__main {
position: relative;
height: clamp(96px, 54vh, 160px);
border-radius: $r-card;
overflow: hidden;
background: $subject-ink;
box-shadow: $shadow-press;
}
.gal__track {
display: flex;
width: 100%;
height: 100%;
transform: translateX(calc(-100% * var(--i, 0)));
transition: transform $duration $easing;
will-change: transform;
}
.gal__slide { position: relative; flex: 0 0 100%; height: 100%; }
// ── 사진 자리. 큰 사진과 썸네일이 같은 배경 클래스를 함께 쓴다
.gal__shot { position: absolute; inset: 0; }
.gal__shot,
.gal__thumbArt { background-repeat: no-repeat; background-size: cover; }
.gal__shot--a {
background-image:
radial-gradient(circle at 24% 22%, rgba($subject-cream, .92), rgba($subject-cream, 0) 34%),
linear-gradient(76deg, rgba($subject-cream, 0) 46%, rgba($stage-paper, .88) 46.6%, rgba($stage-paper, .88) 82%, rgba($subject-cream, 0) 82.6%),
linear-gradient(160deg, mix($stage-yellow, $subject-cream, 46%) 0%, mix($stage-orange, $subject-cream, 26%) 58%, mix($subject-ink, $stage-orange, 62%) 100%);
}
.gal__shot--b {
background-image:
radial-gradient(circle at 62% 26%, rgba($stage-yellow, .86), rgba($stage-yellow, 0) 28%),
linear-gradient(180deg, rgba($subject-ink, 0) 56%, mix($stage-orange, $subject-ink, 46%) 56.6%),
linear-gradient(190deg, mix($subject-ink, $stage-orange, 74%) 0%, mix($subject-ink, $stage-orange, 88%) 100%);
}
.gal__shot--c {
background-image:
radial-gradient(circle at 70% 32%, rgba($stage-paper, .90), rgba($stage-paper, 0) 15%),
linear-gradient(90deg, rgba($subject-mint, .42) 0%, rgba($subject-mint, .42) 33%, rgba($subject-cream, 0) 33.4%, rgba($subject-cream, 0) 34%),
linear-gradient(172deg, mix($subject-mint, $stage-paper, 52%) 0%, mix($subject-mint, $subject-blue, 44%) 62%, mix($subject-blue, $subject-ink, 58%) 100%);
}
.gal__shot--d {
background-image:
linear-gradient(180deg, rgba($subject-ink, 0) 34%, mix($subject-ink, $stage-orange, 66%) 34.6%, mix($subject-ink, $stage-orange, 66%) 40%, rgba($subject-ink, 0) 40.6%,
rgba($subject-ink, 0) 66%, mix($subject-ink, $stage-orange, 66%) 66.6%, mix($subject-ink, $stage-orange, 66%) 72%, rgba($subject-ink, 0) 72.6%),
linear-gradient(96deg, mix($stage-orange, $subject-cream, 34%) 0%, mix($stage-orange, $subject-ink, 44%) 100%);
}
.gal__shot--e {
background-image:
radial-gradient(circle at 30% 74%, rgba($subject-mint, .74), rgba($subject-mint, 0) 26%),
linear-gradient(90deg, rgba($subject-cream, 0) 62%, rgba($subject-cream, .56) 62.6%, rgba($subject-cream, .56) 65%, rgba($subject-cream, 0) 65.6%),
linear-gradient(184deg, mix($subject-sky, $subject-cream, 40%) 0%, mix($stage-yellow, $subject-cream, 62%) 52%, mix($subject-ink, $stage-orange, 58%) 100%);
}
.gal__shot--f {
background-image:
radial-gradient(circle at 22% 38%, rgba($stage-yellow, .58), rgba($stage-yellow, 0) 30%),
linear-gradient(180deg, rgba($subject-ink, 0) 68%, mix($subject-ink, $subject-blue, 62%) 68.6%),
linear-gradient(174deg, mix($subject-blue, $subject-ink, 26%) 0%, $subject-ink 82%);
}
// ── 장면 이름. 파란 면 위 크림 글자 (5.98:1)
.gal__cap {
position: absolute;
left: $sp-2;
bottom: $sp-2;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $panel;
color: $subject-cream;
font-weight: 700;
font-size: $cap-fs;
line-height: 1.4;
}
// ── 화살표
.gal__nav {
position: absolute;
top: 50%;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: $veil;
color: $subject-cream;
font-size: 17px;
cursor: pointer;
transform: translateY(-50%);
transition: transform $dur-quick $easing;
}
.gal__nav--prev { left: $sp-2; }
.gal__nav--next { right: $sp-2; }
.gal__nav:hover { transform: translateY(-50%) scale(1.12); }
.gal__nav:focus-visible { outline: 3px solid $panel; outline-offset: 2px; }
// ── 썸네일 줄. 여기만 가로로 스크롤되고, 미는 사람은 scrollIntoView 다
.gal__strip {
display: flex;
gap: $sp-2;
padding: $sp-1;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.gal__strip::-webkit-scrollbar { display: none; }
.gal__thumb {
flex: 0 0 auto;
width: clamp(48px, 16vw, 76px);
height: clamp(34px, 11vw, 52px);
padding: 0;
border: 0;
border-radius: $r-sm;
overflow: hidden;
background: $subject-ink;
opacity: .58;
cursor: pointer;
transition: opacity $dur-quick $easing, transform $dur-quick $easing, box-shadow $dur-quick $easing;
}
.gal__thumbArt { display: block; width: 100%; height: 100%; }
.gal__thumb[aria-selected="true"] { opacity: 1; transform: scale(1.06); }
// 활성 테두리는 안쪽 그림자로 그린다 — 바깥 그림자는 줄 끝에서 잘린다
.gal__thumb[aria-selected="true"] .gal__thumbArt { box-shadow: inset 0 0 0 3px $color; }
.gal__thumb:focus-visible { outline: 3px solid $panel; outline-offset: 2px; }
// ── 좁은 화면: 조작 면과 여백을 줄인다
@media (max-width: 360px) {
.gal { gap: $sp-1; }
.gal__cap { left: $sp-1; bottom: $sp-1; padding: 0 $sp-2; }
.gal__nav { width: 24px; height: 24px; font-size: 15px; }
.gal__strip { gap: $sp-1; padding: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.gal__track,
.gal__nav,
.gal__thumb { transition-duration: 0s; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
grid-template-columns: minmax(0, 1fr);
}
.gal {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 420px);
min-width: 0;
justify-self: center;
}
.gal:focus-visible {
outline: 3px solid #0a4fe8;
outline-offset: 3px;
}
.gal__main {
position: relative;
height: clamp(96px, 54vh, 160px);
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.gal__track {
display: flex;
width: 100%;
height: 100%;
transform: translateX(calc(-100% * var(--i, 0)));
transition: transform 600ms ease-out;
will-change: transform;
}
.gal__slide {
position: relative;
flex: 0 0 100%;
height: 100%;
}
.gal__shot {
position: absolute;
inset: 0;
}
.gal__shot,
.gal__thumbArt {
background-repeat: no-repeat;
background-size: cover;
}
.gal__shot--a {
background-image: radial-gradient(circle at 24% 22%, rgba(255, 247, 230, 0.92), rgba(255, 247, 230, 0) 34%), linear-gradient(76deg, rgba(255, 247, 230, 0) 46%, rgba(255, 255, 255, 0.88) 46.6%, rgba(255, 255, 255, 0.88) 82%, rgba(255, 247, 230, 0) 82.6%), linear-gradient(160deg, rgb(100%, 90.1882352941%, 60.0705882353%) 0%, rgb(100%, 79.5294117647%, 69.9058823529%) 58%, rgb(43.5921568627%, 16.337254902%, 10.9411764706%) 100%);
}
.gal__shot--b {
background-image: radial-gradient(circle at 62% 26%, rgba(255, 210, 63, 0.86), rgba(255, 210, 63, 0) 28%), linear-gradient(180deg, rgba(23, 20, 26, 0) 56%, rgb(50.8705882353%, 18.1254901961%, 11.0980392157%) 56.6%), linear-gradient(190deg, rgb(32.6745098039%, 13.6549019608%, 10.7058823529%) 0%, rgb(19.937254902%, 10.5254901961%, 10.431372549%) 100%);
}
.gal__shot--c {
background-image: radial-gradient(circle at 70% 32%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0) 15%), linear-gradient(90deg, rgba(76, 212, 166, 0.42) 0%, rgba(76, 212, 166, 0.42) 33%, rgba(255, 247, 230, 0) 33.4%, rgba(255, 247, 230, 0) 34%), linear-gradient(172deg, rgb(63.4980392157%, 91.231372549%, 81.8509803922%) 0%, rgb(23.4352941176%, 60.5176470588%, 82.6666666667%) 62%, rgb(14.4784313725%, 28.0862745098%, 60.2352941176%) 100%);
}
.gal__shot--d {
background-image: linear-gradient(180deg, rgba(23, 20, 26, 0) 34%, rgb(39.9529411765%, 15.4431372549%, 10.862745098%) 34.6%, rgb(39.9529411765%, 15.4431372549%, 10.862745098%) 40%, rgba(23, 20, 26, 0) 40.6%, rgba(23, 20, 26, 0) 66%, rgb(39.9529411765%, 15.4431372549%, 10.862745098%) 66.6%, rgb(39.9529411765%, 15.4431372549%, 10.862745098%) 72%, rgba(23, 20, 26, 0) 72.6%), linear-gradient(96deg, rgb(100%, 74.1960784314%, 63.662745098%) 0%, rgb(49.0509803922%, 17.6784313725%, 11.0588235294%) 100%);
}
.gal__shot--e {
background-image: radial-gradient(circle at 30% 74%, rgba(76, 212, 166, 0.74), rgba(76, 212, 166, 0) 26%), linear-gradient(90deg, rgba(255, 247, 230, 0) 62%, rgba(255, 247, 230, 0.56) 62.6%, rgba(255, 247, 230, 0.56) 65%, rgba(255, 247, 230, 0) 65.6%), linear-gradient(184deg, rgb(77.2549019608%, 89.4901960784%, 94.1176470588%) 0%, rgb(100%, 87.8666666667%, 49.5921568627%) 52%, rgb(47.231372549%, 17.231372549%, 11.0196078431%) 100%);
}
.gal__shot--f {
background-image: radial-gradient(circle at 22% 38%, rgba(255, 210, 63, 0.58), rgba(255, 210, 63, 0) 30%), linear-gradient(180deg, rgba(23, 20, 26, 0) 68%, rgb(12.5960784314%, 21.1058823529%, 42.9803921569%) 68.6%), linear-gradient(174deg, rgb(11.4666666667%, 16.9176470588%, 32.6274509804%) 0%, #17141a 82%);
}
.gal__cap {
position: absolute;
left: 8px;
bottom: 8px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.4;
}
.gal__nav {
position: absolute;
top: 50%;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
font-size: 17px;
cursor: pointer;
transform: translateY(-50%);
transition: transform 220ms ease-out;
}
.gal__nav--prev {
left: 8px;
}
.gal__nav--next {
right: 8px;
}
.gal__nav:hover {
transform: translateY(-50%) scale(1.12);
}
.gal__nav:focus-visible {
outline: 3px solid #0a4fe8;
outline-offset: 2px;
}
.gal__strip {
display: flex;
gap: 8px;
padding: 4px;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.gal__strip::-webkit-scrollbar {
display: none;
}
.gal__thumb {
flex: 0 0 auto;
width: clamp(48px, 16vw, 76px);
height: clamp(34px, 11vw, 52px);
padding: 0;
border: 0;
border-radius: 8px;
overflow: hidden;
background: #17141a;
opacity: 0.58;
cursor: pointer;
transition: opacity 220ms ease-out, transform 220ms ease-out, box-shadow 220ms ease-out;
}
.gal__thumbArt {
display: block;
width: 100%;
height: 100%;
}
.gal__thumb[aria-selected=true] {
opacity: 1;
transform: scale(1.06);
}
.gal__thumb[aria-selected=true] .gal__thumbArt {
box-shadow: inset 0 0 0 3px #2f6df6;
}
.gal__thumb:focus-visible {
outline: 3px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 360px) {
.gal {
gap: 4px;
}
.gal__cap {
left: 4px;
bottom: 4px;
padding: 0 8px;
}
.gal__nav {
width: 24px;
height: 24px;
font-size: 15px;
}
.gal__strip {
gap: 4px;
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.gal__track,
.gal__nav,
.gal__thumb {
transition-duration: 0s;
}
}// 큰 사진과 썸네일 줄이 인덱스 하나를 같이 쓴다.
// 썸네일 클릭 → go(n) 으로 본문이 움직이고, 본문이 움직이면 scrollIntoView 가 활성 칸을 줄 가운데로 끌어온다.
var gal = document.getElementById('gal');
var track = document.getElementById('track');
var strip = document.getElementById('strip');
var slides = track.querySelectorAll('.gal__slide');
var thumbs = strip.querySelectorAll('.gal__thumb');
var SLIDE_LABEL = '{n} of {total}';
var total = slides.length;
var index = 0;
var timer = 0;
var soft = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function label(form, n) { return form.replace('{n}', n).replace('{total}', total); }
function picker(n) { return function () { stop(); go(n); }; }
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' });
}
function stop() {
gal.classList.remove('is-demo');
if (timer) { clearInterval(timer); timer = 0; }
}
for (var i = 0; i < total; i++) {
slides[i].setAttribute('aria-label', label(SLIDE_LABEL, i + 1));
thumbs[i].addEventListener('click', picker(i));
}
document.getElementById('prev').addEventListener('click', function () { stop(); go(index - 1); });
document.getElementById('next').addEventListener('click', function () { stop(); go(index + 1); });
gal.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { stop(); go(index - 1); }
if (e.key === 'ArrowRight') { stop(); go(index + 1); }
});
gal.addEventListener('pointerdown', stop);
go(0);
// 격자 미리보기: .is-demo 가 붙어 있는 동안만 혼자 넘어간다
timer = setInterval(function () { if (gal.classList.contains('is-demo')) { go(index + 1); } }, 480);// ThumbnailStripSlider — vanilla 판(run/082/demo/02-slider-thumbnail-strip-sync/index.html) 의 React 포트.
// 큰 사진과 썸네일 줄이 인덱스 하나를 같이 쓴다.
// ① 썸네일 클릭 → 인덱스가 바뀌고 트랙이 translateX 로 그 장까지 밀린다
// ② 인덱스가 바뀌면 effect 가 scrollIntoView 로 활성 썸네일을 줄 가운데로 끌어온다
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThumbnailStripSlider.module.scss";
export type ThumbnailStripSliderProps = {
duration?: number;
easing?: string;
color?: string;
autoplayMs?: number;
};
type Vars = CSSProperties & {
"--i"?: number;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// useRef<T> 는 T | null 을 돌려준다 — ambient 타입에 맞춰 상자 타입을 따로 둔다
type Box<T> = { current: T | null };
const REGION_LABEL = "객실 사진 슬라이더";
const STRIP_LABEL = "썸네일 줄";
const SLIDE_LABEL = "{total}장 중 {n}장";
const PREV_LABEL = "이전 사진";
const NEXT_LABEL = "다음 사진";
type Shot = { key: string; cap: string; alt: string };
const SHOTS: Shot[] = [
{ key: "a", cap: "창가 침대", alt: "아침 햇빛이 길게 드는 창가 침대와 흰 침구" },
{ key: "b", cap: "원목 책상", alt: "스탠드 불빛이 동그랗게 떨어지는 원목 책상" },
{ key: "c", cap: "청록 욕실", alt: "청록 타일을 두른 욕실과 둥근 거울" },
{ key: "d", cap: "주방 선반", alt: "나무 선반에 컵이 줄지어 놓인 작은 주방" },
{ key: "e", cap: "좁은 발코니", alt: "화분을 둔 좁은 발코니와 난간 너머 골목" },
{ key: "f", cap: "밤의 거실", alt: "간접등만 켠 밤의 거실과 낮은 소파" },
];
function label(form: string, n: number, total: number): string {
return form.replace("{n}", String(n)).replace("{total}", String(total));
}
function CaretLeft() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M160 48v160l-80-80Z" opacity=".2" />
<path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z" />
</g>
</svg>
);
}
function CaretRight() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
);
}
export default function ThumbnailStripSlider(props: ThumbnailStripSliderProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "ease-out";
const color = props.color ?? "#2f6df6";
const autoplayMs = props.autoplayMs ?? 480;
const total = SHOTS.length;
const [index, setIndex] = useState<number>(0);
const [playing, setPlaying] = useState<boolean>(true);
const stripRef = useRef<HTMLDivElement>(null) as Box<HTMLDivElement>;
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => setIndex((prev: number) => (prev + 1) % total), autoplayMs);
return () => window.clearInterval(id);
}, [playing, autoplayMs, total]);
// 본문 → 썸네일 방향. block:'nearest' 라 줄만 움직이고 페이지는 그대로 있다
useEffect(() => {
const strip = stripRef.current;
if (!strip) return;
const active = strip.children[index] as HTMLElement | undefined;
if (!active) return;
const soft = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
active.scrollIntoView({ block: "nearest", inline: "center", behavior: soft ? "smooth" : "auto" });
}, [index]);
const stop = () => setPlaying(false);
const go = (n: number) => {
stop();
setIndex(((n % total) + total) % total);
};
const vars: Vars = { "--i": index, "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<section
className={styles.gal}
style={vars}
tabIndex={0}
role="region"
aria-roledescription="carousel"
aria-label={REGION_LABEL}
onPointerDown={stop}
onKeyDown={(e: { key: string }) => {
if (e.key === "ArrowLeft") go(index - 1);
if (e.key === "ArrowRight") go(index + 1);
}}
>
<div className={styles.main}>
<div className={styles.track}>
{SHOTS.map((shot: Shot, i: number) => (
<article
key={shot.key}
className={styles.slide}
aria-roledescription="slide"
aria-label={label(SLIDE_LABEL, i + 1, total)}
aria-hidden={i === index ? "false" : "true"}
>
<div className={styles.shot + " " + styles["shot--" + shot.key]} role="img" aria-label={shot.alt} />
<p className={styles.cap}>{shot.cap}</p>
</article>
))}
</div>
<button className={styles.nav + " " + styles.navPrev} type="button" aria-label={PREV_LABEL} onClick={() => go(index - 1)}>
<CaretLeft />
</button>
<button className={styles.nav + " " + styles.navNext} type="button" aria-label={NEXT_LABEL} onClick={() => go(index + 1)}>
<CaretRight />
</button>
</div>
<div className={styles.strip} ref={stripRef} role="group" aria-label={STRIP_LABEL}>
{SHOTS.map((shot: Shot, i: number) => (
<button
key={shot.key}
className={styles.thumb}
type="button"
aria-selected={i === index ? "true" : "false"}
onClick={() => go(i)}
>
<span className={styles.thumbArt + " " + styles["shot--" + shot.key]} role="img" aria-label={shot.cap} />
</button>
))}
</div>
</section>
</main>
);
}02 Slider synced with a thumbnail strip — Measured area 62.812% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · CSS Image Slider — 9 Responsive Sliders, No GSAP
The post this demo lives in
CSS Image Slider — 9 Responsive Sliders, No GSAPA css image slider lines up several photo slots in a row and shows them one at a time with arrows, dots, or a finger — N See all demos in this post

