03中央が手前に出る奥行きスライダー
選択中の何コマ離れているかを JS が数え、その分だけ rotateY と縮小をかけ、perspective をかけた舞台が両脇の写真を奥に倒します。
使いどころアルバムジャケット選択、作品ハイライトのセクション
perspectiverotateYtransform-style

<main class="stage">
<section class="cf is-demo" id="cf" tabindex="0" role="region" aria-roledescription="carousel" aria-label="アルバムジャケットの奥行きスライダー">
<div class="cf__box">
<div class="cf__track" id="track">
<button class="cf__card" type="button" aria-roledescription="slide">
<span class="cf__art cf__art--a" role="img" aria-label="水霧が低く覆う静かな湖のジャケット"></span>
<span class="cf__name">夜明けの湖</span>
</button>
<button class="cf__card" type="button" aria-roledescription="slide">
<span class="cf__art cf__art--b" role="img" aria-label="夕暮れに赤く染まる砂漠の岩のジャケット"></span>
<span class="cf__name">赤い砂漠</span>
</button>
<button class="cf__card" type="button" aria-roledescription="slide">
<span class="cf__art cf__art--c" role="img" aria-label="明かりのついたホームが並ぶ夜の駅のジャケット"></span>
<span class="cf__name">夜の停車場</span>
</button>
<button class="cf__card" type="button" aria-roledescription="slide">
<span class="cf__art cf__art--d" role="img" aria-label="雨上がりの濡れた緑の丘と低い雲のジャケット"></span>
<span class="cf__name">緑の丘</span>
</button>
<button class="cf__card" type="button" aria-roledescription="slide">
<span class="cf__art cf__art--e" role="img" aria-label="青い氷が浮かぶ冬の海のジャケット"></span>
<span class="cf__name">氷の海</span>
</button>
</div>
</div>
<div class="cf__bar">
<button class="cf__nav" id="prev" type="button" aria-label="前のジャケット"><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>
<div class="cf__dots" id="dots"></div>
<button class="cf__nav" id="next" type="button" aria-label="次のジャケット"><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>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 가운데가 앞으로 나오는 깊이 슬라이더.
// 무대(.cf__box)가 perspective 를 걸고 트랙(.cf__track)이 transform-style: preserve-3d 로 3D 공간을 연다.
// JS 는 카드마다 "활성 장에서 몇 칸 떨어졌는가"(--d) 와 그 절댓값(--ad) 만 얹는다 —
// 옆으로 밀리는 거리·rotateY 각도·뒤로 물러나는 깊이·축소·흐림은 전부 이 두 숫자의 calc() 다.
// 스크롤 위치를 읽지 않는다(scroll-snap 아님). 그래서 화살표·점·카드 클릭이 전부 같은 인덱스를 만진다.
// 표지 자리는 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); // 주황 무대 위 조작 면
$card-w: clamp(80px, 24vw, 112px);
$step: clamp(48px, 15vw, 72px); // 한 칸 옆으로 밀리는 거리
$name-fs: clamp(9px, 2.4vw, 11px);
@include stage($stage-orange);
.cf {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(100%, 420px);
}
.cf:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
// ── 깊이가 생기는 무대. 여기 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;
}
// ── 카드 하나. 두 숫자(--d · --ad)만 받아 스스로 자리를 잡는다
.cf__card {
position: absolute;
left: 50%;
top: 50%;
width: $card-w;
height: $card-w;
padding: 0;
border: 0;
border-radius: $r-card;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: $subject-ink;
cursor: pointer;
backface-visibility: hidden;
box-shadow: 0 0 0 2px rgba($subject-cream, .30), $shadow-raised;
// 깊이는 translateZ 가 아니라 rotateY + 축소로 만든다. perspective 를 건 3D 공간 안에서
// translateZ 로 물러난 요소는 Chrome 이 **적중 판정(hit test)에서 빼 버린다** — 화면에는 보이는데
// 클릭이 트랙으로 빠진다 (2026-09-14 실측: elementFromPoint 가 옆 카드 자리에서 .cf__track 을 돌려줬다).
// z 를 0 으로 두면 같은 자리에서 다섯 장이 전부 눌린다.
transform:
translate3d(calc(-50% + var(--d, 0) * #{$step}), -50%, 0)
rotateY(calc(var(--d, 0) * -34deg))
scale(calc(1 - .12 * var(--ad, 0)));
// 두 칸까지는 불투명하게 두고 세 칸째부터 사라진다 — 반투명 카드는 주황 무대가 비쳐 탁해진다 (실측 2026-09-14)
opacity: calc(3 - var(--ad, 0));
transition: transform $duration $easing, opacity $duration $easing;
}
// 멀어질수록 어두워지는 것은 투명도가 아니라 카드 위에 덮는 먹 장막이다
.cf__card::after {
content: "";
position: absolute;
inset: 0;
background: $subject-ink;
opacity: calc(.26 * var(--ad, 0));
pointer-events: none;
transition: opacity $duration $easing;
}
.cf__card[aria-current="true"] { box-shadow: 0 0 0 3px $color, $shadow-raised; }
.cf__card:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
// ── 표지 그림 자리
.cf__art { position: absolute; inset: 0; background-repeat: no-repeat; background-size: cover; }
.cf__art--a {
background-image:
radial-gradient(circle at 36% 30%, rgba($subject-cream, .84), rgba($subject-cream, 0) 30%),
linear-gradient(180deg, rgba($subject-cream, 0) 60%, rgba($subject-cream, .34) 60.4%, rgba($subject-cream, 0) 70%),
linear-gradient(176deg, mix($subject-sky, $subject-cream, 46%) 0%, mix($subject-blue, $subject-ink, 48%) 62%, mix($subject-ink, $subject-blue, 78%) 100%);
}
.cf__art--b {
background-image:
radial-gradient(circle at 68% 26%, rgba($subject-cream, .88), rgba($subject-cream, 0) 16%),
linear-gradient(104deg, rgba($subject-ink, 0) 54%, mix($subject-ink, $stage-orange, 70%) 54.6%),
linear-gradient(182deg, mix($stage-orange, $subject-cream, 38%) 0%, $stage-orange 46%, mix($stage-orange, $subject-ink, 54%) 100%);
}
.cf__art--c {
background-image:
linear-gradient(90deg, rgba($stage-yellow, 0) 22%, rgba($stage-yellow, .58) 22.6%, rgba($stage-yellow, 0) 26%,
rgba($stage-yellow, 0) 48%, rgba($stage-yellow, .46) 48.6%, rgba($stage-yellow, 0) 52%,
rgba($stage-yellow, 0) 74%, rgba($stage-yellow, .52) 74.6%, rgba($stage-yellow, 0) 78%),
linear-gradient(180deg, rgba($subject-ink, 0) 64%, mix($subject-ink, $stage-yellow, 82%) 64.6%),
linear-gradient(178deg, mix($subject-blue, $subject-ink, 24%) 0%, $subject-ink 84%);
}
.cf__art--d {
background-image:
radial-gradient(circle at 26% 22%, rgba($stage-paper, .72), rgba($stage-paper, 0) 28%),
linear-gradient(86deg, rgba($subject-mint, 0) 44%, mix($subject-mint, $subject-ink, 58%) 44.6%),
linear-gradient(174deg, mix($subject-sky, $subject-cream, 52%) 0%, mix($subject-mint, $stage-paper, 64%) 48%, mix($subject-mint, $subject-ink, 46%) 100%);
}
.cf__art--e {
background-image:
linear-gradient(118deg, rgba($stage-paper, 0) 34%, rgba($stage-paper, .82) 34.6%, rgba($stage-paper, .82) 44%, rgba($stage-paper, 0) 44.6%),
linear-gradient(62deg, rgba($stage-paper, 0) 66%, rgba($stage-paper, .62) 66.6%, rgba($stage-paper, 0) 74%),
linear-gradient(180deg, mix($subject-sky, $stage-paper, 46%) 0%, mix($subject-sky, $subject-blue, 52%) 54%, mix($subject-blue, $subject-ink, 60%) 100%);
}
// ── 표지 제목 띠. 파란 면 위 크림 글자 (5.98:1)
.cf__name {
position: relative;
padding: $sp-1 $sp-2;
background: $panel;
color: $subject-cream;
font-weight: 700;
font-size: $name-fs;
line-height: 1.4;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// ── 조작 줄
.cf__bar { display: flex; align-items: center; gap: $sp-2; }
.cf__nav {
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;
transition: transform $dur-quick $easing;
}
.cf__nav:hover { transform: scale(1.12); }
.cf__nav:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.cf__dots {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $veil;
}
.cf__dot {
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba($subject-cream, .52);
cursor: pointer;
transition: transform $dur-quick $easing, background-color $dur-quick $easing;
}
.cf__dot[aria-current="true"] { background: $color; transform: scale(1.5); }
.cf__dot:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 좁은 화면: 조작 줄을 한 단계 줄인다
@media (max-width: 360px) {
.cf { gap: $sp-1; }
.cf__nav { width: 24px; height: 24px; font-size: 15px; }
.cf__dots { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cf__card,
.cf__card::after,
.cf__nav,
.cf__dot { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(100%, 420px);
}
.cf:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.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;
}
.cf__card {
position: absolute;
left: 50%;
top: 50%;
width: clamp(80px, 24vw, 112px);
height: clamp(80px, 24vw, 112px);
padding: 0;
border: 0;
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: #17141a;
cursor: pointer;
backface-visibility: hidden;
box-shadow: 0 0 0 2px rgba(255, 247, 230, 0.3), 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate3d(calc(-50% + var(--d, 0) * clamp(48px, 15vw, 72px)), -50%, 0) rotateY(calc(var(--d, 0) * -34deg)) scale(calc(1 - 0.12 * var(--ad, 0)));
opacity: calc(3 - var(--ad, 0));
transition: transform 600ms ease-out, opacity 600ms ease-out;
}
.cf__card::after {
content: "";
position: absolute;
inset: 0;
background: #17141a;
opacity: calc(0.26 * var(--ad, 0));
pointer-events: none;
transition: opacity 600ms ease-out;
}
.cf__card[aria-current=true] {
box-shadow: 0 0 0 3px #2f6df6, 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cf__card:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__art {
position: absolute;
inset: 0;
background-repeat: no-repeat;
background-size: cover;
}
.cf__art--a {
background-image: radial-gradient(circle at 36% 30%, rgba(255, 247, 230, 0.84), rgba(255, 247, 230, 0) 30%), linear-gradient(180deg, rgba(255, 247, 230, 0) 60%, rgba(255, 247, 230, 0.34) 60.4%, rgba(255, 247, 230, 0) 70%), linear-gradient(176deg, rgb(73.8431372549%, 88.3843137255%, 94.7058823529%) 0%, rgb(13.537254902%, 24.5960784314%, 51.6078431373%) 62%, rgb(11.0901960784%, 15.5215686275%, 29.1764705882%) 100%);
}
.cf__art--b {
background-image: radial-gradient(circle at 68% 26%, rgba(255, 247, 230, 0.88), rgba(255, 247, 230, 0) 16%), linear-gradient(104deg, rgba(23, 20, 26, 0) 54%, rgb(36.3137254902%, 14.5490196078%, 10.7843137255%) 54.6%), linear-gradient(182deg, rgb(100%, 71.5294117647%, 60.5411764706%) 0%, #ff4d1f 46%, rgb(58.1490196078%, 19.9137254902%, 11.2549019608%) 100%);
}
.cf__art--c {
background-image: linear-gradient(90deg, rgba(255, 210, 63, 0) 22%, rgba(255, 210, 63, 0.58) 22.6%, rgba(255, 210, 63, 0) 26%, rgba(255, 210, 63, 0) 48%, rgba(255, 210, 63, 0.46) 48.6%, rgba(255, 210, 63, 0) 52%, rgba(255, 210, 63, 0) 74%, rgba(255, 210, 63, 0.52) 74.6%, rgba(255, 210, 63, 0) 78%), linear-gradient(180deg, rgba(23, 20, 26, 0) 64%, rgb(25.3960784314%, 21.2549019608%, 12.8078431373%) 64.6%), linear-gradient(178deg, rgb(11.2784313725%, 16.2196078431%, 30.9019607843%) 0%, #17141a 84%);
}
.cf__art--d {
background-image: radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0) 28%), linear-gradient(86deg, rgba(76, 212, 166, 0) 44%, rgb(21.0745098039%, 51.5137254902%, 42.0392156863%) 44.6%), linear-gradient(174deg, rgb(70.431372549%, 87.2784313725%, 95.2941176471%) 0%, rgb(55.0745098039%, 89.2078431373%, 77.662745098%) 48%, rgb(18.5803921569%, 42.4784313725%, 35.4509803922%) 100%);
}
.cf__art--e {
background-image: linear-gradient(118deg, rgba(255, 255, 255, 0) 34%, rgba(255, 255, 255, 0.82) 34.6%, rgba(255, 255, 255, 0.82) 44%, rgba(255, 255, 255, 0) 44.6%), linear-gradient(62deg, rgba(255, 255, 255, 0) 66%, rgba(255, 255, 255, 0.62) 66.6%, rgba(255, 255, 255, 0) 74%), linear-gradient(180deg, rgb(73.8431372549%, 90.0784313725%, 100%) 0%, rgb(31.2784313725%, 61.3019607843%, 98.3058823529%) 54%, rgb(14.6666666667%, 28.7843137255%, 61.9607843137%) 100%);
}
.cf__name {
position: relative;
padding: 4px 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cf__bar {
display: flex;
align-items: center;
gap: 8px;
}
.cf__nav {
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;
transition: transform 220ms ease-out;
}
.cf__nav:hover {
transform: scale(1.12);
}
.cf__nav:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.cf__dots {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.72);
}
.cf__dot {
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(255, 247, 230, 0.52);
cursor: pointer;
transition: transform 220ms ease-out, background-color 220ms ease-out;
}
.cf__dot[aria-current=true] {
background: #2f6df6;
transform: scale(1.5);
}
.cf__dot:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 360px) {
.cf {
gap: 4px;
}
.cf__nav {
width: 24px;
height: 24px;
font-size: 15px;
}
.cf__dots {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__card,
.cf__card::after,
.cf__nav,
.cf__dot {
transition-duration: 0s;
}
}// 스크롤 위치가 아니라 "활성 장에서 몇 칸 떨어졌는가" 가 모양을 정한다.
// JS 는 카드마다 거리 d 와 |d| 만 CSS 변수로 얹고, rotateY·translateZ·축소는 CSS 가 계산한다.
var cf = document.getElementById('cf');
var track = document.getElementById('track');
var dotsBox = document.getElementById('dots');
var cards = track.querySelectorAll('.cf__card');
var SLIDE_LABEL = '{total}枚中{n}枚目 · {name}';
var DOT_LABEL = '{n}枚目のジャケットを表示';
var total = cards.length;
var index = 0;
var timer = 0;
var dots = [];
function picker(n) { return function () { stop(); go(n); }; }
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');
}
}
function stop() {
cf.classList.remove('is-demo');
if (timer) { clearInterval(timer); timer = 0; }
}
for (var i = 0; i < total; i++) {
var name = cards[i].querySelector('.cf__name').textContent;
cards[i].setAttribute('aria-label', SLIDE_LABEL.replace('{n}', i + 1).replace('{total}', total).replace('{name}', name));
cards[i].addEventListener('click', picker(i));
var dot = document.createElement('button');
dot.type = 'button';
dot.className = 'cf__dot';
dot.setAttribute('aria-label', DOT_LABEL.replace('{n}', i + 1));
dot.addEventListener('click', picker(i));
dotsBox.appendChild(dot);
dots.push(dot);
}
document.getElementById('prev').addEventListener('click', function () { stop(); go(index - 1); });
document.getElementById('next').addEventListener('click', function () { stop(); go(index + 1); });
cf.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { stop(); go(index - 1); }
if (e.key === 'ArrowRight') { stop(); go(index + 1); }
});
cf.addEventListener('pointerdown', stop);
go(0);
// 격자 미리보기: .is-demo 가 붙어 있는 동안만 혼자 넘어간다
timer = setInterval(function () { if (cf.classList.contains('is-demo')) { go(index + 1); } }, 440);// CoverflowDepthSlider — vanilla 판(run/082/demo/03-slider-coverflow-depth-3d/index.html) 의 React 포트.
// 스크롤 위치가 아니라 "활성 장에서 몇 칸 떨어졌는가" 가 모양을 정한다.
// 컴포넌트는 카드마다 거리 d 와 |d| 만 CSS 변수로 얹고, rotateY·translateZ·축소는 CSS 가 계산한다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CoverflowDepthSlider.module.scss";
export type CoverflowDepthSliderProps = {
duration?: number;
easing?: string;
color?: string;
autoplayMs?: number;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
type CardVars = CSSProperties & { "--d"?: number; "--ad"?: number };
const REGION_LABEL = "앨범 표지 깊이 슬라이더";
const SLIDE_LABEL = "{total}장 중 {n}장 · {name}";
const DOT_LABEL = "{n}번째 표지 보기";
const PREV_LABEL = "이전 표지";
const NEXT_LABEL = "다음 표지";
type Cover = { key: string; name: string; alt: string };
const COVERS: Cover[] = [
{ key: "a", name: "새벽 호수", alt: "물안개가 낮게 덮인 잔잔한 호수 표지" },
{ key: "b", name: "붉은 사막", alt: "해 질 무렵 붉게 물든 사막 바위 표지" },
{ key: "c", name: "밤의 정거장", alt: "불 켜진 승강장이 늘어선 밤 기차 정거장 표지" },
{ key: "d", name: "초록 언덕", alt: "비가 갠 뒤 젖은 초록 언덕과 낮은 구름 표지" },
{ key: "e", name: "얼음 바다", alt: "푸른 얼음 조각이 떠 있는 겨울 바다 표지" },
];
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 CoverflowDepthSlider(props: CoverflowDepthSliderProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "ease-out";
const color = props.color ?? "#2f6df6";
const autoplayMs = props.autoplayMs ?? 440;
const total = COVERS.length;
const [index, setIndex] = useState<number>(0);
const [playing, setPlaying] = useState<boolean>(true);
useEffect(() => {
if (!playing) return;
const id = window.setInterval(() => setIndex((prev: number) => (prev + 1) % total), autoplayMs);
return () => window.clearInterval(id);
}, [playing, autoplayMs, total]);
const stop = () => setPlaying(false);
const go = (n: number) => {
stop();
setIndex(((n % total) + total) % total);
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<section
className={styles.cf}
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.box}>
<div className={styles.track}>
{COVERS.map((cover: Cover, i: number) => {
const d = i - index;
const far = Math.abs(d);
const cardVars: CardVars = { "--d": d, "--ad": far, zIndex: total - far, pointerEvents: far > 2 ? "none" : "auto" };
const name = SLIDE_LABEL.replace("{n}", String(i + 1)).replace("{total}", String(total)).replace("{name}", cover.name);
return (
<button
key={cover.key}
className={styles.card}
style={cardVars}
type="button"
aria-roledescription="slide"
aria-label={name}
aria-current={d === 0 ? "true" : "false"}
onClick={() => go(i)}
>
<span className={styles.art + " " + styles["art--" + cover.key]} role="img" aria-label={cover.alt} />
<span className={styles.name}>{cover.name}</span>
</button>
);
})}
</div>
</div>
<div className={styles.bar}>
<button className={styles.nav} type="button" aria-label={PREV_LABEL} onClick={() => go(index - 1)}>
<CaretLeft />
</button>
<div className={styles.dots}>
{COVERS.map((cover: Cover, i: number) => (
<button
key={cover.key}
className={styles.dot}
type="button"
aria-label={DOT_LABEL.replace("{n}", String(i + 1))}
aria-current={i === index ? "true" : "false"}
onClick={() => go(i)}
/>
))}
</div>
<button className={styles.nav} type="button" aria-label={NEXT_LABEL} onClick={() => go(index + 1)}>
<CaretRight />
</button>
</div>
</section>
</main>
);
}03 中央が手前に出る奥行きスライダー — 計測 累積面積 33.322% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · 画像 スライダー css 9種 — GSAPなしでレスポンシブ対応
このデモが属する記事
画像 スライダー css 9種 — GSAPなしでレスポンシブ対応画像 スライダー css とは、写真の枠を1列に並べて矢印・ドット・指で1枚ずつ見せる部品のことです。GSAPなしで9つ組んであり、JavaScript が扱うのはインデックスの数字ひとつだけ、距離と色は calc() と transiti この記事のデモをすべて見る


