04Lightbox Navigated by Swiping Next and Previous
Dragging left or right while the lightbox is open pushes the current image out and slides the next or previous one into its place (a different demo from 082's inline carousel slider — this one advances inside a full-screen overlay alongside a numbered counter).
Best forMulti-photo product and travel lightboxes
Pointer EventssetPointerCapturevelocity threshold

<main class="stage">
<div class="scene is-demo" id="scene">
<ul class="grid" id="grid">
<li><button class="thumb" id="thumb0" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--a"></span></button></li>
<li><button class="thumb" id="thumb1" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--b"></span></button></li>
<li><button class="thumb" id="thumb2" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--c"></span></button></li>
</ul>
<div class="backdrop" id="backdrop"></div>
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="Browse photos">
<div class="viewport" id="viewport">
<div class="track" id="track">
<span class="fullImg fullImg--a"></span>
<span class="fullImg fullImg--b"></span>
<span class="fullImg fullImg--c"></span>
</div>
</div>
<button class="navBtn navBtn--prev" id="prevBtn" type="button" aria-label="Previous photo"><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>
<button class="navBtn navBtn--next" id="nextBtn" type="button" aria-label="Next photo"><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>
<p class="counter" id="counter" aria-live="polite">1 / 3</p>
<button class="closeBtn" id="closeBtn" type="button">Close</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-light;
// 계산 겹: index.html 의 pointermove 가 매 이동마다 속도(px/ms)를 계산하고, pointerup 에서
// 드래그 거리 비율 또는 그 속도가 문턱을 넘는지로 다음/이전 커밋 여부를 판정한다.
@include stage($stage-paper);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #f3f1ea, $stage-paper 70%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-4;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
&:focus-visible .thumbImg { outline: 2px solid $color; outline-offset: 2px; }
}
.thumbImg {
display: block;
width: 58px;
height: 58px;
border-radius: $r-sm;
box-shadow: $shadow-press;
&--a { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
&--b { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
&--c { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
}
.backdrop {
position: absolute;
inset: 0;
background: $subject-ink;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.viewport {
position: relative;
width: 220px;
height: 130px;
border-radius: $r-card;
overflow: hidden;
touch-action: pan-y;
}
.track {
display: flex;
width: 660px;
height: 100%;
}
.fullImg {
display: block;
width: 220px;
height: 100%;
flex: 0 0 auto;
&--a { background: linear-gradient(135deg, $subject-blue, $subject-lilac); }
&--b { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
&--c { background: linear-gradient(135deg, $subject-pink, $subject-lilac); }
}
.navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
appearance: none;
border: none;
width: 28px;
height: 28px;
border-radius: $r-pill;
display: grid;
place-items: center;
background: rgba(#000, .38);
color: $subject-cream;
font-size: 16px;
cursor: pointer;
&--prev { left: $sp-2; }
&--next { right: $sp-2; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.counter {
position: absolute;
bottom: $sp-2;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 10px;
font-weight: 700;
color: $subject-cream;
background: rgba(#000, .4);
padding: $sp-1 $sp-2;
border-radius: $r-pill;
}
.closeBtn {
position: absolute;
top: $sp-2;
right: $sp-2;
appearance: none;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 11px;
font-weight: 600;
color: $subject-ink;
background: rgba($subject-cream, .92);
cursor: pointer;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
@media (max-width: 340px) {
.scene { width: 220px; height: 124px; }
.viewport { width: 160px; height: 96px; }
.track { width: 480px; }
.fullImg { width: 160px; }
.navBtn { width: 24px; height: 24px; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 스와이프 커밋·백드롭 트윈은 index.html 의 animate() 가 reduce 시 즉시 최종 상태로 처리한다.
.closeBtn, .navBtn, .thumb { 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);
}
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: linear-gradient(160deg, #f3f1ea, #ffffff 70%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 58px;
height: 58px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.thumbImg--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.thumbImg--b {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.thumbImg--c {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.backdrop {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.viewport {
position: relative;
width: 220px;
height: 130px;
border-radius: 16px;
overflow: hidden;
touch-action: pan-y;
}
.track {
display: flex;
width: 660px;
height: 100%;
}
.fullImg {
display: block;
width: 220px;
height: 100%;
flex: 0 0 auto;
}
.fullImg--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.fullImg--b {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.fullImg--c {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
appearance: none;
border: none;
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.38);
color: #fff7e6;
font-size: 16px;
cursor: pointer;
}
.navBtn--prev {
left: 8px;
}
.navBtn--next {
right: 8px;
}
.navBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.counter {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 10px;
font-weight: 700;
color: #fff7e6;
background: rgba(0, 0, 0, 0.4);
padding: 4px 8px;
border-radius: 999px;
}
.closeBtn {
position: absolute;
top: 8px;
right: 8px;
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #17141a;
background: rgba(255, 247, 230, 0.92);
cursor: pointer;
}
.closeBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.viewport {
width: 160px;
height: 96px;
}
.track {
width: 480px;
}
.fullImg {
width: 160px;
}
.navBtn {
width: 24px;
height: 24px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .navBtn, .thumb {
transition: none;
}
}(function () {
var SWIPE_COMMIT_RATIO = 0.25; // 드래그 거리 / 뷰포트 폭 이 이 비율을 넘으면 커밋
var SWIPE_VELOCITY_PX_MS = 0.5; // 놓는 순간 속도(px/ms) 가 이 값을 넘어도 커밋
var SNAP_MS = 320;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var viewport = document.getElementById('viewport');
var track = document.getElementById('track');
var prevBtn = document.getElementById('prevBtn');
var nextBtn = document.getElementById('nextBtn');
var counter = document.getElementById('counter');
var closeBtn = document.getElementById('closeBtn');
var thumbs = [].slice.call(document.querySelectorAll('.thumb'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var COUNT = 3;
var idx = 0;
var open = false;
var dragging = false;
var dragStartX = 0;
var dragBaseX = 0;
var lastMoveX = 0;
var lastMoveT = 0;
var velocity = 0;
var timers = [];
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function viewportW() { return viewport.getBoundingClientRect().width; }
function animate(duration, onFrame, onDone) {
if (reduce) { onFrame(1); if (onDone) onDone(); return; }
var start = performance.now();
function frame(now) {
var p = clamp((now - start) / duration, 0, 1);
onFrame(easeOutCubic(p));
if (p < 1) requestAnimationFrame(frame);
else if (onDone) onDone();
}
requestAnimationFrame(frame);
}
function setTrackX(px) { track.style.transform = 'translateX(' + px.toFixed(2) + 'px)'; }
function baseXFor(i) { return -i * viewportW(); }
function goTo(nextIdx, onDone) {
nextIdx = clamp(nextIdx, 0, COUNT - 1);
var fromX = baseXFor(idx);
var toX = baseXFor(nextIdx);
animate(SNAP_MS, function (e) {
setTrackX(fromX + (toX - fromX) * e);
}, function () {
idx = nextIdx;
counter.textContent = (idx + 1) + ' / ' + COUNT;
if (onDone) onDone();
});
}
function openFrom(startIdx) {
if (open) return;
open = true;
idx = startIdx;
setTrackX(baseXFor(idx));
counter.textContent = (idx + 1) + ' / ' + COUNT;
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
backdrop.style.opacity = '0';
animate(260, function (e) { backdrop.style.opacity = (e * 0.78).toFixed(3); }, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(240, function (e) { backdrop.style.opacity = (0.78 * (1 - e)).toFixed(3); }, function () {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumbs[idx].focus({ preventScroll: true });
});
}
function human() {
if (scene.classList.contains('is-demo')) {
scene.classList.remove('is-demo');
timers.forEach(function (id) { clearTimeout(id); clearInterval(id); });
timers = [];
}
}
thumbs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); openFrom(i); });
});
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
prevBtn.addEventListener('click', function () { human(); goTo(idx - 1); });
nextBtn.addEventListener('click', function () { human(); goTo(idx + 1); });
document.addEventListener('keydown', function (e) {
if (!open) return;
if (e.key === 'Escape') { human(); closeLightbox(); }
else if (e.key === 'ArrowLeft') { human(); goTo(idx - 1); }
else if (e.key === 'ArrowRight') { human(); goTo(idx + 1); }
});
viewport.addEventListener('pointerdown', function (e) {
human();
dragging = true;
dragStartX = e.clientX;
dragBaseX = baseXFor(idx);
lastMoveX = e.clientX;
lastMoveT = performance.now();
velocity = 0;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) return;
var now = performance.now();
var dt = now - lastMoveT;
if (dt > 0) velocity = (e.clientX - lastMoveX) / dt;
lastMoveX = e.clientX;
lastMoveT = now;
setTrackX(dragBaseX + (e.clientX - dragStartX));
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
var deltaX = e.clientX - dragStartX;
var w = viewportW();
var commit = Math.abs(deltaX) > w * SWIPE_COMMIT_RATIO || Math.abs(velocity) > SWIPE_VELOCITY_PX_MS;
if (commit) {
goTo(idx + (deltaX < 0 ? 1 : -1));
} else {
goTo(idx);
}
}
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);
function cycle() {
openFrom(0);
timers.push(setTimeout(function () { goTo(1); }, 420));
timers.push(setTimeout(function () { goTo(2); }, 1020));
timers.push(setTimeout(closeLightbox, 1620));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './SwipeNextPrev.module.scss';
const SWIPE_COMMIT_RATIO = 0.25; // 드래그 거리 / 뷰포트 폭 이 이 비율을 넘으면 커밋
const SWIPE_VELOCITY_PX_MS = 0.5; // 놓는 순간 속도(px/ms) 가 이 값을 넘어도 커밋
const SNAP_MS = 320;
const LOOP_MS = 2000;
const COUNT = 3;
export default function SwipeNextPrev() {
const sceneRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const scene0 = sceneRef.current;
if (!scene0) return undefined;
const backdrop0 = scene0.querySelector<HTMLDivElement>('.' + styles.backdrop);
const lightbox0 = scene0.querySelector<HTMLDivElement>('.' + styles.lightbox);
const viewport0 = scene0.querySelector<HTMLDivElement>('.' + styles.viewport);
const track0 = scene0.querySelector<HTMLDivElement>('.' + styles.track);
const prevBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles['navBtn--prev']);
const nextBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles['navBtn--next']);
const counter0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.counter);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumbs = Array.from(scene0.querySelectorAll<HTMLButtonElement>('.' + styles.thumb));
if (!backdrop0 || !lightbox0 || !viewport0 || !track0 || !prevBtn0 || !nextBtn0 || !counter0 || !closeBtn0 || thumbs.length === 0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const viewport = viewport0;
const track = track0;
const prevBtn = prevBtn0;
const nextBtn = nextBtn0;
const counter = counter0;
const closeBtn = closeBtn0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let idx = 0;
let open = false;
let dragging = false;
let dragStartX = 0;
let dragBaseX = 0;
let lastMoveX = 0;
let lastMoveT = 0;
let velocity = 0;
let timers: number[] = [];
function clamp(v: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function viewportW() { return viewport.getBoundingClientRect().width; }
function animate(duration: number, onFrame: (e: number) => void, onDone?: () => void) {
if (reduce) { onFrame(1); if (onDone) onDone(); return; }
const start = performance.now();
function frame(now: number) {
const p = clamp((now - start) / duration, 0, 1);
onFrame(easeOutCubic(p));
if (p < 1) requestAnimationFrame(frame);
else if (onDone) onDone();
}
requestAnimationFrame(frame);
}
function setTrackX(px: number) { track.style.transform = 'translateX(' + px.toFixed(2) + 'px)'; }
function baseXFor(i: number) { return -i * viewportW(); }
function goTo(nextIdxRaw: number, onDone?: () => void) {
const nextIdx = clamp(nextIdxRaw, 0, COUNT - 1);
const fromX = baseXFor(idx);
const toX = baseXFor(nextIdx);
animate(SNAP_MS, (e) => {
setTrackX(fromX + (toX - fromX) * e);
}, () => {
idx = nextIdx;
counter.textContent = (idx + 1) + ' / ' + COUNT;
if (onDone) onDone();
});
}
function openFrom(startIdx: number) {
if (open) return;
open = true;
idx = startIdx;
setTrackX(baseXFor(idx));
counter.textContent = (idx + 1) + ' / ' + COUNT;
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
backdrop.style.opacity = '0';
animate(260, (e) => { backdrop.style.opacity = (e * 0.78).toFixed(3); }, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(240, (e) => { backdrop.style.opacity = (0.78 * (1 - e)).toFixed(3); }, () => {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumbs[idx].focus({ preventScroll: true });
});
}
function human() {
if (scene.classList.contains(styles['is-demo'])) {
scene.classList.remove(styles['is-demo']);
timers.forEach((id) => { window.clearTimeout(id); window.clearInterval(id); });
timers = [];
}
}
const thumbHandlers = thumbs.map((_, i) => () => { human(); openFrom(i); });
thumbs.forEach((t, i) => t.addEventListener('click', thumbHandlers[i]));
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
function onPrevClick() { human(); goTo(idx - 1); }
function onNextClick() { human(); goTo(idx + 1); }
function onKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') { human(); closeLightbox(); }
else if (e.key === 'ArrowLeft') { human(); goTo(idx - 1); }
else if (e.key === 'ArrowRight') { human(); goTo(idx + 1); }
}
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
prevBtn.addEventListener('click', onPrevClick);
nextBtn.addEventListener('click', onNextClick);
document.addEventListener('keydown', onKeydown);
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
dragStartX = e.clientX;
dragBaseX = baseXFor(idx);
lastMoveX = e.clientX;
lastMoveT = performance.now();
velocity = 0;
viewport.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const now = performance.now();
const dt = now - lastMoveT;
if (dt > 0) velocity = (e.clientX - lastMoveX) / dt;
lastMoveX = e.clientX;
lastMoveT = now;
setTrackX(dragBaseX + (e.clientX - dragStartX));
}
function endDrag(e: PointerEvent) {
if (!dragging) return;
dragging = false;
const deltaX = e.clientX - dragStartX;
const w = viewportW();
const commit = Math.abs(deltaX) > w * SWIPE_COMMIT_RATIO || Math.abs(velocity) > SWIPE_VELOCITY_PX_MS;
if (commit) goTo(idx + (deltaX < 0 ? 1 : -1));
else goTo(idx);
}
viewport.addEventListener('pointerdown', onPointerDown);
viewport.addEventListener('pointermove', onPointerMove);
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);
function cycle() {
openFrom(0);
timers.push(window.setTimeout(() => { goTo(1); }, 420));
timers.push(window.setTimeout(() => { goTo(2); }, 1020));
timers.push(window.setTimeout(closeLightbox, 1620));
}
if (!reduce) {
cycle();
timers.push(window.setInterval(() => {
if (scene.classList.contains(styles['is-demo'])) cycle();
}, LOOP_MS));
}
return () => {
timers.forEach((id) => { window.clearTimeout(id); window.clearInterval(id); });
thumbs.forEach((t, i) => t.removeEventListener('click', thumbHandlers[i]));
closeBtn.removeEventListener('click', onCloseClick);
backdrop.removeEventListener('click', onBackdropClick);
prevBtn.removeEventListener('click', onPrevClick);
nextBtn.removeEventListener('click', onNextClick);
document.removeEventListener('keydown', onKeydown);
viewport.removeEventListener('pointerdown', onPointerDown);
viewport.removeEventListener('pointermove', onPointerMove);
viewport.removeEventListener('pointerup', endDrag);
viewport.removeEventListener('pointercancel', endDrag);
};
}, []);
return (
<div className={`${styles.scene} ${styles['is-demo']}`} ref={sceneRef}>
<ul className={styles.grid}>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--a']}`} /></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--b']}`} /></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--c']}`} /></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="사진 넘겨보기">
<div className={styles.viewport}>
<div className={styles.track}>
<span className={`${styles.fullImg} ${styles['fullImg--a']}`} />
<span className={`${styles.fullImg} ${styles['fullImg--b']}`} />
<span className={`${styles.fullImg} ${styles['fullImg--c']}`} />
</div>
</div>
<button className={`${styles.navBtn} ${styles['navBtn--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>
<button className={`${styles.navBtn} ${styles['navBtn--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>
<p className={styles.counter} aria-live="polite">1 / 3</p>
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}04 Lightbox Navigated by Swiping Next and Previous — Measured area 39.872% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · CSS Lightbox Gallery Animation — 9 Transitions
The post this demo lives in
CSS Lightbox Gallery Animation — 9 TransitionsAmong nine css lightbox gallery ideas, one grows from the clicked thumbnail's spot, one times a downward flick to decide See all demos in this post
If you like this effect - similar demos
Transitions07 Page value change and row-position value shift — when the page value changes, previous rows' position values exit and new rows' position values enterCollapsible Table Row: 9 UI Patterns
Transitions09 Backdrop blur and dim9 CSS Hamburger Menu Animations, No Library
Transitions06 Swipe to dismiss9 CSS Toast Notifications — No Library, Pure CSS