04Main image slide swap
Picking a thumbnail slides the current main image out left while the new one enters from the right into the same frame, and the thumbnail outline hops to the newly picked cell.
Best forProduct detail galleries, lodging photo galleries
translateX 교차opacity활성 썸네일 링

<main class="stage">
<section class="gal is-demo" aria-label="Picking a thumbnail swaps the main image in place">
<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">Front</span></div>
<div class="gal__scene gal__scene--2"><span class="gal__tag">Side</span></div>
<div class="gal__scene gal__scene--3"><span class="gal__tag">Detail</span></div>
</div>
<div class="gal__thumbs">
<label class="gal__thumb gal__thumb--1" for="gal-1">Front</label>
<label class="gal__thumb gal__thumb--2" for="gal-2">Side</label>
<label class="gal__thumb gal__thumb--3" for="gal-3">Detail</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 Main image slide swap — Measured area 31.293% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS Screen Transition Animation Effects
The post this demo lives in
9 CSS Screen Transition Animation EffectsA CSS screen transition animation smooths every moment the screen changes — an image swap, a confirmed order, a form ste See all demos in this post


