06下のサムネイルが現在のカードに合わせて明るくなるカルーセル
大きな画面のカードが切り替わるたびに、下の小さなサムネイル列で該当する枠だけ縁が明るくなり残りは薄く沈み、今何番目かが一目でわかる。
使いどころ商品詳細ページの大きな画像とサムネイル一覧
computeTargetOffsetcenterActiveThumbthumbCenter
<main class="stage">
<section class="carousel is-demo" id="carousel" role="region" aria-roledescription="carousel" aria-label="カードカルーセル">
<div class="viewport" id="viewport" tabindex="0">
<div class="mainSlide" id="mainSlide"></div>
</div>
<button type="button" class="navBtn navBtn--prev" id="prevBtn" aria-label="前のカード">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M112 56v144l-72-72Z" opacity=".2"/><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z"/></g></svg>
</button>
<button type="button" class="navBtn navBtn--next" id="nextBtn" aria-label="次のカード">
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
<div class="thumbStrip" id="thumbStrip">
<div class="thumbTrack" id="thumbTrack"></div>
</div>
<div class="readout" id="readout" aria-live="polite"></div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-sky;
// 계산 겹: index.html 의 computeTargetOffset() 이 활성 썸네일 중심을 띠 중심에 맞추는 목표값을
// 산술로 구하고, centerActiveThumb() 이 매 프레임 lerp 로 다가간다 — scrollIntoView 를 쓰지 않는다.
@include stage($stage-ink);
.carousel {
position: relative;
width: 300px;
}
.viewport {
position: relative;
width: 300px;
height: 130px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
outline: none;
}
.mainSlide {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 48px;
line-height: 1;
}
.card--0 { background: linear-gradient(160deg, $subject-sky, #9cb8ce); color: $subject-ink; }
.card--1 { background: linear-gradient(160deg, $subject-pink, #d97fa0); color: $subject-ink; }
.card--2 { background: linear-gradient(160deg, $subject-blue-deep, #475690); color: $subject-cream; }
.card--3 { background: linear-gradient(160deg, $subject-mint, #63b299); color: $subject-ink; }
.card--4 { background: linear-gradient(160deg, $subject-lilac, #bdb3da); color: $subject-ink; }
.thumbStrip {
position: relative;
width: 300px;
height: 46px;
margin-top: $sp-2;
overflow: hidden;
border-radius: $r-control;
}
.thumbTrack {
display: flex;
height: 100%;
will-change: transform;
}
.thumb {
flex: 0 0 46px;
height: 46px;
margin-right: $sp-2;
border: 2px solid transparent;
border-radius: $r-control;
display: grid;
place-items: center;
font-weight: 700;
font-size: 15px;
cursor: pointer;
opacity: .55;
transition: opacity $duration $easing, border-color $duration $easing;
&:last-child { margin-right: 0; }
&.isActive { opacity: 1; border-color: $subject-cream; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.navBtn {
position: absolute;
top: 65px;
margin-top: -18px;
width: 36px;
height: 36px;
border-radius: $r-pill;
border: none;
display: grid;
place-items: center;
background: rgba($stage-ink, .6);
color: $subject-cream;
cursor: pointer;
z-index: 4;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.navBtn--prev { left: -($sp-3); }
.navBtn--next { right: -($sp-3); }
.readout {
position: absolute;
right: $sp-1;
top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba($stage-ink, .6);
color: rgba($subject-cream, .9);
font-size: 11px;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
z-index: 4;
}
@media (max-width: 340px) {
.carousel { width: 220px; }
.viewport { width: 220px; height: 96px; }
.mainSlide { font-size: 34px; }
.thumbStrip { width: 220px; height: 36px; }
.thumb { flex-basis: 36px; height: 36px; font-size: 12px; }
.navBtn { width: 28px; height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 썸네일 클릭·버튼·키보드는 index.html 이 그대로 중앙 정렬 계산을 돌린다.
// scss 만: 썸네일 opacity/border 전환만 꺼서 잔여 트랜지션을 없앤다.
.thumb { transition: none; }
.navBtn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.carousel {
position: relative;
width: 300px;
}
.viewport {
position: relative;
width: 300px;
height: 130px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
outline: none;
}
.mainSlide {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-weight: 700;
font-size: 48px;
line-height: 1;
}
.card--0 {
background: linear-gradient(160deg, #6ec8ff, #9cb8ce);
color: #17141a;
}
.card--1 {
background: linear-gradient(160deg, #ff9ebb, #d97fa0);
color: #17141a;
}
.card--2 {
background: linear-gradient(160deg, #0a4fe8, #475690);
color: #fff7e6;
}
.card--3 {
background: linear-gradient(160deg, #4cd4a6, #63b299);
color: #17141a;
}
.card--4 {
background: linear-gradient(160deg, #b9a0ff, #bdb3da);
color: #17141a;
}
.thumbStrip {
position: relative;
width: 300px;
height: 46px;
margin-top: 8px;
overflow: hidden;
border-radius: 12px;
}
.thumbTrack {
display: flex;
height: 100%;
will-change: transform;
}
.thumb {
flex: 0 0 46px;
height: 46px;
margin-right: 8px;
border: 2px solid transparent;
border-radius: 12px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 15px;
cursor: pointer;
opacity: 0.55;
transition: opacity 300ms ease-out, border-color 300ms ease-out;
}
.thumb:last-child {
margin-right: 0;
}
.thumb.isActive {
opacity: 1;
border-color: #fff7e6;
}
.thumb:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.navBtn {
position: absolute;
top: 65px;
margin-top: -18px;
width: 36px;
height: 36px;
border-radius: 999px;
border: none;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.6);
color: #fff7e6;
cursor: pointer;
z-index: 4;
}
.navBtn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.navBtn--prev {
left: -12px;
}
.navBtn--next {
right: -12px;
}
.readout {
position: absolute;
right: 4px;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.6);
color: rgba(255, 247, 230, 0.9);
font-size: 11px;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
z-index: 4;
}
@media (max-width: 340px) {
.carousel {
width: 220px;
}
.viewport {
width: 220px;
height: 96px;
}
.mainSlide {
font-size: 34px;
}
.thumbStrip {
width: 220px;
height: 36px;
}
.thumb {
flex-basis: 36px;
height: 36px;
font-size: 12px;
}
.navBtn {
width: 28px;
height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.thumb {
transition: none;
}
.navBtn {
transition: none;
}
}(function () {
var TOTAL = 5;
var THUMB_W = 46;
var THUMB_GAP = 10;
var THUMB_STEP = THUMB_W + THUMB_GAP;
var STRIP_W = 300;
var CENTER_EASE = 0.24; // 매 프레임 목표 오프셋으로 당기는 비율 — scrollIntoView 가 아니라 직접 보간한다
var CENTER_STOP = 0.3;
var DEMO_HOLD_MS = 420;
var carousel = document.getElementById('carousel');
var viewport = document.getElementById('viewport');
var mainSlide = document.getElementById('mainSlide');
var thumbStrip = document.getElementById('thumbStrip');
var thumbTrack = document.getElementById('thumbTrack');
var readout = document.getElementById('readout');
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var LABEL_PREFIX = 'カード ';
var LABEL_SEP = ' / ';
var COLOR_CLASSES = ['card--0', 'card--1', 'card--2', 'card--3', 'card--4'];
var thumbs = [];
for (var i = 0; i < TOTAL; i++) {
var t = document.createElement('button');
t.type = 'button';
t.className = 'thumb ' + COLOR_CLASSES[i];
t.textContent = String(i + 1);
t.setAttribute('aria-label', LABEL_PREFIX + (i + 1) + LABEL_SEP + TOTAL);
(function (idx) { t.addEventListener('click', function () { human(); goTo(idx); }); })(i);
thumbTrack.appendChild(t);
thumbs.push(t);
}
var current = 0;
var offset = 0; // 썸네일 트랙의 현재 translateX(px)
var targetOffset = 0; // 방금 계산한 목표 translateX(px)
var centerFrame = null;
var demoTimers = [];
function paintMain() {
mainSlide.className = 'mainSlide ' + COLOR_CLASSES[current];
mainSlide.textContent = String(current + 1);
readout.textContent = LABEL_PREFIX + (current + 1) + LABEL_SEP + TOTAL;
thumbs.forEach(function (el, n) { el.classList.toggle('isActive', n === current); });
}
// 계산 겹 — 활성 썸네일의 중심이 띠의 중심에 오도록 목표 오프셋을 산술로 구하고,
// 트랙 폭 안으로 클램프한 뒤 매 프레임 보간(lerp)해 다가간다. 브라우저의 scrollIntoView 를 쓰지 않는다.
function computeTargetOffset() {
var thumbCenter = current * THUMB_STEP + THUMB_W / 2;
var raw = STRIP_W / 2 - thumbCenter;
var trackWidth = TOTAL * THUMB_STEP - THUMB_GAP;
var minOffset = Math.min(0, STRIP_W - trackWidth);
return Math.max(minOffset, Math.min(0, raw));
}
function stopCenter() {
if (centerFrame) { cancelAnimationFrame(centerFrame); centerFrame = null; }
}
function centerActiveThumb() {
targetOffset = computeTargetOffset();
stopCenter();
function frame() {
offset += (targetOffset - offset) * CENTER_EASE;
thumbTrack.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
if (Math.abs(targetOffset - offset) > CENTER_STOP) {
centerFrame = requestAnimationFrame(frame);
} else {
offset = targetOffset;
thumbTrack.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
centerFrame = null;
}
}
centerFrame = requestAnimationFrame(frame);
}
function goTo(i) {
current = ((i % TOTAL) + TOTAL) % TOTAL;
paintMain();
centerActiveThumb();
}
function human() {
if (carousel.classList.contains('is-demo')) {
carousel.classList.remove('is-demo');
demoTimers.forEach(function (t) { clearTimeout(t); });
demoTimers = [];
}
}
prevBtn.addEventListener('click', function () { human(); goTo(current - 1); });
nextBtn.addEventListener('click', function () { human(); goTo(current + 1); });
function onKeydown(e) {
if (e.key === 'ArrowLeft') { human(); goTo(current - 1); e.preventDefault(); }
else if (e.key === 'ArrowRight') { human(); goTo(current + 1); e.preventDefault(); }
}
viewport.addEventListener('keydown', onKeydown);
// reduce: 실사용 경로 적용 — 썸네일 클릭·버튼·키보드는 위 리스너가 그대로 중앙 정렬 계산을 돌린다.
// 아래 demoCycle() 은 시연 게이트만이고 reduce 에서는 돌지 않는다.
var DEMO_SEQUENCE = [2, 4, 1, 3, 0];
function demoCycle(i) {
if (!carousel.classList.contains('is-demo')) return;
goTo(DEMO_SEQUENCE[i % DEMO_SEQUENCE.length]);
demoTimers.push(setTimeout(function () { demoCycle(i + 1); }, DEMO_HOLD_MS));
}
function init() {
paintMain();
offset = computeTargetOffset();
targetOffset = offset;
thumbTrack.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
if (!reduce) demoCycle(0);
}
init();
})();import { useEffect, useRef } from 'react';
import styles from './CarouselThumbnailSync.module.scss';
// 상수 — vanilla demo 06 (run/384/demo/06-carousel-slide-thumbnail-sync/index.html) 과 동일
const TOTAL = 5;
const THUMB_W = 46;
const THUMB_GAP = 10;
const THUMB_STEP = THUMB_W + THUMB_GAP;
const STRIP_W = 300;
const CENTER_EASE = 0.24; // 매 프레임 목표 오프셋으로 당기는 비율 — scrollIntoView 가 아니라 직접 보간한다
const CENTER_STOP = 0.3;
const DEMO_HOLD_MS = 420;
const LABEL_PREFIX = '카드 ';
const LABEL_SEP = ' / ';
const COLOR_CLASSES = ['card--0', 'card--1', 'card--2', 'card--3', 'card--4'] as const;
const DEMO_SEQUENCE = [2, 4, 1, 3, 0];
export default function CarouselThumbnailSync() {
const carouselRef = useRef<HTMLElement>(null);
useEffect(() => {
const carousel = carouselRef.current;
if (!carousel) return undefined;
const viewport = carousel.querySelector<HTMLDivElement>('.' + styles.viewport);
const mainSlide = carousel.querySelector<HTMLDivElement>('.' + styles.mainSlide);
const thumbTrack = carousel.querySelector<HTMLDivElement>('.' + styles.thumbTrack);
const readout = carousel.querySelector<HTMLDivElement>('.' + styles.readout);
const prevBtn = carousel.querySelector<HTMLButtonElement>('.' + styles['navBtn--prev']);
const nextBtn = carousel.querySelector<HTMLButtonElement>('.' + styles['navBtn--next']);
if (!viewport || !mainSlide || !thumbTrack || !readout || !prevBtn || !nextBtn) return undefined;
const thumbs: HTMLButtonElement[] = [];
const thumbClickHandlers: (() => void)[] = [];
thumbTrack.innerHTML = '';
for (let i = 0; i < TOTAL; i++) {
const t = document.createElement('button');
t.type = 'button';
t.className = styles.thumb + ' ' + styles[COLOR_CLASSES[i]];
t.textContent = String(i + 1);
t.setAttribute('aria-label', LABEL_PREFIX + (i + 1) + LABEL_SEP + TOTAL);
const idx = i;
const handler = () => { human(); goTo(idx); };
thumbClickHandlers.push(handler);
t.addEventListener('click', handler);
thumbTrack.appendChild(t);
thumbs.push(t);
}
let current = 0;
let offset = 0; // 썸네일 트랙의 현재 translateX(px)
let targetOffset = 0; // 방금 계산한 목표 translateX(px)
let centerFrame = 0;
let demoTimers: number[] = [];
function paintMain() {
mainSlide!.className = styles.mainSlide + ' ' + styles[COLOR_CLASSES[current]];
mainSlide!.textContent = String(current + 1);
readout!.textContent = LABEL_PREFIX + (current + 1) + LABEL_SEP + TOTAL;
thumbs.forEach((el, n) => el.classList.toggle(styles.isActive, n === current));
}
// 계산 겹 — 활성 썸네일의 중심이 띠의 중심에 오도록 목표 오프셋을 산술로 구하고,
// 트랙 폭 안으로 클램프한 뒤 매 프레임 보간(lerp)해 다가간다. 브라우저의 scrollIntoView 를 쓰지 않는다.
function computeTargetOffset() {
const thumbCenter = current * THUMB_STEP + THUMB_W / 2;
const raw = STRIP_W / 2 - thumbCenter;
const trackWidth = TOTAL * THUMB_STEP - THUMB_GAP;
const minOffset = Math.min(0, STRIP_W - trackWidth);
return Math.max(minOffset, Math.min(0, raw));
}
function stopCenter() {
if (centerFrame) { cancelAnimationFrame(centerFrame); centerFrame = 0; }
}
function centerActiveThumb() {
targetOffset = computeTargetOffset();
stopCenter();
function frame() {
offset += (targetOffset - offset) * CENTER_EASE;
thumbTrack!.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
if (Math.abs(targetOffset - offset) > CENTER_STOP) {
centerFrame = requestAnimationFrame(frame);
} else {
offset = targetOffset;
thumbTrack!.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
centerFrame = 0;
}
}
centerFrame = requestAnimationFrame(frame);
}
function goTo(i: number) {
current = ((i % TOTAL) + TOTAL) % TOTAL;
paintMain();
centerActiveThumb();
}
function human() {
if (carousel!.classList.contains(styles['is-demo'])) {
carousel!.classList.remove(styles['is-demo']);
demoTimers.forEach((t) => window.clearTimeout(t));
demoTimers = [];
}
}
function onPrev() { human(); goTo(current - 1); }
function onNext() { human(); goTo(current + 1); }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') { human(); goTo(current - 1); e.preventDefault(); }
else if (e.key === 'ArrowRight') { human(); goTo(current + 1); e.preventDefault(); }
}
prevBtn.addEventListener('click', onPrev);
nextBtn.addEventListener('click', onNext);
viewport.addEventListener('keydown', onKeydown);
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// reduce: 실사용 경로 적용 — 썸네일 클릭·버튼·키보드는 위 리스너가 그대로 중앙 정렬 계산을 돌린다.
// 아래 demoCycle() 은 시연 게이트만이고 reduce 에서는 돌지 않는다.
function demoCycle(i: number) {
if (!carousel!.classList.contains(styles['is-demo'])) return;
goTo(DEMO_SEQUENCE[i % DEMO_SEQUENCE.length]);
demoTimers.push(window.setTimeout(() => demoCycle(i + 1), DEMO_HOLD_MS));
}
paintMain();
offset = computeTargetOffset();
targetOffset = offset;
thumbTrack.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
if (!reduce) demoCycle(0);
return () => {
prevBtn.removeEventListener('click', onPrev);
nextBtn.removeEventListener('click', onNext);
viewport.removeEventListener('keydown', onKeydown);
thumbs.forEach((t, i) => t.removeEventListener('click', thumbClickHandlers[i]));
demoTimers.forEach((t) => window.clearTimeout(t));
stopCenter();
};
}, []);
return (
<main className={styles.stage}>
<section
className={styles.carousel + ' ' + styles['is-demo']}
ref={carouselRef}
role="region"
aria-roledescription="carousel"
aria-label="카드 캐러셀"
>
<div className={styles.viewport} tabIndex={0}>
<div className={styles.mainSlide}></div>
</div>
<button type="button" className={styles.navBtn + ' ' + styles['navBtn--prev']} aria-label="이전 카드">
<svg viewBox="0 0 256 256" width={18} height={18} aria-hidden="true"><g fill="currentColor"><path d="M112 56v144l-72-72Z" opacity=".2" /><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z" /></g></svg>
</button>
<button type="button" className={styles.navBtn + ' ' + styles['navBtn--next']} aria-label="다음 카드">
<svg viewBox="0 0 256 256" width={18} height={18} aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2" /><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z" /></g></svg>
</button>
<div className={styles.thumbStrip}>
<div className={styles.thumbTrack}></div>
</div>
<div className={styles.readout} aria-live="polite"></div>
</section>
</main>
);
}06 下のサムネイルが現在のカードに合わせて明るくなるカルーセル — 計測 累積面積 33.855% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · カルーセル スライド 9種 — 慣性ドラッグから3D遠近切替まで
このデモが属する記事
カルーセル スライド 9種 — 慣性ドラッグから3D遠近切替までカルーセル スライド9種のうち、1つはカードが切り替わるたびに慣性で少し滑ってから止まり、1つは端に触れるたびにゴムのように弾んで戻り、1つはカードが3D遠近で曲がりながら横へ通り過ぎます。 この記事のデモをすべて見る

