04큰 이미지 슬라이드 교체
아래 썸네일을 고르면 지금 큰 이미지는 왼쪽으로 빠지고 새 이미지가 오른쪽에서 들어와 같은 자리를 차지하며, 썸네일 테두리도 새로 고른 칸으로 옮겨 간다.
쓰임상품 상세 갤러리, 숙소 사진 갤러리
translateX 교차opacity활성 썸네일 링

<main class="stage">
<section class="gal is-demo" aria-label="썸네일을 고르면 큰 이미지가 같은 자리에서 교체된다">
<input class="gal__radio" type="radio" name="gal-218" id="gal-1" checked>
<input class="gal__radio" type="radio" name="gal-218" id="gal-2">
<input class="gal__radio" type="radio" name="gal-218" id="gal-3">
<div class="gal__frame">
<div class="gal__scene gal__scene--1"><span class="gal__tag">정면</span></div>
<div class="gal__scene gal__scene--2"><span class="gal__tag">옆면</span></div>
<div class="gal__scene gal__scene--3"><span class="gal__tag">디테일</span></div>
</div>
<div class="gal__thumbs">
<label class="gal__thumb gal__thumb--1" for="gal-1">정면</label>
<label class="gal__thumb gal__thumb--2" for="gal-2">옆면</label>
<label class="gal__thumb gal__thumb--3" for="gal-3">디테일</label>
<span class="gal__indicator" aria-hidden="true"></span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 이미지가 자리를 바꾸는 시간
$easing: $ease-out;
$color: $subject-blue; // 1번 장면 색
@include stage($stage-paper);
.gal { width: 280px; max-width: 100%; }
.gal__radio { position: absolute; opacity: 0; pointer-events: none; }
// ── 큰 이미지 틀: 장면 3장이 겹쳐 있고 translateX 로만 교체된다
.gal__frame {
position: relative;
height: clamp(100px, 50vh, 176px);
border-radius: $r-card;
overflow: hidden;
background: rgba(23, 20, 26, .06);
}
.gal__scene {
position: absolute; inset: 0;
display: grid;
place-items: center;
transform: translateX(110%);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.gal__scene--1 { background: linear-gradient(150deg, $color, rgba($color, .35)); }
.gal__scene--2 { background: linear-gradient(150deg, $stage-orange, rgba($stage-orange, .35)); }
.gal__scene--3 { background: linear-gradient(150deg, $stage-ink, rgba($stage-ink, .5)); }
.gal__tag {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .9);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: $stage-ink;
}
.gal__scene--1 { transform: translateX(0); opacity: 1; } // 기본 = 1번
// 라디오로 고르면: 새 장면은 오른쪽에서 들어오고 나머지는 왼쪽으로 빠진다
#gal-1:checked ~ .gal__frame .gal__scene--1 { transform: translateX(0); opacity: 1; }
#gal-1:checked ~ .gal__frame .gal__scene--2,
#gal-1:checked ~ .gal__frame .gal__scene--3 { transform: translateX(-110%); opacity: 0; }
#gal-2:checked ~ .gal__frame .gal__scene--2 { transform: translateX(0); opacity: 1; }
#gal-2:checked ~ .gal__frame .gal__scene--1,
#gal-2:checked ~ .gal__frame .gal__scene--3 { transform: translateX(-110%); opacity: 0; }
#gal-3:checked ~ .gal__frame .gal__scene--3 { transform: translateX(0); opacity: 1; }
#gal-3:checked ~ .gal__frame .gal__scene--1,
#gal-3:checked ~ .gal__frame .gal__scene--2 { transform: translateX(-110%); opacity: 0; }
// ── 썸네일: 고정 폭 셀 3개 + 밑에 움직이는 인디케이터 (transform 만)
.gal__thumbs {
position: relative;
display: flex;
gap: $sp-2;
justify-content: center;
margin-top: $sp-3;
padding-bottom: $sp-2;
}
.gal__thumb {
box-sizing: border-box;
width: 88px;
padding: $sp-2 0;
border: 2px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: $stage-paper;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: rgba(23, 20, 26, .7);
text-align: center;
cursor: pointer;
transition: color $dur-quick $easing, border-color $dur-quick $easing;
}
#gal-1:checked ~ .gal__thumbs .gal__thumb--1,
#gal-2:checked ~ .gal__thumbs .gal__thumb--2,
#gal-3:checked ~ .gal__thumbs .gal__thumb--3 { color: $stage-ink; border-color: $stage-ink; }
.gal__indicator {
position: absolute;
bottom: 0; left: calc(50% - 140px); // 행의 왼쪽 끝 (280px 행 기준)
width: 88px;
height: 4px;
border-radius: $r-pill;
background: $stage-ink;
transform: translateX(0);
transition: transform $duration $easing;
}
#gal-2:checked ~ .gal__thumbs .gal__indicator { transform: translateX(96px); }
#gal-3:checked ~ .gal__thumbs .gal__indicator { transform: translateX(192px); }
// 렌더·갤러리용 자동 루프: 1→2→3→1 을 2초 주기로
.gal.is-demo .gal__scene--1 { animation: s1-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__scene--2 { animation: s2-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__scene--3 { animation: s3-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__indicator { animation: ind-loop $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--1 { animation: th1 $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--2 { animation: th2 $dur-loop $easing infinite; }
.gal.is-demo .gal__thumb--3 { animation: th3 $dur-loop $easing infinite; }
@keyframes s1-loop {
0%, 23% { transform: translateX(0); opacity: 1; }
33%, 88% { transform: translateX(-110%); opacity: 0; }
98%, 100% { transform: translateX(0); opacity: 1; }
}
@keyframes s2-loop {
0%, 23% { transform: translateX(110%); opacity: 0; }
33%, 56% { transform: translateX(0); opacity: 1; }
66%, 100% { transform: translateX(-110%); opacity: 0; }
}
@keyframes s3-loop {
0%, 56% { transform: translateX(110%); opacity: 0; }
66%, 88% { transform: translateX(0); opacity: 1; }
98%, 100% { transform: translateX(110%); opacity: 0; }
}
@keyframes ind-loop {
0%, 23% { transform: translateX(0); }
33%, 56% { transform: translateX(calc(100% + 8px)); }
66%, 88% { transform: translateX(calc(200% + 16px)); }
98%, 100% { transform: translateX(0); }
}
@keyframes th1 { // 활성 엄지 테두리가 장면과 함께 이동
0%, 23% { border-color: $stage-ink; color: $stage-ink; }
33%, 88% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
98%, 100% { border-color: $stage-ink; color: $stage-ink; }
}
@keyframes th2 {
0%, 23% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
33%, 56% { border-color: $stage-ink; color: $stage-ink; }
66%, 100% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
}
@keyframes th3 {
0%, 56% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
66%, 88% { border-color: $stage-ink; color: $stage-ink; }
98%, 100% { border-color: rgba(23, 20, 26, .16); color: rgba(23, 20, 26, .7); }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.gal__frame { height: 100px; }
.gal__thumb { width: 72px; padding: 4px 0; font-size: 10px; }
.gal__indicator { width: 72px; left: calc(50% - 116px); }
#gal-2:checked ~ .gal__thumbs .gal__indicator { transform: translateX(80px); }
#gal-3:checked ~ .gal__thumbs .gal__indicator { transform: translateX(160px); }
}
@media (prefers-reduced-motion: reduce) {
.gal__scene, .gal__thumb, .gal__indicator { transition: none; }
.gal.is-demo .gal__scene,
.gal.is-demo .gal__indicator,
.gal.is-demo .gal__thumb { animation: 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);
}
.gal {
width: 280px;
max-width: 100%;
}
.gal__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.gal__frame {
position: relative;
height: clamp(100px, 50vh, 176px);
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
}
.gal__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transform: translateX(110%);
opacity: 0;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.gal__scene--1 {
background: linear-gradient(150deg, #2f6df6, rgba(47, 109, 246, 0.35));
}
.gal__scene--2 {
background: linear-gradient(150deg, #ff4d1f, rgba(255, 77, 31, 0.35));
}
.gal__scene--3 {
background: linear-gradient(150deg, #17141a, rgba(23, 20, 26, 0.5));
}
.gal__tag {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.9);
font-weight: 800;
font-size: clamp(12px, 3.4vw, 14px);
font-family: inherit;
color: #17141a;
}
.gal__scene--1 {
transform: translateX(0);
opacity: 1;
}
#gal-1:checked ~ .gal__frame .gal__scene--1 {
transform: translateX(0);
opacity: 1;
}
#gal-1:checked ~ .gal__frame .gal__scene--2,
#gal-1:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(-110%);
opacity: 0;
}
#gal-2:checked ~ .gal__frame .gal__scene--2 {
transform: translateX(0);
opacity: 1;
}
#gal-2:checked ~ .gal__frame .gal__scene--1,
#gal-2:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(-110%);
opacity: 0;
}
#gal-3:checked ~ .gal__frame .gal__scene--3 {
transform: translateX(0);
opacity: 1;
}
#gal-3:checked ~ .gal__frame .gal__scene--1,
#gal-3:checked ~ .gal__frame .gal__scene--2 {
transform: translateX(-110%);
opacity: 0;
}
.gal__thumbs {
position: relative;
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
padding-bottom: 8px;
}
.gal__thumb {
box-sizing: border-box;
width: 88px;
padding: 8px 0;
border: 2px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #ffffff;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: rgba(23, 20, 26, 0.7);
text-align: center;
cursor: pointer;
transition: color 220ms ease-out, border-color 220ms ease-out;
}
#gal-1:checked ~ .gal__thumbs .gal__thumb--1,
#gal-2:checked ~ .gal__thumbs .gal__thumb--2,
#gal-3:checked ~ .gal__thumbs .gal__thumb--3 {
color: #17141a;
border-color: #17141a;
}
.gal__indicator {
position: absolute;
bottom: 0;
left: calc(50% - 140px);
width: 88px;
height: 4px;
border-radius: 999px;
background: #17141a;
transform: translateX(0);
transition: transform 300ms ease-out;
}
#gal-2:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(96px);
}
#gal-3:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(192px);
}
.gal.is-demo .gal__scene--1 {
animation: s1-loop 2s ease-out infinite;
}
.gal.is-demo .gal__scene--2 {
animation: s2-loop 2s ease-out infinite;
}
.gal.is-demo .gal__scene--3 {
animation: s3-loop 2s ease-out infinite;
}
.gal.is-demo .gal__indicator {
animation: ind-loop 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--1 {
animation: th1 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--2 {
animation: th2 2s ease-out infinite;
}
.gal.is-demo .gal__thumb--3 {
animation: th3 2s ease-out infinite;
}
@keyframes s1-loop {
0%, 23% {
transform: translateX(0);
opacity: 1;
}
33%, 88% {
transform: translateX(-110%);
opacity: 0;
}
98%, 100% {
transform: translateX(0);
opacity: 1;
}
}
@keyframes s2-loop {
0%, 23% {
transform: translateX(110%);
opacity: 0;
}
33%, 56% {
transform: translateX(0);
opacity: 1;
}
66%, 100% {
transform: translateX(-110%);
opacity: 0;
}
}
@keyframes s3-loop {
0%, 56% {
transform: translateX(110%);
opacity: 0;
}
66%, 88% {
transform: translateX(0);
opacity: 1;
}
98%, 100% {
transform: translateX(110%);
opacity: 0;
}
}
@keyframes ind-loop {
0%, 23% {
transform: translateX(0);
}
33%, 56% {
transform: translateX(calc(100% + 8px));
}
66%, 88% {
transform: translateX(calc(200% + 16px));
}
98%, 100% {
transform: translateX(0);
}
}
@keyframes th1 {
0%, 23% {
border-color: #17141a;
color: #17141a;
}
33%, 88% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
98%, 100% {
border-color: #17141a;
color: #17141a;
}
}
@keyframes th2 {
0%, 23% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
33%, 56% {
border-color: #17141a;
color: #17141a;
}
66%, 100% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
}
@keyframes th3 {
0%, 56% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
66%, 88% {
border-color: #17141a;
color: #17141a;
}
98%, 100% {
border-color: rgba(23, 20, 26, 0.16);
color: rgba(23, 20, 26, 0.7);
}
}
@media (max-width: 360px) {
.gal__frame {
height: 100px;
}
.gal__thumb {
width: 72px;
padding: 4px 0;
font-size: 10px;
}
.gal__indicator {
width: 72px;
left: calc(50% - 116px);
}
#gal-2:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(80px);
}
#gal-3:checked ~ .gal__thumbs .gal__indicator {
transform: translateX(160px);
}
}
@media (prefers-reduced-motion: reduce) {
.gal__scene, .gal__thumb, .gal__indicator {
transition: none;
}
.gal.is-demo .gal__scene,
.gal.is-demo .gal__indicator,
.gal.is-demo .gal__thumb {
animation: none;
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./ProductImageSwap.module.scss";
export type ProductImageSwapProps = {
/** 장면 이름 순서 — 썸네일 라벨과 같다 */
scenes: string[];
/** 장면 배경색 순서 (장면 수와 같게) */
colors?: string[];
/** 이미지가 자리를 바꾸는 시간 (기본 300ms) */
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
export default function ProductImageSwap({
scenes,
colors = ["#2f6df6", "#ff4d1f", "#17141a"],
duration = 300,
easing = "ease-out",
}: ProductImageSwapProps) {
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
return (
<section className={styles.gal} style={vars} aria-label="상품 이미지 갤러리">
<div className={styles.frame}>
{scenes.map((name, i) => (
<div
key={name}
className={styles.scene}
data-active={i === active}
style={{ background: `linear-gradient(150deg, ${colors[i % colors.length]}, rgba(0,0,0,.25))` }}
>
<span className={styles.tag}>{name}</span>
</div>
))}
</div>
<div className={styles.thumbs}>
{scenes.map((name, i) => (
<button
key={name}
type="button"
className={`${styles.thumb} ${i === active ? styles.thumbOn : ""}`}
aria-pressed={i === active}
onClick={() => setActive(i)}
>
{name}
</button>
))}
<span className={styles.indicator} data-index={Math.min(active, 2)} aria-hidden="true" />
</div>
</section>
);
}04 큰 이미지 슬라이드 교체 — 측정 누적면적 31.293% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 화면 전환 효과 9종 — 화면이 바뀌는 순간 모음
이 데모가 속한 글
CSS 화면 전환 효과 9종 — 화면이 바뀌는 순간 모음CSS 화면 전환 효과는 페이지를 새로 불러오지 않고 한 화면 안에서 무엇이 다른 모습으로 바뀔 때, 그 바뀜을 부드럽게 눈에 보여 주는 장치입니다. 장이 바뀌듯 화면이 바뀌는 순간마다 붙일 수 있는 아홉 가지를 전 이 글의 데모 전부 보기


