04重ねて溶け合うフェードスライドショー
写真を同じ位置に重ね、opacity だけを反対方向に動かして場面を変えるので、トラックの移動距離が 0 になり狭い画面でも横スクロールが出ません。
使いどころ背景写真がゆっくり変わる紹介セクション、レビュー写真の入れ替え
opacityaria-hiddentransition

<main class="stage">
<div class="xf is-demo" id="xf">
<div class="xf__view" id="view" role="region" aria-roledescription="carousel" aria-label="背景写真がゆっくり変わるスライドショー" tabindex="0">
<div class="xf__shot is-on" role="group" aria-roledescription="slide" aria-label="4枚中1枚目" aria-hidden="false">
<span class="xf__photo xf__photo--rain" role="img" aria-label="雨上がりの濡れた路地に看板の灯りがにじむ風景"></span>
<span class="xf__cap">雨上がりの路地</span>
</div>
<div class="xf__shot" role="group" aria-roledescription="slide" aria-label="4枚中2枚目" aria-hidden="true">
<span class="xf__photo xf__photo--dusk" role="img" aria-label="夕暮れの屋上から見下ろすオレンジ色の空"></span>
<span class="xf__cap">夕暮れの屋上</span>
</div>
<div class="xf__shot" role="group" aria-roledescription="slide" aria-label="4枚中3枚目" aria-hidden="true">
<span class="xf__photo xf__photo--fog" role="img" aria-label="朝霧が低く垂れこめる湖と葦原"></span>
<span class="xf__cap">霧の湖</span>
</div>
<div class="xf__shot" role="group" aria-roledescription="slide" aria-label="4枚中4枚目" aria-hidden="true">
<span class="xf__photo xf__photo--snow" role="img" aria-label="雪が積もった庭と低い塀"></span>
<span class="xf__cap">雪の庭</span>
</div>
</div>
<div class="xf__bar">
<button class="xf__nav" type="button" data-step="-1" aria-label="前のスライド"><svg viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true" focusable="false"><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>
<span class="xf__dots" role="group" aria-label="スライドを選ぶ">
<button class="xf__dot" type="button" data-go="0" aria-label="1枚目" aria-current="true"></button>
<button class="xf__dot" type="button" data-go="1" aria-label="2枚目" aria-current="false"></button>
<button class="xf__dot" type="button" data-go="2" aria-label="3枚目" aria-current="false"></button>
<button class="xf__dot" type="button" data-go="3" aria-label="4枚目" aria-current="false"></button>
</span>
<button class="xf__nav" type="button" data-step="1" aria-label="次のスライド"><svg viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true" focusable="false"><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>
</main>@use "../../../../site/static/demo-tokens" as *;
// 겹쳐 두고 서로 녹는 페이드 슬라이드쇼.
// 네 장을 같은 격자 칸(1 / 1)에 포개 두고 켜진 장만 opacity 1, 나머지는 0 으로 둔다.
// 트랙이 밀려나는 거리가 0 이라 좁은 화면에서 가로 스크롤이 생길 여지 자체가 없다 —
// 이것이 트랙 이동형(01)과 갈리는 지점이다. 바뀌는 값은 opacity 하나뿐이라
// 합성 단계에서만 처리되고 레이아웃을 다시 계산하지 않는다.
// 화면에서 사라진 장은 aria-hidden="true" 로 접근성 트리에서도 함께 빠진다.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue-deep;
$line: rgba($subject-ink, .18);
@include stage($stage-paper);
.xf {
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 400px);
}
// ── 포개지는 자리. 한 격자 칸에 네 장이 전부 올라간다
.xf__view {
position: relative; display: grid;
height: clamp(104px, 38vw, 172px);
border-radius: $r-card; overflow: hidden;
background: $subject-ink;
}
.xf__view:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.xf__shot {
grid-area: 1 / 1; position: relative;
opacity: 0;
transition: opacity $duration $easing;
}
.xf__shot.is-on { opacity: 1; }
// ── 사진 자리 (FLUX 대기 중 — 지금은 토큰 팔레트로 합성한 장면 그라디언트)
.xf__photo { position: absolute; inset: 0; display: block; }
.xf__photo--rain {
background:
linear-gradient(96deg, rgba($stage-yellow, 0) 26%, rgba($stage-yellow, .34) 30%, rgba($stage-yellow, 0) 34%),
linear-gradient(to bottom, rgba($subject-ink, 0) 63%, rgba($subject-ink, .44) 63.6%, rgba($subject-ink, .62)),
radial-gradient(58% 42% at 76% 22%, rgba($subject-cream, .34), rgba($subject-cream, 0) 70%),
linear-gradient(166deg, $subject-blue-deep 6%, $subject-sky 96%);
}
.xf__photo--dusk {
background:
linear-gradient(to bottom, rgba($subject-ink, 0) 68%, rgba($subject-ink, .54) 68.6%, rgba($subject-ink, .72)),
radial-gradient(30% 26% at 70% 30%, rgba($stage-yellow, .88), rgba($stage-yellow, 0) 72%),
linear-gradient(172deg, $stage-orange 10%, $subject-pink 94%);
}
.xf__photo--fog {
background:
linear-gradient(to bottom, rgba($subject-cream, 0) 70%, rgba($subject-cream, .44) 70.6%, rgba($subject-cream, .14)),
linear-gradient(to top, rgba($subject-cream, .46), rgba($subject-cream, 0) 46%),
radial-gradient(70% 30% at 40% 64%, rgba($subject-cream, .40), rgba($subject-cream, 0) 76%),
linear-gradient(158deg, $chart-3 4%, $subject-mint 98%);
}
.xf__photo--snow {
background:
linear-gradient(to bottom, rgba($stage-paper, 0) 58%, rgba($stage-paper, .76) 58.6%, rgba($stage-paper, .92)),
linear-gradient(to bottom, rgba($subject-ink, 0) 52%, rgba($subject-ink, .26) 52.4%, rgba($subject-ink, 0) 58%),
radial-gradient(52% 40% at 26% 24%, rgba($stage-paper, .70), rgba($stage-paper, 0) 74%),
linear-gradient(150deg, $subject-lilac 8%, $subject-cream 96%);
}
.xf__cap {
position: absolute; left: $sp-3; bottom: $sp-3;
padding: $sp-1 $sp-3; border-radius: $r-pill;
background: $color; color: $subject-cream;
font-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 1.5;
white-space: nowrap;
}
// ── 조작 줄
.xf__bar { display: flex; align-items: center; justify-content: center; gap: $sp-3; }
.xf__nav {
display: grid; place-items: center;
width: 28px; height: 28px; padding: 0;
border: 1px solid $line; border-radius: $r-pill;
background: $stage-paper; color: $subject-ink;
font-size: 14px; cursor: pointer;
transition: transform $dur-quick $easing;
}
.xf__nav:hover { transform: scale(1.08); }
.xf__nav:active { transform: scale(.94); }
.xf__nav:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.xf__dots { display: flex; align-items: center; gap: $sp-2; }
.xf__dot {
width: 8px; height: 8px; padding: 0;
border: 0; border-radius: 50%;
background: rgba($subject-ink, .26);
cursor: pointer;
transition: transform $dur-quick $easing, background-color $dur-quick $easing;
}
.xf__dot[aria-current="true"] { background: $color; transform: scale(1.5); }
.xf__dot:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 좁은 화면: 조작 줄과 사진 높이를 함께 줄인다
@media (max-width: 340px) {
.xf { gap: $sp-2; }
.xf__cap { left: $sp-2; bottom: $sp-2; padding: 0 $sp-2; }
.xf__nav { width: 24px; height: 24px; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.xf__shot, .xf__nav, .xf__dot { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.xf {
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 400px);
}
.xf__view {
position: relative;
display: grid;
height: clamp(104px, 38vw, 172px);
border-radius: 16px;
overflow: hidden;
background: #17141a;
}
.xf__view:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.xf__shot {
grid-area: 1/1;
position: relative;
opacity: 0;
transition: opacity 600ms ease-out;
}
.xf__shot.is-on {
opacity: 1;
}
.xf__photo {
position: absolute;
inset: 0;
display: block;
}
.xf__photo--rain {
background: linear-gradient(96deg, rgba(255, 210, 63, 0) 26%, rgba(255, 210, 63, 0.34) 30%, rgba(255, 210, 63, 0) 34%), linear-gradient(to bottom, rgba(23, 20, 26, 0) 63%, rgba(23, 20, 26, 0.44) 63.6%, rgba(23, 20, 26, 0.62)), radial-gradient(58% 42% at 76% 22%, rgba(255, 247, 230, 0.34), rgba(255, 247, 230, 0) 70%), linear-gradient(166deg, #0a4fe8 6%, #6ec8ff 96%);
}
.xf__photo--dusk {
background: linear-gradient(to bottom, rgba(23, 20, 26, 0) 68%, rgba(23, 20, 26, 0.54) 68.6%, rgba(23, 20, 26, 0.72)), radial-gradient(30% 26% at 70% 30%, rgba(255, 210, 63, 0.88), rgba(255, 210, 63, 0) 72%), linear-gradient(172deg, #ff4d1f 10%, #ff9ebb 94%);
}
.xf__photo--fog {
background: linear-gradient(to bottom, rgba(255, 247, 230, 0) 70%, rgba(255, 247, 230, 0.44) 70.6%, rgba(255, 247, 230, 0.14)), linear-gradient(to top, rgba(255, 247, 230, 0.46), rgba(255, 247, 230, 0) 46%), radial-gradient(70% 30% at 40% 64%, rgba(255, 247, 230, 0.4), rgba(255, 247, 230, 0) 76%), linear-gradient(158deg, #2b9d74 4%, #4cd4a6 98%);
}
.xf__photo--snow {
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 58%, rgba(255, 255, 255, 0.76) 58.6%, rgba(255, 255, 255, 0.92)), linear-gradient(to bottom, rgba(23, 20, 26, 0) 52%, rgba(23, 20, 26, 0.26) 52.4%, rgba(23, 20, 26, 0) 58%), radial-gradient(52% 40% at 26% 24%, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0) 74%), linear-gradient(150deg, #b9a0ff 8%, #fff7e6 96%);
}
.xf__cap {
position: absolute;
left: 12px;
bottom: 12px;
padding: 4px 12px;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.5;
white-space: nowrap;
}
.xf__bar {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.xf__nav {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(23, 20, 26, 0.18);
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-size: 14px;
cursor: pointer;
transition: transform 220ms ease-out;
}
.xf__nav:hover {
transform: scale(1.08);
}
.xf__nav:active {
transform: scale(0.94);
}
.xf__nav:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.xf__dots {
display: flex;
align-items: center;
gap: 8px;
}
.xf__dot {
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.26);
cursor: pointer;
transition: transform 220ms ease-out, background-color 220ms ease-out;
}
.xf__dot[aria-current=true] {
background: #0a4fe8;
transform: scale(1.5);
}
.xf__dot:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.xf {
gap: 8px;
}
.xf__cap {
left: 8px;
bottom: 8px;
padding: 0 8px;
}
.xf__nav {
width: 24px;
height: 24px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.xf__shot, .xf__nav, .xf__dot {
transition: none;
}
}var root = document.getElementById('xf');
var view = document.getElementById('view');
var shots = Array.prototype.slice.call(root.querySelectorAll('.xf__shot'));
var dots = Array.prototype.slice.call(root.querySelectorAll('.xf__dot'));
var index = 0;
var timer = 0;
// 트랙이 없다. 장은 한자리에 포개져 있고 바뀌는 값은 opacity 하나뿐이라 이동 거리가 0 이다
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');
});
}
function stopPreview() {
root.classList.remove('is-demo');
if (timer) { clearInterval(timer); timer = 0; }
}
root.addEventListener('click', function (e) {
var nav = e.target.closest('[data-step]');
var dot = e.target.closest('[data-go]');
if (!nav && !dot) { return; }
stopPreview();
show(nav ? index + Number(nav.getAttribute('data-step')) : Number(dot.getAttribute('data-go')));
});
view.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') { return; }
e.preventDefault();
stopPreview();
show(index + (e.key === 'ArrowRight' ? 1 : -1));
});
if (root.classList.contains('is-demo')) {
timer = setInterval(function () { show(index + 1); }, 720);
}// CrossfadeSlideshow — vanilla 판(run/082/demo/04-slider-crossfade-slideshow/index.html) 의 React 포트.
// 네 장을 같은 격자 칸에 포개 두고 켜진 장만 opacity 1 로 둔다. 트랙이 없으니 이동 거리가 0 이다.
// 인덱스 하나를 화살표·점·사진이 함께 보고, 화면에서 사라진 장은 aria-hidden 으로 접근성 트리에서도 빠진다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CrossfadeSlideshow.module.scss";
export type CrossfadeSlideshowProps = {
duration?: number;
easing?: string;
color?: string;
interval?: number;
autoPlay?: boolean;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Shot = { tone: string; cap: string; alt: string; slide: string; dot: string };
const REGION = "배경 사진이 천천히 바뀌는 슬라이드쇼";
const DOTS_LABEL = "장 고르기";
const PREV = "이전 장";
const NEXT = "다음 장";
const SHOTS: Shot[] = [
{ tone: "rain", cap: "비 온 뒤 골목", alt: "비 온 뒤 젖은 골목 바닥에 간판 불빛이 번진 풍경", slide: "4장 중 1장", dot: "1번째 장" },
{ tone: "dusk", cap: "해 질 녘 옥상", alt: "해 질 녘 옥상에서 내려다본 주황빛 하늘", slide: "4장 중 2장", dot: "2번째 장" },
{ tone: "fog", cap: "안개 낀 호수", alt: "새벽 안개가 낮게 깔린 호수와 갈대밭", slide: "4장 중 3장", dot: "3번째 장" },
{ tone: "snow", cap: "눈 덮인 마당", alt: "눈이 소복이 쌓인 마당과 낮은 담장", slide: "4장 중 4장", dot: "4번째 장" },
];
export default function CrossfadeSlideshow(props: CrossfadeSlideshowProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "ease-out";
const color = props.color ?? "#0a4fe8";
const interval = props.interval ?? 720;
const [index, setIndex] = useState<number>(0);
const [preview, setPreview] = useState<boolean>(props.autoPlay ?? true);
useEffect(() => {
if (!preview) { return; }
const id = window.setInterval(() => {
setIndex((prev: number) => (prev + 1) % SHOTS.length);
}, interval);
return () => window.clearInterval(id);
}, [preview, interval]);
const go = (next: number) => {
setPreview(false);
setIndex((next + SHOTS.length) % SHOTS.length);
};
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={styles.root} style={vars}>
<div
className={styles.view}
role="region"
aria-roledescription="carousel"
aria-label={REGION}
tabIndex={0}
onKeyDown={(e: { key: string; preventDefault: () => void }) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") { return; }
e.preventDefault();
go(index + (e.key === "ArrowRight" ? 1 : -1));
}}
>
{SHOTS.map((shot, i) => (
<div
key={shot.tone}
className={styles.shot + (i === index ? " " + styles.isOn : "")}
role="group"
aria-roledescription="slide"
aria-label={shot.slide}
aria-hidden={i === index ? "false" : "true"}
>
<span className={styles.photo + " " + styles["photo--" + shot.tone]} role="img" aria-label={shot.alt} />
<span className={styles.cap}>{shot.cap}</span>
</div>
))}
</div>
<div className={styles.bar}>
<button className={styles.nav} type="button" aria-label={PREV} onClick={() => go(index - 1)}>
<svg viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true" focusable="false">
<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>
<span className={styles.dots} role="group" aria-label={DOTS_LABEL}>
{SHOTS.map((shot, i) => (
<button
key={shot.dot}
className={styles.dot}
type="button"
aria-label={shot.dot}
aria-current={i === index ? "true" : "false"}
onClick={() => go(i)}
/>
))}
</span>
<button className={styles.nav} type="button" aria-label={NEXT} onClick={() => go(index + 1)}>
<svg viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true" focusable="false">
<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>
</main>
);
}04 重ねて溶け合うフェードスライドショー — 計測 累積面積 48.004% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · 画像 スライダー css 9種 — GSAPなしでレスポンシブ対応
このデモが属する記事
画像 スライダー css 9種 — GSAPなしでレスポンシブ対応画像 スライダー css とは、写真の枠を1列に並べて矢印・ドット・指で1枚ずつ見せる部品のことです。GSAPなしで9つ組んであり、JavaScript が扱うのはインデックスの数字ひとつだけ、距離と色は calc() と transiti この記事のデモをすべて見る

