css ライトボックス 拡大アニメーション 9種まとめ
この css ライトボックス 9種のうち、1つはクリックしたサムネイルの位置からそのまま拡大し、1つは下向きスワイプの速度を計算して閉じるか戻すかを決め、1つはキーボード移動のたびにフォーカスリングが次の位置へ滑ります。
02 lightbox-backdrop-fade-scale
03 lightbox-caption-slide-up
04 lightbox-swipe-next-prev
05 lightbox-zoom-pan-drag
06 lightbox-close-velocity-fling
07 lightbox-keyboard-focus-trap-ring
09 lightbox-reduced-motion-instant
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 サムネイルの位置から広がるライトボックス
- 02 背景フェード+画像スケールアップ切り替え
- 03 キャプションが下から滑り上がるライトボックス
- 04 スワイプで次・前へ移る ライトボックス
- 05 拡大後にドラッグで移動するライトボックス
- 06 下に投げるように閉じるライトボックス
- 07 フォーカスリングが滑りながら閉じ込められるライトボックス
- 08 サムネイル列が追従して動くライトボックス
- 09 モーション削減を即座に反映するライトボックス
以下の9つのライトボックス転換パターンは、CDNからの読み込みなしに単体のHTML・CSS・JSファイルとして作られているため、アーカイブからどのフォルダを1つ取り出しても、そのindex.htmlをブラウザでそのまま開くだけで動作を確認できます。
デモ
上の各サムネイルを開いてそれぞれのライトボックスを試し、背景クリック・Escapeキー・そのパターンが用意しているジェスチャーで閉じてみてください。以下の数値とコードはすべて、後から要約したものではなく、その実際のファイルからそのまま読み取ったものです。
9つのパターンの解剖 — どこで計算の一層が回るか
下の表は、上のデモが生成したのと同じキャプチャから読み取った数値です。各パターンを同じ方法で連続サンプリングしています。累積面積は、キャプチャ全体で動いたピクセルがフレームの何%を占めたかの合計、平均強度は実際に何かが動いたフレームでのピクセル変化の強さ、動いたフレーム数(23フレーム中)は23回のフレーム間比較のうち何回に差が出たかを表します。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム数(23フレーム中) |
|---|---|---|---|---|
| 01 | サムネイルの位置から広がるライトボックス | 36.70% | 111.0 | 15 |
| 02 | 背景フェード+画像スケールアップ切り替え | 39.72% | 79.2 | 14 |
| 03 | キャプションが下から滑り上がるライトボックス | 39.87% | 110.8 | 21 |
| 04 | スワイプで次・前へ移る ライトボックス | 39.87% | 95.2 | 20 |
| 05 | 拡大後にドラッグで移動するライトボックス | 30.18% | 53.5 | 9 |
| 06 | 下に投げるように閉じるライトボックス | 39.87% | 74.1 | 15 |
| 07 | フォーカスリングが滑りながら閉じ込められるライトボックス | 39.82% | 106.4 | 20 |
| 08 | サムネイル列が追従して動くライトボックス | 39.87% | 99.7 | 10 |
| 09 | モーション削減を即座に反映するライトボックス | 22.44% | 78.2 | 17 |
03・04・06・08の4パターンは、いずれも背景の暗転と画面いっぱいの画像トランスフォームを同時に行うため、どの値が動きを生んでいるかに関わらずキャプチャに占める割合がほぼ同じになり、9パターン中最大の累積面積(約39.87%)で並んでいます。09は22.44%で最小、05は30.18%でそれに次いで小さく、この05は平均強度も53.5と全体最小、動いたフレーム数も23フレーム中9と最少です。ダブルタップやボタンでの拡大操作にしか反応せず、他の8パターンのような開閉サイクル自体を持たないためです。平均強度が最も高いのは01の111.0で、03の110.8にわずかに勝っています。動いたフレーム数が最も多いのは03で、23フレーム中21です。
クラスターA — 実際に測定した矩形がそのまま補間を駆動します、数式ではなく(01・03・07)
01・03・07の3パターンは、いずれも定数から開始値を計算するのではなく、画面上に実在する要素に対してgetBoundingClientRect()を呼ぶところから動きを始めます。01はクリックされたサムネイル自身の画面上の矩形を読み取り、フルサイズ画像がそこから広がる際のずれと拡大率を計算します。03はキャプションを画面外に一度配置した直後にその実際のレンダリング高さを読み取ります。短いキャプションと長いキャプションでは押し上げる距離が異なるためです。07は新しくフォーカスされたコントロールの矩形を読み取り、直前のリング位置からどこへ滑らせればよいかを正確に把握します。
クラスターB — 2つの閾値のORがどちらの分岐を実行するかを決めます(04・06・09)
04・06・09の3パターンは、いずれも同じ形の判定で1つの分岐を決めています。2つの独立した条件を||で結び、どちらか一方が真であれば十分という判定です。04のendDrag()は、ドラッグ距離がSWIPE_COMMIT_RATIOをビューポート幅に掛けた値を超えるか、離す瞬間の速度がSWIPE_VELOCITY_PX_MSを超えるか、どちらか早く成立した時点で次または前の画像への切り替えを確定します。06も同じ形の条件で、距離の割合かLIGHTBOX_FLING_VELOCITY_PX_MSを超える速度かによってflingClose()とspringBack()のどちらを実行するかを選びます。09のeffectiveReduce()はこの仕組みの最も単純な形で、realReduce || simulateReduceという判定により、OSの設定か実演用のスイッチのどちらかが真であれば、そのライトボックスを即座に切り替える状態にします。
クラスターC — 導出した比率が連続的なトランスフォームを可動域の中に収め続けます(02・05・08)
02・05・08の3パターンは、それぞれ異なる理由で値が変わるたびにclamp()を通します。02のfromAspect()は、画像の縦横比から正規化した1つの値をもとに開始スケールと背景の暗さを同時に導き出すため、縦長の写真と横長の写真では同じコードパスから異なる印象の開き方が生まれます。05のpanBounds()は、scale自体がまだ目標値へイージング中のアニメーションフレームごとに毎回再計算されるため、現在のズーム段階がまだ許していない位置へパンが動くことはありません。08のcenterOffsetFor()は帯の目標スクロール位置をclamp()で制限しており、アクティブなサムネイルが再配置される際に帯の最初や最後の項目を超えて動くことがないようにしています。
パターン別コード
以下は各パターンの実際のファイルから取り出した関数を1つずつ、ブラウザで動いているそのままの形で載せています。書き直しも省略もありません。ライトボックス デザインを検索すると、CSSの@keyframesだけで完結する開閉トランスフォームが数多く見つかりますが、以下の9つはいずれもトランジションの下でこうした計算された値を少なくとも1つ動かし続けています。
01サムネイルの位置から広がるライトボックス
グリッドのサムネイルをクリックすると、その画像があった位置とサイズからそのまま拡大が始まり、画面全体のライトボックスへ広がります。常に中央から現れる一般的なダイアログとは開始位置が異なります。
<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><span class="thumbLabel">写真1</span></button></li>
<li><button class="thumb" id="thumb1" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--b"></span><span class="thumbLabel">写真2</span></button></li>
<li><button class="thumb" id="thumb2" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--c"></span><span class="thumbLabel">写真3</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="写真の拡大表示">
<span class="fullImg" id="fullImg"></span>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue-light;
// 계산 겹: index.html 의 openFrom()/closeLightbox() 가 클릭한 썸네일과 풀 이미지의
// getBoundingClientRect 차이(FLIP)로 매 프레임 translate·scale 을 산술로 만든다 — 고정 트윈이 아니다.
@include stage($stage-ink);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #201c2c, $stage-ink 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;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
cursor: pointer;
&:focus-visible .thumbImg { outline: 2px solid $color; outline-offset: 2px; }
}
.thumbImg {
display: block;
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); }
}
.thumbLabel {
font-size: 10px;
font-weight: 600;
color: rgba($subject-cream, .72);
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(#050409, .92);
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.fullImg {
display: block;
width: 220px;
height: 136px;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: top left;
&--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); }
}
.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; }
.fullImg { width: 156px; height: 96px; }
.thumbLabel { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 주 확장·닫힘 트윈은 index.html 의 animate() 가 reduce 시 즉시 최종 상태로 처리한다.
// 여기서는 CSS 전용 보조 트랜지션(포커스 아웃라인 등)만 정리한다.
.closeBtn, .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: #17141a;
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, #201c2c, #17141a 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;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.thumbImg {
display: block;
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);
}
.thumbLabel {
font-size: 10px;
font-weight: 600;
color: rgba(255, 247, 230, 0.72);
}
.backdrop {
position: absolute;
inset: 0;
background: rgba(5, 4, 9, 0.92);
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.fullImg {
display: block;
width: 220px;
height: 136px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top left;
}
.fullImg--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.fullImg--b {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.fullImg--c {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.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;
}
.fullImg {
width: 156px;
height: 96px;
}
.thumbLabel {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .thumb {
transition: none;
}
}(function () {
var LIGHTBOX_MORPH_SCALE_FROM = 0.3; // 썸네일 폭 = 장면 폭 × 이 比率 — 실제 레이아웃 크기로 FLIP 델타를 잰다
var OPEN_MS = 650;
var CLOSE_MS = 480;
var LOOP_MS = 2000;
var HOLD_OPEN_MS = 600;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var fullImg = document.getElementById('fullImg');
var closeBtn = document.getElementById('closeBtn');
var thumbs = [].slice.call(document.querySelectorAll('.thumb'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var imgClassByIdx = ['thumbImg--a', 'thumbImg--b', 'thumbImg--c'];
var sceneW = scene.getBoundingClientRect().width || 320;
var thumbW = Math.round(sceneW * LIGHTBOX_MORPH_SCALE_FROM);
thumbs.forEach(function (t) {
var img = t.querySelector('.thumbImg');
img.style.width = thumbW + 'px';
img.style.height = Math.round(thumbW * 0.62) + 'px';
});
var open = false;
var activeIdx = 1;
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); }
// reduce: 실사용 경로 적용 — 트윈 없이 최종 상태로 즉시 전환한다(자동 루프는 아래에서 아예 안 돈다)
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 openFrom(idx) {
if (open) return;
open = true;
var thumbImg = thumbs[idx].querySelector('.thumbImg');
var fromRect = thumbImg.getBoundingClientRect();
fullImg.className = 'fullImg ' + imgClassByIdx[idx];
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
var toRect = fullImg.getBoundingClientRect();
var dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
var dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
var scaleFrom = fromRect.width / toRect.width;
animate(OPEN_MS, function (e) {
var s = scaleFrom + (1 - scaleFrom) * e;
var tx = dx * (1 - e);
var ty = dy * (1 - e);
fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (e * 0.72).toFixed(3);
}, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox(idx) {
if (!open) return;
var thumbImg = thumbs[idx].querySelector('.thumbImg');
var fromRect = thumbImg.getBoundingClientRect();
var toRect = fullImg.getBoundingClientRect();
var dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
var dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
var scaleTo = fromRect.width / toRect.width;
animate(CLOSE_MS, function (e) {
var s = 1 + (scaleTo - 1) * e;
var tx = dx * e;
var ty = dy * e;
fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (0.72 * (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 (t) { clearTimeout(t); clearInterval(t); });
timers = [];
}
}
thumbs.forEach(function (t, i) {
t.addEventListener('click', function () {
human();
activeIdx = i;
openFrom(i);
});
});
closeBtn.addEventListener('click', function () { human(); closeLightbox(activeIdx); });
backdrop.addEventListener('click', function () { human(); closeLightbox(activeIdx); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && open) { human(); closeLightbox(activeIdx); }
});
function cycle() {
activeIdx = 1;
openFrom(1);
timers.push(setTimeout(function () { closeLightbox(1); }, OPEN_MS + HOLD_OPEN_MS));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './ThumbnailMorphExpand.module.scss';
const LIGHTBOX_MORPH_SCALE_FROM = 0.3; // 썸네일 폭 = 장면 폭 × 이 비율 — 실제 레이아웃 크기로 FLIP 델타를 잰다
const OPEN_MS = 650;
const CLOSE_MS = 480;
const LOOP_MS = 2000;
const HOLD_OPEN_MS = 600;
export default function ThumbnailMorphExpand() {
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 fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumbs = Array.from(scene0.querySelectorAll<HTMLButtonElement>('.' + styles.thumb));
if (!backdrop0 || !lightbox0 || !fullImg0 || !closeBtn0 || thumbs.length === 0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const fullImg = fullImg0;
const closeBtn = closeBtn0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const imgClassByIdx = [styles['thumbImg--a'], styles['thumbImg--b'], styles['thumbImg--c']];
const sceneW = scene.getBoundingClientRect().width || 320;
const thumbW = Math.round(sceneW * LIGHTBOX_MORPH_SCALE_FROM);
thumbs.forEach((t) => {
const img = t.querySelector<HTMLSpanElement>('.' + styles.thumbImg);
if (!img) return;
img.style.width = thumbW + 'px';
img.style.height = Math.round(thumbW * 0.62) + 'px';
});
let open = false;
let activeIdx = 1;
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); }
// reduce: 실사용 경로 적용 — 트윈 없이 최종 상태로 즉시 전환한다(자동 루프는 아래에서 아예 안 돈다)
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 openFrom(idx: number) {
if (open) return;
open = true;
const thumbImg = thumbs[idx].querySelector<HTMLSpanElement>('.' + styles.thumbImg);
if (!thumbImg) return;
const fromRect = thumbImg.getBoundingClientRect();
fullImg.className = styles.fullImg + ' ' + imgClassByIdx[idx];
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
const toRect = fullImg.getBoundingClientRect();
const dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
const dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
const scaleFrom = fromRect.width / toRect.width;
animate(OPEN_MS, (e) => {
const s = scaleFrom + (1 - scaleFrom) * e;
const tx = dx * (1 - e);
const ty = dy * (1 - e);
fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (e * 0.72).toFixed(3);
}, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox(idx: number) {
if (!open) return;
const thumbImg = thumbs[idx].querySelector<HTMLSpanElement>('.' + styles.thumbImg);
if (!thumbImg) return;
const fromRect = thumbImg.getBoundingClientRect();
const toRect = fullImg.getBoundingClientRect();
const dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
const dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
const scaleTo = fromRect.width / toRect.width;
animate(CLOSE_MS, (e) => {
const s = 1 + (scaleTo - 1) * e;
const tx = dx * e;
const ty = dy * e;
fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (0.72 * (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 onThumbClick = (i: number) => () => { human(); activeIdx = i; openFrom(i); };
const thumbHandlers = thumbs.map((_, i) => onThumbClick(i));
thumbs.forEach((t, i) => t.addEventListener('click', thumbHandlers[i]));
function onCloseClick() { human(); closeLightbox(activeIdx); }
function onBackdropClick() { human(); closeLightbox(activeIdx); }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) { human(); closeLightbox(activeIdx); }
}
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
document.addEventListener('keydown', onKeydown);
function cycle() {
activeIdx = 1;
openFrom(1);
timers.push(window.setTimeout(() => { closeLightbox(1); }, OPEN_MS + HOLD_OPEN_MS));
}
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);
document.removeEventListener('keydown', onKeydown);
};
}, []);
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']}`} /><span className={styles.thumbLabel}>사진 1</span></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--b']}`} /><span className={styles.thumbLabel}>사진 2</span></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--c']}`} /><span className={styles.thumbLabel}>사진 3</span></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="사진 확대 보기">
<span className={styles.fullImg} />
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}01 · サムネイルの位置から広がるライトボックス — 計測 累積面積 36.697% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function openFrom(idx) {
if (open) return;
open = true;
var thumbImg = thumbs[idx].querySelector('.thumbImg');
var fromRect = thumbImg.getBoundingClientRect();
fullImg.className = 'fullImg ' + imgClassByIdx[idx];
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
var toRect = fullImg.getBoundingClientRect();
var dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
var dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
var scaleFrom = fromRect.width / toRect.width;
animate(OPEN_MS, function (e) {
var s = scaleFrom + (1 - scaleFrom) * e;
var tx = dx * (1 - e);
var ty = dy * (1 - e);
fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (e * 0.72).toFixed(3);
}, function () {
closeBtn.focus({ preventScroll: true });
});
}
02背景フェード+画像スケールアップ切り替え
背景が暗くフェードする間、画像自体は最終サイズよりわずかに小さい状態から始まり、少し拡大して止まります。一般的なダイアログのフェードスケールとは異なる実演で、開始スケールと背景の暗さが画像の縦横比から一緒に計算されます。

<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" data-w="280" data-h="210"><span class="thumbImg thumbImg--a"></span><span class="thumbLabel">写真1</span></button></li>
<li><button class="thumb" id="thumb1" type="button" aria-haspopup="dialog" data-w="200" data-h="200"><span class="thumbImg thumbImg--b"></span><span class="thumbLabel">写真2</span></button></li>
<li><button class="thumb" id="thumb2" type="button" aria-haspopup="dialog" data-w="180" data-h="240"><span class="thumbImg thumbImg--c"></span><span class="thumbLabel">写真3</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="写真の拡大表示">
<span class="fullImg" id="fullImg"></span>
<p class="ratioNote" id="ratioNote"></p>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-orange-deep;
// 계산 겹: index.html 의 fromAspect() 가 이미지 자신의 가로세로 비율을 0~1 로 정규화해
// 시작 스케일과 배경 어둡기(dim)를 같은 지점에서 함께 산출한다 — 세 사진마다 값이 다르다.
@include stage($stage-yellow);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #fff1c2, $stage-yellow 75%);
}
.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;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
cursor: pointer;
&:focus-visible .thumbImg { outline: 2px solid $color; outline-offset: 2px; }
}
.thumbImg {
display: block;
width: 64px;
height: 48px;
border-radius: $r-sm;
box-shadow: $shadow-press;
&--a { background: linear-gradient(135deg, $subject-blue, $subject-mint); height: 48px; }
&--b { background: linear-gradient(135deg, $subject-pink, $subject-lilac); width: 56px; height: 56px; }
&--c { background: linear-gradient(135deg, $subject-sky, $subject-lilac); width: 48px; height: 64px; }
}
.thumbLabel {
font-size: 10px;
font-weight: 600;
color: $subject-ink;
}
.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;
gap: $sp-2;
visibility: hidden;
}
.fullImg {
display: block;
width: 200px;
height: 150px;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: center;
&--a { background: linear-gradient(135deg, $subject-blue, $subject-mint); }
&--b { background: linear-gradient(135deg, $subject-pink, $subject-lilac); width: 172px; height: 172px; }
&--c { background: linear-gradient(135deg, $subject-sky, $subject-lilac); width: 138px; height: 184px; }
}
.ratioNote {
position: absolute;
bottom: $sp-2;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 10px;
font-weight: 600;
color: $subject-cream;
background: rgba(#000, .4);
padding: $sp-1 $sp-2;
border-radius: $r-sm;
}
.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, .95);
cursor: pointer;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
@media (max-width: 340px) {
.scene { width: 220px; height: 124px; }
.fullImg { width: 140px; height: 104px; &--b { width: 118px; height: 118px; } &--c { width: 96px; height: 128px; } }
.ratioNote { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 스케일업·페이드 트윈은 index.html 의 animate() 가 reduce 시 즉시 최종 상태로 처리한다.
.closeBtn, .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: #ffd23f;
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, #fff1c2, #ffd23f 75%);
}
.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;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #c93000;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 64px;
height: 48px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.thumbImg--a {
background: linear-gradient(135deg, #2f6df6, #4cd4a6);
height: 48px;
}
.thumbImg--b {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
width: 56px;
height: 56px;
}
.thumbImg--c {
background: linear-gradient(135deg, #6ec8ff, #b9a0ff);
width: 48px;
height: 64px;
}
.thumbLabel {
font-size: 10px;
font-weight: 600;
color: #17141a;
}
.backdrop {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: grid;
place-items: center;
gap: 8px;
visibility: hidden;
}
.fullImg {
display: block;
width: 200px;
height: 150px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center;
}
.fullImg--a {
background: linear-gradient(135deg, #2f6df6, #4cd4a6);
}
.fullImg--b {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
width: 172px;
height: 172px;
}
.fullImg--c {
background: linear-gradient(135deg, #6ec8ff, #b9a0ff);
width: 138px;
height: 184px;
}
.ratioNote {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 10px;
font-weight: 600;
color: #fff7e6;
background: rgba(0, 0, 0, 0.4);
padding: 4px 8px;
border-radius: 8px;
}
.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.95);
cursor: pointer;
}
.closeBtn:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.fullImg {
width: 140px;
height: 104px;
}
.fullImg--b {
width: 118px;
height: 118px;
}
.fullImg--c {
width: 96px;
height: 128px;
}
.ratioNote {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .thumb {
transition: none;
}
}(function () {
var OPEN_MS = 600;
var CLOSE_MS = 420;
var LOOP_MS = 2000;
var HOLD_OPEN_MS = 620;
var ASPECT_NORM_LO = 0.6;
var ASPECT_NORM_HI = 1.6;
var SCALE_FROM_MIN = 0.7;
var SCALE_FROM_MAX = 0.92;
var DIM_MAX = 0.82;
var DIM_MIN = 0.58;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var fullImg = document.getElementById('fullImg');
var ratioNote = document.getElementById('ratioNote');
var closeBtn = document.getElementById('closeBtn');
var thumbs = [].slice.call(document.querySelectorAll('.thumb'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var imgClassByIdx = ['thumbImg--a', 'thumbImg--b', 'thumbImg--c'];
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 이미지 자신의 가로세로 비율에서 시작 스케일과 배경 어둡기를 함께 계산한다 — 두 값이 같은 정규화 지점에서 나온다
function fromAspect(w, h) {
var aspect = w / h;
var norm = clamp((aspect - ASPECT_NORM_LO) / (ASPECT_NORM_HI - ASPECT_NORM_LO), 0, 1);
var scaleFrom = SCALE_FROM_MIN + (SCALE_FROM_MAX - SCALE_FROM_MIN) * norm;
var dim = DIM_MAX - (DIM_MAX - DIM_MIN) * norm;
return { aspect: aspect, scaleFrom: scaleFrom, dim: dim };
}
var open = false;
var activeIdx = 1;
var timers = [];
// reduce: 실사용 경로 적용 — 트윈 없이 최종 상태로 즉시 전환한다
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 openFrom(idx) {
if (open) return;
open = true;
var t = thumbs[idx];
var calc = fromAspect(Number(t.dataset.w), Number(t.dataset.h));
fullImg.className = 'fullImg ' + imgClassByIdx[idx];
ratioNote.textContent = '比率 ' + calc.aspect.toFixed(2) + ' ・開始倍率 ' + calc.scaleFrom.toFixed(2);
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(OPEN_MS, function (e) {
var s = calc.scaleFrom + (1 - calc.scaleFrom) * e;
fullImg.style.transform = 'scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (calc.dim * e).toFixed(3);
}, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox(idx) {
if (!open) return;
var t = thumbs[idx];
var calc = fromAspect(Number(t.dataset.w), Number(t.dataset.h));
animate(CLOSE_MS, function (e) {
var s = 1 + (calc.scaleFrom - 1) * e;
fullImg.style.transform = 'scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (calc.dim * (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();
activeIdx = i;
openFrom(i);
});
});
closeBtn.addEventListener('click', function () { human(); closeLightbox(activeIdx); });
backdrop.addEventListener('click', function () { human(); closeLightbox(activeIdx); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && open) { human(); closeLightbox(activeIdx); }
});
function cycle() {
activeIdx = 2;
openFrom(2);
timers.push(setTimeout(function () { closeLightbox(2); }, OPEN_MS + HOLD_OPEN_MS));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './BackdropFadeScale.module.scss';
const OPEN_MS = 600;
const CLOSE_MS = 420;
const LOOP_MS = 2000;
const HOLD_OPEN_MS = 620;
const ASPECT_NORM_LO = 0.6;
const ASPECT_NORM_HI = 1.6;
const SCALE_FROM_MIN = 0.7;
const SCALE_FROM_MAX = 0.92;
const DIM_MAX = 0.82;
const DIM_MIN = 0.58;
const THUMB_SIZES: { w: number; h: number }[] = [
{ w: 280, h: 210 },
{ w: 200, h: 200 },
{ w: 180, h: 240 },
];
export default function BackdropFadeScale() {
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 fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const ratioNote0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.ratioNote);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumbs = Array.from(scene0.querySelectorAll<HTMLButtonElement>('.' + styles.thumb));
if (!backdrop0 || !lightbox0 || !fullImg0 || !ratioNote0 || !closeBtn0 || thumbs.length === 0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const fullImg = fullImg0;
const ratioNote = ratioNote0;
const closeBtn = closeBtn0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const imgClassByIdx = [styles['thumbImg--a'], styles['thumbImg--b'], styles['thumbImg--c']];
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 fromAspect(w: number, h: number) {
const aspect = w / h;
const norm = clamp((aspect - ASPECT_NORM_LO) / (ASPECT_NORM_HI - ASPECT_NORM_LO), 0, 1);
const scaleFrom = SCALE_FROM_MIN + (SCALE_FROM_MAX - SCALE_FROM_MIN) * norm;
const dim = DIM_MAX - (DIM_MAX - DIM_MIN) * norm;
return { aspect, scaleFrom, dim };
}
let open = false;
let activeIdx = 1;
let timers: number[] = [];
// reduce: 실사용 경로 적용 — 트윈 없이 최종 상태로 즉시 전환한다
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 openFrom(idx: number) {
if (open) return;
open = true;
const calc = fromAspect(THUMB_SIZES[idx].w, THUMB_SIZES[idx].h);
fullImg.className = styles.fullImg + ' ' + imgClassByIdx[idx];
ratioNote.textContent = '비율 ' + calc.aspect.toFixed(2) + ' · 시작 배율 ' + calc.scaleFrom.toFixed(2);
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(OPEN_MS, (e) => {
const s = calc.scaleFrom + (1 - calc.scaleFrom) * e;
fullImg.style.transform = 'scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (calc.dim * e).toFixed(3);
}, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox(idx: number) {
if (!open) return;
const calc = fromAspect(THUMB_SIZES[idx].w, THUMB_SIZES[idx].h);
animate(CLOSE_MS, (e) => {
const s = 1 + (calc.scaleFrom - 1) * e;
fullImg.style.transform = 'scale(' + s.toFixed(4) + ')';
backdrop.style.opacity = (calc.dim * (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(); activeIdx = i; openFrom(i); });
thumbs.forEach((t, i) => t.addEventListener('click', thumbHandlers[i]));
function onCloseClick() { human(); closeLightbox(activeIdx); }
function onBackdropClick() { human(); closeLightbox(activeIdx); }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) { human(); closeLightbox(activeIdx); }
}
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
document.addEventListener('keydown', onKeydown);
function cycle() {
activeIdx = 2;
openFrom(2);
timers.push(window.setTimeout(() => { closeLightbox(2); }, OPEN_MS + HOLD_OPEN_MS));
}
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);
document.removeEventListener('keydown', onKeydown);
};
}, []);
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']}`} /><span className={styles.thumbLabel}>사진 1</span></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--b']}`} /><span className={styles.thumbLabel}>사진 2</span></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--c']}`} /><span className={styles.thumbLabel}>사진 3</span></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="사진 확대 보기">
<span className={styles.fullImg} />
<p className={styles.ratioNote} />
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}02 · 背景フェード+画像スケールアップ切り替え — 計測 累積面積 39.722% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 画像自身の縦横比から開始スケールと背景の暗さを一緒に計算する — 2つの値が同じ正規化点から生まれる
function fromAspect(w, h) {
var aspect = w / h;
var norm = clamp((aspect - ASPECT_NORM_LO) / (ASPECT_NORM_HI - ASPECT_NORM_LO), 0, 1);
var scaleFrom = SCALE_FROM_MIN + (SCALE_FROM_MAX - SCALE_FROM_MIN) * norm;
var dim = DIM_MAX - (DIM_MAX - DIM_MIN) * norm;
return { aspect: aspect, scaleFrom: scaleFrom, dim: dim };
}
var open = false;
var activeIdx = 1;
var timers = [];
03キャプションが下から滑り上がるライトボックス
画像が広がり終えた少し後に、下部のキャプション帯が画面の下端から押し上げられるように現れます。画像とキャプションが同時ではなく、二段階で現れます。

<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><span class="thumbLabel">写真1</span></button></li>
<li><button class="thumb" id="thumb1" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--b"></span><span class="thumbLabel">写真2</span></button></li>
<li><button class="thumb" id="thumb2" type="button" aria-haspopup="dialog"><span class="thumbImg thumbImg--c"></span><span class="thumbLabel">写真3</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="写真と説明を見る">
<span class="fullImg" id="fullImg"></span>
<p class="caption" id="caption"></p>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-ink;
// 계산 겹: index.html 이 캡션 요소를 먼저 visibility:hidden 으로 렌더해 실제 높이(getBoundingClientRect)를
// 잰 뒤 그 값만큼 translateY 로 내렸다가 0 으로 올린다 — 텍스트 길이(로케일)마다 시작 위치가 다르다.
@include stage($stage-orange);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #ffe3b0, $stage-orange 85%);
}
.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;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
cursor: pointer;
&:focus-visible .thumbImg { outline: 2px solid $subject-ink; outline-offset: 2px; }
}
.thumbImg {
display: block;
width: 60px;
height: 44px;
border-radius: $r-sm;
box-shadow: $shadow-press;
&--a { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
&--b { background: linear-gradient(135deg, $subject-lilac, $subject-blue); }
&--c { background: linear-gradient(135deg, $subject-sky, $subject-mint); }
}
.thumbLabel {
font-size: 10px;
font-weight: 700;
color: $subject-ink;
}
.backdrop {
position: absolute;
inset: 0;
background: $subject-ink;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
visibility: hidden;
overflow: hidden;
}
.fullImg {
display: block;
width: 220px;
height: 118px;
border-radius: $r-card $r-card 0 0;
opacity: 0;
&--a { background: linear-gradient(135deg, $subject-mint, $subject-sky); }
&--b { background: linear-gradient(135deg, $subject-lilac, $subject-blue); }
&--c { background: linear-gradient(135deg, $subject-sky, $subject-mint); }
}
.caption {
width: 220px;
margin: 0;
padding: $sp-2 $sp-3;
border-radius: 0 0 $r-card $r-card;
background: rgba($subject-cream, .96);
color: $subject-ink;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
box-shadow: $shadow-raised;
}
.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-cream;
background: rgba($subject-ink, .82);
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
}
@media (max-width: 340px) {
.scene { width: 220px; height: 124px; }
.fullImg { width: 160px; height: 82px; }
.caption { width: 160px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 이미지 등장·캡션 슬라이드는 index.html 의 animate() 가 reduce 시 즉시 최종 상태로 처리한다.
.closeBtn, .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: #ff4d1f;
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, #ffe3b0, #ff4d1f 85%);
}
.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;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 60px;
height: 44px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.thumbImg--a {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.thumbImg--b {
background: linear-gradient(135deg, #b9a0ff, #2f6df6);
}
.thumbImg--c {
background: linear-gradient(135deg, #6ec8ff, #4cd4a6);
}
.thumbLabel {
font-size: 10px;
font-weight: 700;
color: #17141a;
}
.backdrop {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
visibility: hidden;
overflow: hidden;
}
.fullImg {
display: block;
width: 220px;
height: 118px;
border-radius: 16px 16px 0 0;
opacity: 0;
}
.fullImg--a {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.fullImg--b {
background: linear-gradient(135deg, #b9a0ff, #2f6df6);
}
.fullImg--c {
background: linear-gradient(135deg, #6ec8ff, #4cd4a6);
}
.caption {
width: 220px;
margin: 0;
padding: 8px 12px;
border-radius: 0 0 16px 16px;
background: rgba(255, 247, 230, 0.96);
color: #17141a;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.closeBtn {
position: absolute;
top: 8px;
right: 8px;
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: rgba(23, 20, 26, 0.82);
cursor: pointer;
}
.closeBtn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.fullImg {
width: 160px;
height: 82px;
}
.caption {
width: 160px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .thumb {
transition: none;
}
}(function () {
var IMAGE_MS = 550;
var CAPTION_GAP_MS = 120;
var CAPTION_MS = 420;
var HOLD_MS = 480;
var CLOSE_MS = 380;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var fullImg = document.getElementById('fullImg');
var caption = document.getElementById('caption');
var closeBtn = document.getElementById('closeBtn');
var thumbs = [].slice.call(document.querySelectorAll('.thumb'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var imgClassByIdx = ['fullImg--a', 'fullImg--b', 'fullImg--c'];
// 문구.json 에서 en·ja 로 함께 옮겨지도록 문자열 리터럴로 둔다(html data-* 속성은 문구치환 대상 밖)
var captionByIdx = ['秋の散歩道で撮った一枚', '都市の夜景', '海辺で過ごした一日、波の音が聞こえた瞬間'];
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// reduce: 실사용 경로 적용 — 트윈 없이 최종 상태로 즉시 전환한다
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);
}
var open = false;
var activeIdx = 1;
var timers = [];
function openFrom(idx) {
if (open) return;
open = true;
caption.textContent = captionByIdx[idx];
caption.style.transform = 'translateY(0px)';
caption.style.opacity = '0';
fullImg.className = 'fullImg ' + imgClassByIdx[idx];
fullImg.style.transform = 'scale(.86)';
fullImg.style.opacity = '0';
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(IMAGE_MS, function (e) {
fullImg.style.transform = 'scale(' + (0.86 + 0.14 * e).toFixed(4) + ')';
fullImg.style.opacity = e.toFixed(3);
backdrop.style.opacity = (0.7 * e).toFixed(3);
}, function () {
// 캡션 자신의 실제 렌더 높이를 측정해 그 높이만큼 아래서 밀어 올린다 — 텍스트 길이마다 시작 위치가 다르다
caption.style.visibility = 'hidden';
caption.style.opacity = '1';
var capH = caption.getBoundingClientRect().height;
caption.style.transform = 'translateY(' + capH.toFixed(1) + 'px)';
caption.style.visibility = 'visible';
timers.push(setTimeout(function () {
animate(CAPTION_MS, function (e2) {
caption.style.transform = 'translateY(' + (capH * (1 - e2)).toFixed(1) + 'px)';
}, function () {
closeBtn.focus({ preventScroll: true });
});
}, CAPTION_GAP_MS));
});
}
function closeLightbox() {
if (!open) return;
animate(CLOSE_MS, function (e) {
fullImg.style.opacity = (1 - e).toFixed(3);
caption.style.opacity = (1 - e).toFixed(3);
backdrop.style.opacity = (0.7 * (1 - e)).toFixed(3);
}, function () {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumbs[activeIdx].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();
activeIdx = i;
openFrom(i);
});
});
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && open) { human(); closeLightbox(); }
});
function cycle() {
activeIdx = 2;
openFrom(2);
timers.push(setTimeout(closeLightbox, IMAGE_MS + CAPTION_GAP_MS + CAPTION_MS + HOLD_MS));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './CaptionSlideUp.module.scss';
const IMAGE_MS = 550;
const CAPTION_GAP_MS = 120;
const CAPTION_MS = 420;
const HOLD_MS = 480;
const CLOSE_MS = 380;
const LOOP_MS = 2000;
// 문구.json 에서 en·ja 로 함께 옮겨지도록 문자열 리터럴로 둔다(html data-* 속성은 문구치환 대상 밖)
const captionByIdx = ['가을 산책길에서 담은 한 장', '도시의 야경', '바닷가에서 보낸 하루, 파도소리가 들리던 순간'];
export default function CaptionSlideUp() {
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 fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const caption0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.caption);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumbs = Array.from(scene0.querySelectorAll<HTMLButtonElement>('.' + styles.thumb));
if (!backdrop0 || !lightbox0 || !fullImg0 || !caption0 || !closeBtn0 || thumbs.length === 0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const fullImg = fullImg0;
const caption = caption0;
const closeBtn = closeBtn0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const imgClassByIdx = [styles['fullImg--a'], styles['fullImg--b'], styles['fullImg--c']];
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); }
// reduce: 실사용 경로 적용 — 트윈 없이 최종 상태로 즉시 전환한다
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);
}
let open = false;
let activeIdx = 1;
let timers: number[] = [];
function openFrom(idx: number) {
if (open) return;
open = true;
caption.textContent = captionByIdx[idx];
caption.style.transform = 'translateY(0px)';
caption.style.opacity = '0';
fullImg.className = styles.fullImg + ' ' + imgClassByIdx[idx];
fullImg.style.transform = 'scale(.86)';
fullImg.style.opacity = '0';
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(IMAGE_MS, (e) => {
fullImg.style.transform = 'scale(' + (0.86 + 0.14 * e).toFixed(4) + ')';
fullImg.style.opacity = e.toFixed(3);
backdrop.style.opacity = (0.7 * e).toFixed(3);
}, () => {
// 캡션 자신의 실제 렌더 높이를 측정해 그 높이만큼 아래서 밀어 올린다 — 텍스트 길이마다 시작 위치가 다르다
caption.style.visibility = 'hidden';
caption.style.opacity = '1';
const capH = caption.getBoundingClientRect().height;
caption.style.transform = 'translateY(' + capH.toFixed(1) + 'px)';
caption.style.visibility = 'visible';
timers.push(window.setTimeout(() => {
animate(CAPTION_MS, (e2) => {
caption.style.transform = 'translateY(' + (capH * (1 - e2)).toFixed(1) + 'px)';
}, () => {
closeBtn.focus({ preventScroll: true });
});
}, CAPTION_GAP_MS));
});
}
function closeLightbox() {
if (!open) return;
animate(CLOSE_MS, (e) => {
fullImg.style.opacity = (1 - e).toFixed(3);
caption.style.opacity = (1 - e).toFixed(3);
backdrop.style.opacity = (0.7 * (1 - e)).toFixed(3);
}, () => {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumbs[activeIdx].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(); activeIdx = i; openFrom(i); });
thumbs.forEach((t, i) => t.addEventListener('click', thumbHandlers[i]));
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) { human(); closeLightbox(); }
}
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
document.addEventListener('keydown', onKeydown);
function cycle() {
activeIdx = 2;
openFrom(2);
timers.push(window.setTimeout(closeLightbox, IMAGE_MS + CAPTION_GAP_MS + CAPTION_MS + HOLD_MS));
}
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);
document.removeEventListener('keydown', onKeydown);
};
}, []);
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']}`} /><span className={styles.thumbLabel}>사진 1</span></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--b']}`} /><span className={styles.thumbLabel}>사진 2</span></button></li>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={`${styles.thumbImg} ${styles['thumbImg--c']}`} /><span className={styles.thumbLabel}>사진 3</span></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="사진과 설명 보기">
<span className={styles.fullImg} />
<p className={styles.caption} />
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}03 · キャプションが下から滑り上がるライトボックス — 計測 累積面積 39.872% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
animate(IMAGE_MS, function (e) {
fullImg.style.transform = 'scale(' + (0.86 + 0.14 * e).toFixed(4) + ')';
fullImg.style.opacity = e.toFixed(3);
backdrop.style.opacity = (0.7 * e).toFixed(3);
}, function () {
// キャプション自身の実際のレンダリング高さを測定し、その高さの分だけ下から押し上げる — テキストの長さによって開始位置が変わる
caption.style.visibility = 'hidden';
caption.style.opacity = '1';
var capH = caption.getBoundingClientRect().height;
caption.style.transform = 'translateY(' + capH.toFixed(1) + 'px)';
caption.style.visibility = 'visible';
timers.push(setTimeout(function () {
animate(CAPTION_MS, function (e2) {
caption.style.transform = 'translateY(' + (capH * (1 - e2)).toFixed(1) + 'px)';
}, function () {
closeBtn.focus({ preventScroll: true });
});
}, CAPTION_GAP_MS));
});
04スワイプで次・前へ移る ライトボックス
ライトボックスが開いた状態で左右にドラッグすると、現在の画像が押し出されて次または前の画像がその場所を埋めます。082のインラインカルーセルスライダーとは異なる実演で、こちらは全画面オーバーレイの中で番号カウンターとともに切り替わります。

<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="写真をめくって見る">
<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="前の写真"><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="次の写真"><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">閉じる</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 · スワイプで次・前へ移る ライトボックス — 計測 累積面積 39.872% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
}
05拡大後にドラッグで移動するライトボックス
ダブルタップやボタンで画像を拡大すると、その後はドラッグした方向へ画像が動きますが、拡大した分だけ生まれる余白の中でしか動けないよう移動範囲が毎回再計算されます。256のルーペ・ミニマップビューアーとは異なる実演で、こちらはライトボックスが開いている間の拡大・移動だけを扱います。

<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"></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="写真の拡大移動">
<div class="frame" id="frame">
<span class="fullImg" id="fullImg"></span>
</div>
<button class="zoomBtn zoomBtn--in" id="zoomInBtn" 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="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8h-24v24a8 8 0 0 1-16 0v-24H80a8 8 0 0 1 0-16h24V80a8 8 0 0 1 16 0v24h24a8 8 0 0 1 8 8"/></g></svg></button>
<button class="zoomBtn zoomBtn--out" id="zoomOutBtn" 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="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8H80a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></button>
<p class="zoomNote" id="zoomNote">1.0×</p>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: index.html 의 panBounds() 가 확대 배율(scale)과 프레임 실측 크기(getBoundingClientRect)로
// 그때그때 남는 여백을 다시 계산해 드래그·자동 이동 범위를 그 값 안으로 clamp 한다.
@include stage($stage-ink);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #232a3a, $stage-ink 85%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.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: 72px;
height: 72px;
border-radius: $r-sm;
box-shadow: $shadow-press;
background: linear-gradient(135deg, $subject-mint, $subject-sky);
}
.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;
}
.frame {
position: relative;
width: 220px;
height: 140px;
border-radius: $r-card;
overflow: hidden;
touch-action: none;
background: rgba(#000, .2);
}
.fullImg {
position: absolute;
inset: 0;
display: block;
background: linear-gradient(135deg, $subject-mint, $subject-sky 55%, $subject-lilac);
transform-origin: center center;
}
.zoomBtn {
position: absolute;
bottom: $sp-2;
appearance: none;
border: none;
width: 26px;
height: 26px;
border-radius: $r-pill;
display: grid;
place-items: center;
background: rgba(#000, .4);
color: $subject-cream;
font-size: 14px;
cursor: pointer;
&--in { right: 46px; }
&--out { right: $sp-2; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.zoomNote {
position: absolute;
bottom: $sp-2;
left: $sp-2;
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; }
.frame { width: 160px; height: 100px; }
.zoomBtn { width: 22px; height: 22px; font-size: 12px; }
.zoomBtn--in { right: 38px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 확대·이동 트윈은 index.html 의 animate()/panLoop() 가 reduce 시 즉시 최종 상태로 처리한다.
.closeBtn, .zoomBtn, .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: #17141a;
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, #232a3a, #17141a 85%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 72px;
height: 72px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.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;
}
.frame {
position: relative;
width: 220px;
height: 140px;
border-radius: 16px;
overflow: hidden;
touch-action: none;
background: rgba(0, 0, 0, 0.2);
}
.fullImg {
position: absolute;
inset: 0;
display: block;
background: linear-gradient(135deg, #4cd4a6, #6ec8ff 55%, #b9a0ff);
transform-origin: center center;
}
.zoomBtn {
position: absolute;
bottom: 8px;
appearance: none;
border: none;
width: 26px;
height: 26px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.4);
color: #fff7e6;
font-size: 14px;
cursor: pointer;
}
.zoomBtn--in {
right: 46px;
}
.zoomBtn--out {
right: 8px;
}
.zoomBtn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.zoomNote {
position: absolute;
bottom: 8px;
left: 8px;
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 #4cd4a6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.frame {
width: 160px;
height: 100px;
}
.zoomBtn {
width: 22px;
height: 22px;
font-size: 12px;
}
.zoomBtn--in {
right: 38px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .zoomBtn, .thumb {
transition: none;
}
}(function () {
var ZOOM_SCALE = 2.2;
var ZOOM_MS = 420;
var PAN_MS = 900;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var frame = document.getElementById('frame');
var fullImg = document.getElementById('fullImg');
var zoomInBtn = document.getElementById('zoomInBtn');
var zoomOutBtn = document.getElementById('zoomOutBtn');
var zoomNote = document.getElementById('zoomNote');
var closeBtn = document.getElementById('closeBtn');
var thumb = document.getElementById('thumb0');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var open = false;
var scale = 1;
var offX = 0;
var offY = 0;
var dragging = false;
var dragStartX = 0;
var dragStartY = 0;
var dragBaseX = 0;
var dragBaseY = 0;
var panRafId = null;
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 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 panBounds(s) {
var r = frame.getBoundingClientRect();
return { x: (s - 1) * r.width / 2, y: (s - 1) * r.height / 2 };
}
function apply() {
fullImg.style.transform = 'translate(' + offX.toFixed(2) + 'px,' + offY.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
zoomNote.textContent = scale.toFixed(1) + '×';
}
function zoomTo(target, onDone) {
var fromScale = scale;
var fromX = offX, fromY = offY;
animate(ZOOM_MS, function (e) {
scale = fromScale + (target - fromScale) * e;
var b = panBounds(scale);
offX = clamp(fromX * (1 - e), -b.x, b.x);
offY = clamp(fromY * (1 - e), -b.y, b.y);
apply();
}, onDone);
}
function panLoop(durationMs, onDone) {
if (reduce) { if (onDone) onDone(); return; }
var start = performance.now();
function frame2(now) {
var t = now - start;
var b = panBounds(scale);
offX = clamp(b.x * Math.sin(t / 300), -b.x, b.x);
offY = clamp(b.y * 0.6 * Math.sin(t / 420 + 1), -b.y, b.y);
apply();
if (t < durationMs) panRafId = requestAnimationFrame(frame2);
else if (onDone) onDone();
}
panRafId = requestAnimationFrame(frame2);
}
function openFrom() {
if (open) return;
open = true;
scale = 1; offX = 0; offY = 0;
apply();
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(240, function (e) { backdrop.style.opacity = (e * 0.78).toFixed(3); }, function () {
zoomInBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
if (panRafId) cancelAnimationFrame(panRafId);
animate(220, 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;
thumb.focus({ preventScroll: true });
});
}
function human() {
if (scene.classList.contains('is-demo')) {
scene.classList.remove('is-demo');
if (panRafId) cancelAnimationFrame(panRafId);
timers.forEach(function (id) { clearTimeout(id); clearInterval(id); });
timers = [];
}
}
thumb.addEventListener('click', function () { human(); openFrom(); });
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
zoomInBtn.addEventListener('click', function () { human(); zoomTo(ZOOM_SCALE); });
zoomOutBtn.addEventListener('click', function () { human(); zoomTo(1); });
frame.addEventListener('dblclick', function () { human(); zoomTo(scale > 1.01 ? 1 : ZOOM_SCALE); });
document.addEventListener('keydown', function (e) {
if (open && e.key === 'Escape') { human(); closeLightbox(); }
});
frame.addEventListener('pointerdown', function (e) {
if (scale <= 1.01) return;
human();
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragBaseX = offX;
dragBaseY = offY;
frame.setPointerCapture(e.pointerId);
});
frame.addEventListener('pointermove', function (e) {
if (!dragging) return;
var b = panBounds(scale);
offX = clamp(dragBaseX + (e.clientX - dragStartX), -b.x, b.x);
offY = clamp(dragBaseY + (e.clientY - dragStartY), -b.y, b.y);
apply();
});
function endDrag() { dragging = false; }
frame.addEventListener('pointerup', endDrag);
frame.addEventListener('pointercancel', endDrag);
function cycle() {
openFrom();
timers.push(setTimeout(function () {
zoomTo(ZOOM_SCALE, function () {
panLoop(PAN_MS, function () {
zoomTo(1);
});
});
}, 260));
timers.push(setTimeout(closeLightbox, 1780));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './ZoomPanDrag.module.scss';
const ZOOM_SCALE = 2.2;
const ZOOM_MS = 420;
const PAN_MS = 900;
const LOOP_MS = 2000;
export default function ZoomPanDrag() {
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 frame0 = scene0.querySelector<HTMLDivElement>('.' + styles.frame);
const fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const zoomInBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles['zoomBtn--in']);
const zoomOutBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles['zoomBtn--out']);
const zoomNote0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.zoomNote);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumb0 = scene0.querySelector<HTMLButtonElement>('.' + styles.thumb);
if (!backdrop0 || !lightbox0 || !frame0 || !fullImg0 || !zoomInBtn0 || !zoomOutBtn0 || !zoomNote0 || !closeBtn0 || !thumb0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const frame = frame0;
const fullImg = fullImg0;
const zoomInBtn = zoomInBtn0;
const zoomOutBtn = zoomOutBtn0;
const zoomNote = zoomNote0;
const closeBtn = closeBtn0;
const thumb = thumb0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let open = false;
let scale = 1;
let offX = 0;
let offY = 0;
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragBaseX = 0;
let dragBaseY = 0;
let panRafId: number | null = null;
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 animate(duration: number, onFrame: (e: number) => void, onDone?: () => void) {
if (reduce) { onFrame(1); if (onDone) onDone(); return; }
const start = performance.now();
function frame2(now: number) {
const p = clamp((now - start) / duration, 0, 1);
onFrame(easeOutCubic(p));
if (p < 1) requestAnimationFrame(frame2);
else if (onDone) onDone();
}
requestAnimationFrame(frame2);
}
// 확대 배율만큼 남는 여백을 프레임 실측 크기로 매번 다시 계산한다 — 확대 정도가 바뀌면 이동 가능 범위도 바뀐다
function panBounds(s: number) {
const r = frame.getBoundingClientRect();
return { x: (s - 1) * r.width / 2, y: (s - 1) * r.height / 2 };
}
function apply() {
fullImg.style.transform = 'translate(' + offX.toFixed(2) + 'px,' + offY.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
zoomNote.textContent = scale.toFixed(1) + '×';
}
function zoomTo(target: number, onDone?: () => void) {
const fromScale = scale;
const fromX = offX, fromY = offY;
animate(ZOOM_MS, (e) => {
scale = fromScale + (target - fromScale) * e;
const b = panBounds(scale);
offX = clamp(fromX * (1 - e), -b.x, b.x);
offY = clamp(fromY * (1 - e), -b.y, b.y);
apply();
}, onDone);
}
function panLoop(durationMs: number, onDone?: () => void) {
if (reduce) { if (onDone) onDone(); return; }
const start = performance.now();
function frame2(now: number) {
const t = now - start;
const b = panBounds(scale);
offX = clamp(b.x * Math.sin(t / 300), -b.x, b.x);
offY = clamp(b.y * 0.6 * Math.sin(t / 420 + 1), -b.y, b.y);
apply();
if (t < durationMs) panRafId = requestAnimationFrame(frame2);
else if (onDone) onDone();
}
panRafId = requestAnimationFrame(frame2);
}
function openFrom() {
if (open) return;
open = true;
scale = 1; offX = 0; offY = 0;
apply();
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(240, (e) => { backdrop.style.opacity = (e * 0.78).toFixed(3); }, () => {
zoomInBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
if (panRafId) cancelAnimationFrame(panRafId);
animate(220, (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;
thumb.focus({ preventScroll: true });
});
}
function human() {
if (scene.classList.contains(styles['is-demo'])) {
scene.classList.remove(styles['is-demo']);
if (panRafId) cancelAnimationFrame(panRafId);
timers.forEach((id) => { window.clearTimeout(id); window.clearInterval(id); });
timers = [];
}
}
function onThumbClick() { human(); openFrom(); }
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
function onZoomInClick() { human(); zoomTo(ZOOM_SCALE); }
function onZoomOutClick() { human(); zoomTo(1); }
function onFrameDblClick() { human(); zoomTo(scale > 1.01 ? 1 : ZOOM_SCALE); }
function onKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') { human(); closeLightbox(); }
}
thumb.addEventListener('click', onThumbClick);
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
zoomInBtn.addEventListener('click', onZoomInClick);
zoomOutBtn.addEventListener('click', onZoomOutClick);
frame.addEventListener('dblclick', onFrameDblClick);
document.addEventListener('keydown', onKeydown);
function onPointerDown(e: PointerEvent) {
if (scale <= 1.01) return;
human();
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragBaseX = offX;
dragBaseY = offY;
frame.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const b = panBounds(scale);
offX = clamp(dragBaseX + (e.clientX - dragStartX), -b.x, b.x);
offY = clamp(dragBaseY + (e.clientY - dragStartY), -b.y, b.y);
apply();
}
function endDrag() { dragging = false; }
frame.addEventListener('pointerdown', onPointerDown);
frame.addEventListener('pointermove', onPointerMove);
frame.addEventListener('pointerup', endDrag);
frame.addEventListener('pointercancel', endDrag);
function cycle() {
openFrom();
timers.push(window.setTimeout(() => {
zoomTo(ZOOM_SCALE, () => {
panLoop(PAN_MS, () => {
zoomTo(1);
});
});
}, 260));
timers.push(window.setTimeout(closeLightbox, 1780));
}
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); });
if (panRafId) cancelAnimationFrame(panRafId);
thumb.removeEventListener('click', onThumbClick);
closeBtn.removeEventListener('click', onCloseClick);
backdrop.removeEventListener('click', onBackdropClick);
zoomInBtn.removeEventListener('click', onZoomInClick);
zoomOutBtn.removeEventListener('click', onZoomOutClick);
frame.removeEventListener('dblclick', onFrameDblClick);
document.removeEventListener('keydown', onKeydown);
frame.removeEventListener('pointerdown', onPointerDown);
frame.removeEventListener('pointermove', onPointerMove);
frame.removeEventListener('pointerup', endDrag);
frame.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} /></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="사진 확대 이동">
<div className={styles.frame}>
<span className={styles.fullImg} />
</div>
<button className={`${styles.zoomBtn} ${styles['zoomBtn--in']}`} 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="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8h-24v24a8 8 0 0 1-16 0v-24H80a8 8 0 0 1 0-16h24V80a8 8 0 0 1 16 0v24h24a8 8 0 0 1 8 8" />
</g>
</svg>
</button>
<button className={`${styles.zoomBtn} ${styles['zoomBtn--out']}`} 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="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8H80a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8" />
</g>
</svg>
</button>
<p className={styles.zoomNote}>1.0×</p>
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}05 · 拡大後にドラッグで移動するライトボックス — 計測 累積面積 30.183% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function panBounds(s) {
var r = frame.getBoundingClientRect();
return { x: (s - 1) * r.width / 2, y: (s - 1) * r.height / 2 };
}
function apply() {
fullImg.style.transform = 'translate(' + offX.toFixed(2) + 'px,' + offY.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
zoomNote.textContent = scale.toFixed(1) + '×';
}
function zoomTo(target, onDone) {
var fromScale = scale;
var fromX = offX, fromY = offY;
animate(ZOOM_MS, function (e) {
scale = fromScale + (target - fromScale) * e;
var b = panBounds(scale);
offX = clamp(fromX * (1 - e), -b.x, b.x);
offY = clamp(fromY * (1 - e), -b.y, b.y);
apply();
}, onDone);
}
06下に投げるように閉じるライトボックス
下方向にドラッグして指を離した瞬間、最後の移動速度が計算されます。十分速く振り払った時だけ画面外へ弾かれるように閉じ、遅い場合は元の位置に戻ります。

<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"></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="写真を投げて閉じる">
<span class="fullImg" id="fullImg"></span>
<p class="dragNote" id="dragNote">下へドラッグして閉じる</p>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-orange-deep;
// 계산 겹: index.html 의 pointermove 가 매 이동마다 세로 속도(px/ms) 를 재고, pointerup 에서
// 그 속도 또는 드래그 거리 비율로 "던졌는지" 판정한다. 던지면 그 속도로 남은 거리를 마저 날아가는
// 시간을 역산하고(flingClose), 아니면 스프링 오버슛 이징으로 되돌아온다(springBack).
@include stage($stage-yellow);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #fff3c4, $stage-yellow 85%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.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: 72px;
height: 72px;
border-radius: $r-sm;
box-shadow: $shadow-press;
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;
overflow: hidden;
}
.fullImg {
display: block;
width: 190px;
height: 130px;
border-radius: $r-card;
background: linear-gradient(135deg, $subject-pink, $subject-lilac 60%, $subject-sky);
touch-action: none;
cursor: grab;
&:active { cursor: grabbing; }
}
.dragNote {
position: absolute;
bottom: $sp-3;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 10px;
font-weight: 700;
color: $subject-cream;
background: rgba(#000, .38);
padding: $sp-1 $sp-3;
border-radius: $r-pill;
pointer-events: none;
}
.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; }
.fullImg { width: 140px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 던지기·스프링백 트윈은 index.html 의 animate() 가 reduce 시 즉시 최종 상태로 처리한다.
.closeBtn, .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: #ffd23f;
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, #fff3c4, #ffd23f 85%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #c93000;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 72px;
height: 72px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
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;
overflow: hidden;
}
.fullImg {
display: block;
width: 190px;
height: 130px;
border-radius: 16px;
background: linear-gradient(135deg, #ff9ebb, #b9a0ff 60%, #6ec8ff);
touch-action: none;
cursor: grab;
}
.fullImg:active {
cursor: grabbing;
}
.dragNote {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: 10px;
font-weight: 700;
color: #fff7e6;
background: rgba(0, 0, 0, 0.38);
padding: 4px 12px;
border-radius: 999px;
pointer-events: none;
}
.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 #c93000;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.fullImg {
width: 140px;
height: 96px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .thumb {
transition: none;
}
}(function () {
var LIGHTBOX_FLING_VELOCITY_PX_MS = 0.5; // 놓는 순간 이 속도(px/ms) 를 넘으면 던진 것으로 본다
var DRAG_COMMIT_RATIO = 0.32; // 드래그 거리 / 무대 높이 가 이 비율을 넘어도 던진 것으로 본다
var SPRING_BACK_MS = 380;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var fullImg = document.getElementById('fullImg');
var dragNote = document.getElementById('dragNote');
var closeBtn = document.getElementById('closeBtn');
var thumb = document.getElementById('thumb0');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var open = false;
var dragging = false;
var dragStartY = 0;
var dragStartX = 0;
var curY = 0;
var curX = 0;
var lastMoveY = 0;
var lastMoveT = 0;
var velocityY = 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 easeSpringBack(t) { var p = t - 1; return 1 + p * p * (2.7 * p + 1.7); }
function stageH() { return scene.getBoundingClientRect().height; }
function animate(duration, onFrame, onDone, easeFn) {
var ease = easeFn || easeOutCubic;
if (reduce) { onFrame(1); if (onDone) onDone(); return; }
var start = performance.now();
function frame(now) {
var p = clamp((now - start) / duration, 0, 1);
onFrame(ease(p));
if (p < 1) requestAnimationFrame(frame);
else if (onDone) onDone();
}
requestAnimationFrame(frame);
}
function setDrag(x, y, progress) {
fullImg.style.transform = 'translate(' + x.toFixed(2) + 'px,' + y.toFixed(2) + 'px) scale(' + (1 - progress * 0.22).toFixed(4) + ')';
backdrop.style.opacity = (0.78 * (1 - progress)).toFixed(3);
dragNote.style.opacity = (1 - progress * 2).toFixed(3);
}
function openFrom() {
if (open) return;
open = true;
curX = 0; curY = 0;
dragNote.style.opacity = '1';
setDrag(0, 0, 0);
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(240, function (e) { backdrop.style.opacity = (e * 0.78).toFixed(3); }, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumb.focus({ preventScroll: true });
}
// 던진 속도로 남은 거리를 마저 날아가게 한다 — 값이 클수록 짧고 빠르게 끝난다
function flingClose(vy) {
var h = stageH();
var fromY = curY;
var fromX = curX;
var targetY = fromY + Math.sign(vy || 1) * (h * 1.4);
var duration = clamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320);
animate(duration, function (e) {
var y = fromY + (targetY - fromY) * e;
var progress = clamp(Math.abs(y) / h, 0, 1);
setDrag(fromX * (1 - e), y, progress);
}, closeLightbox, function (t) { return t; });
}
function springBack() {
var fromX = curX;
var fromY = curY;
animate(SPRING_BACK_MS, function (e) {
setDrag(fromX * (1 - e), fromY * (1 - e), 0);
}, function () {
curX = 0; curY = 0;
closeBtn.focus({ preventScroll: true });
}, easeSpringBack);
}
function human() {
if (scene.classList.contains('is-demo')) {
scene.classList.remove('is-demo');
timers.forEach(function (id) { clearTimeout(id); clearInterval(id); });
timers = [];
}
}
thumb.addEventListener('click', function () { human(); openFrom(); });
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
document.addEventListener('keydown', function (e) {
if (open && e.key === 'Escape') { human(); closeLightbox(); }
});
fullImg.addEventListener('pointerdown', function (e) {
human();
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
lastMoveY = e.clientY;
lastMoveT = performance.now();
velocityY = 0;
fullImg.setPointerCapture(e.pointerId);
});
fullImg.addEventListener('pointermove', function (e) {
if (!dragging) return;
var now = performance.now();
var dt = now - lastMoveT;
if (dt > 0) velocityY = (e.clientY - lastMoveY) / dt;
lastMoveY = e.clientY;
lastMoveT = now;
curX = e.clientX - dragStartX;
curY = Math.max(0, e.clientY - dragStartY);
var progress = clamp(curY / (stageH() * DRAG_COMMIT_RATIO), 0, 1);
setDrag(curX, curY, progress);
});
function endDrag() {
if (!dragging) return;
dragging = false;
var h = stageH();
var commit = curY > h * DRAG_COMMIT_RATIO || Math.abs(velocityY) > LIGHTBOX_FLING_VELOCITY_PX_MS;
if (commit) flingClose(velocityY);
else springBack();
}
fullImg.addEventListener('pointerup', endDrag);
fullImg.addEventListener('pointercancel', endDrag);
function cycle() {
openFrom();
timers.push(setTimeout(function () {
curX = 6; curY = 0;
var h = stageH();
var fromY = 0;
animate(360, function (e) {
curY = fromY + h * 0.62 * e;
setDrag(curX * (1 - e * 0.4), curY, e * 0.7);
}, function () {
flingClose(LIGHTBOX_FLING_VELOCITY_PX_MS * 1.6);
});
}, 420));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './CloseVelocityFling.module.scss';
const LIGHTBOX_FLING_VELOCITY_PX_MS = 0.5; // 놓는 순간 이 속도(px/ms) 를 넘으면 던진 것으로 본다
const DRAG_COMMIT_RATIO = 0.32; // 드래그 거리 / 무대 높이 가 이 비율을 넘어도 던진 것으로 본다
const SPRING_BACK_MS = 380;
const LOOP_MS = 2000;
export default function CloseVelocityFling() {
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 fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const dragNote0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.dragNote);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumb0 = scene0.querySelector<HTMLButtonElement>('.' + styles.thumb);
if (!backdrop0 || !lightbox0 || !fullImg0 || !dragNote0 || !closeBtn0 || !thumb0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const fullImg = fullImg0;
const dragNote = dragNote0;
const closeBtn = closeBtn0;
const thumb = thumb0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let open = false;
let dragging = false;
let dragStartY = 0;
let dragStartX = 0;
let curY = 0;
let curX = 0;
let lastMoveY = 0;
let lastMoveT = 0;
let velocityY = 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 easeSpringBack(t: number) { const p = t - 1; return 1 + p * p * (2.7 * p + 1.7); }
function stageH() { return scene.getBoundingClientRect().height; }
function animate(duration: number, onFrame: (e: number) => void, onDone?: () => void, easeFn?: (t: number) => number) {
const ease = easeFn || easeOutCubic;
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(ease(p));
if (p < 1) requestAnimationFrame(frame);
else if (onDone) onDone();
}
requestAnimationFrame(frame);
}
function setDrag(x: number, y: number, progress: number) {
fullImg.style.transform = 'translate(' + x.toFixed(2) + 'px,' + y.toFixed(2) + 'px) scale(' + (1 - progress * 0.22).toFixed(4) + ')';
backdrop.style.opacity = (0.78 * (1 - progress)).toFixed(3);
dragNote.style.opacity = (1 - progress * 2).toFixed(3);
}
function openFrom() {
if (open) return;
open = true;
curX = 0; curY = 0;
dragNote.style.opacity = '1';
setDrag(0, 0, 0);
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(240, (e) => { backdrop.style.opacity = (e * 0.78).toFixed(3); }, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumb.focus({ preventScroll: true });
}
// 던진 속도로 남은 거리를 마저 날아가게 한다 — 값이 클수록 짧고 빠르게 끝난다
function flingClose(vy: number) {
const h = stageH();
const fromY = curY;
const fromX = curX;
const targetY = fromY + Math.sign(vy || 1) * (h * 1.4);
const duration = clamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320);
animate(duration, (e) => {
const y = fromY + (targetY - fromY) * e;
const progress = clamp(Math.abs(y) / h, 0, 1);
setDrag(fromX * (1 - e), y, progress);
}, closeLightbox, (t) => t);
}
function springBack() {
const fromX = curX;
const fromY = curY;
animate(SPRING_BACK_MS, (e) => {
setDrag(fromX * (1 - e), fromY * (1 - e), 0);
}, () => {
curX = 0; curY = 0;
closeBtn.focus({ preventScroll: true });
}, easeSpringBack);
}
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 = [];
}
}
function onThumbClick() { human(); openFrom(); }
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
function onKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') { human(); closeLightbox(); }
}
thumb.addEventListener('click', onThumbClick);
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
document.addEventListener('keydown', onKeydown);
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
lastMoveY = e.clientY;
lastMoveT = performance.now();
velocityY = 0;
fullImg.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const now = performance.now();
const dt = now - lastMoveT;
if (dt > 0) velocityY = (e.clientY - lastMoveY) / dt;
lastMoveY = e.clientY;
lastMoveT = now;
curX = e.clientX - dragStartX;
curY = Math.max(0, e.clientY - dragStartY);
const progress = clamp(curY / (stageH() * DRAG_COMMIT_RATIO), 0, 1);
setDrag(curX, curY, progress);
}
function endDrag() {
if (!dragging) return;
dragging = false;
const h = stageH();
const commit = curY > h * DRAG_COMMIT_RATIO || Math.abs(velocityY) > LIGHTBOX_FLING_VELOCITY_PX_MS;
if (commit) flingClose(velocityY);
else springBack();
}
fullImg.addEventListener('pointerdown', onPointerDown);
fullImg.addEventListener('pointermove', onPointerMove);
fullImg.addEventListener('pointerup', endDrag);
fullImg.addEventListener('pointercancel', endDrag);
function cycle() {
openFrom();
timers.push(window.setTimeout(() => {
curX = 6; curY = 0;
const h = stageH();
const fromY = 0;
animate(360, (e) => {
curY = fromY + h * 0.62 * e;
setDrag(curX * (1 - e * 0.4), curY, e * 0.7);
}, () => {
flingClose(LIGHTBOX_FLING_VELOCITY_PX_MS * 1.6);
});
}, 420));
}
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); });
thumb.removeEventListener('click', onThumbClick);
closeBtn.removeEventListener('click', onCloseClick);
backdrop.removeEventListener('click', onBackdropClick);
document.removeEventListener('keydown', onKeydown);
fullImg.removeEventListener('pointerdown', onPointerDown);
fullImg.removeEventListener('pointermove', onPointerMove);
fullImg.removeEventListener('pointerup', endDrag);
fullImg.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} /></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="사진 던져서 닫기">
<span className={styles.fullImg} />
<p className={styles.dragNote}>아래로 끌어서 닫기</p>
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}06 · 下に投げるように閉じるライトボックス — 計測 累積面積 39.872% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function flingClose(vy) {
var h = stageH();
var fromY = curY;
var fromX = curX;
var targetY = fromY + Math.sign(vy || 1) * (h * 1.4);
var duration = clamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320);
animate(duration, function (e) {
var y = fromY + (targetY - fromY) * e;
var progress = clamp(Math.abs(y) / h, 0, 1);
setDrag(fromX * (1 - e), y, progress);
}, closeLightbox, function (t) { return t; });
}
function springBack() {
var fromX = curX;
var fromY = curY;
animate(SPRING_BACK_MS, function (e) {
setDrag(fromX * (1 - e), fromY * (1 - e), 0);
}, function () {
curX = 0; curY = 0;
closeBtn.focus({ preventScroll: true });
}, easeSpringBack);
}
07フォーカスリングが滑りながら閉じ込められるライトボックス
ライトボックス内でタブや矢印キーによってフォーカスが次のボタンへ移るたびに、フォーカス枠は瞬時に移動せず前の位置から次の位置へ滑るように動きます。今どこに焦点があるか目で追い続けられます。

<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"></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="キーボードだけで動くライトボックス">
<span class="fullImg" id="fullImg"></span>
<div class="toolbar" id="toolbar">
<button class="ctrlBtn" id="prevBtn" type="button">前へ</button>
<button class="ctrlBtn" id="infoBtn" type="button">情報</button>
<button class="ctrlBtn ctrlBtn--primary" id="nextBtn" type="button">次へ</button>
</div>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
<span class="focusRing" id="focusRing" aria-hidden="true"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-sky;
// 계산 겹: index.html 의 glideRingTo() 가 링의 실제 이전 위치와 다음 포커스 요소의 실제 위치를
// (getBoundingClientRect, FLIP) 측정해 그 차이만큼 이동·스케일 보간한다 — 좌표를 하드코딩하지 않는다.
@include stage($stage-orange);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #ffe3b0, $stage-orange 85%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.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: 72px;
height: 72px;
border-radius: $r-sm;
box-shadow: $shadow-press;
background: linear-gradient(135deg, $subject-blue, $subject-sky);
}
.backdrop {
position: absolute;
inset: 0;
background: $subject-ink;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
visibility: hidden;
}
.fullImg {
display: block;
width: 190px;
height: 108px;
border-radius: $r-card;
background: linear-gradient(135deg, $subject-blue, $subject-sky 60%, $subject-mint);
}
.toolbar {
display: flex;
gap: $sp-2;
}
.ctrlBtn {
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;
position: relative;
&--primary { background: $subject-sky; color: $subject-ink; }
}
.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;
}
.focusRing {
position: absolute;
top: 0;
left: 0;
border-radius: $r-pill;
border: 2px solid $color;
box-shadow: 0 0 0 3px rgba($subject-sky, .32);
opacity: 0;
pointer-events: none;
will-change: transform;
}
@media (max-width: 340px) {
.scene { width: 220px; height: 124px; }
.fullImg { width: 140px; height: 80px; }
.ctrlBtn { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 링 이동은 index.html 의 animate() 가 reduce 시 즉시 최종 위치로 처리한다.
.closeBtn, .ctrlBtn, .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: #ff4d1f;
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, #ffe3b0, #ff4d1f 85%);
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 72px;
height: 72px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: linear-gradient(135deg, #2f6df6, #6ec8ff);
}
.backdrop {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
visibility: hidden;
}
.fullImg {
display: block;
width: 190px;
height: 108px;
border-radius: 16px;
background: linear-gradient(135deg, #2f6df6, #6ec8ff 60%, #4cd4a6);
}
.toolbar {
display: flex;
gap: 8px;
}
.ctrlBtn {
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;
position: relative;
}
.ctrlBtn--primary {
background: #6ec8ff;
color: #17141a;
}
.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;
}
.focusRing {
position: absolute;
top: 0;
left: 0;
border-radius: 999px;
border: 2px solid #6ec8ff;
box-shadow: 0 0 0 3px rgba(110, 200, 255, 0.32);
opacity: 0;
pointer-events: none;
will-change: transform;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.fullImg {
width: 140px;
height: 80px;
}
.ctrlBtn {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .ctrlBtn, .thumb {
transition: none;
}
}(function () {
var LIGHTBOX_FOCUS_RING_MS = 220; // 포커스 링이 次へ 요소로 미끄러지는 시간
var STEP_HOLD_MS = 260;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var closeBtn = document.getElementById('closeBtn');
var prevBtn = document.getElementById('prevBtn');
var infoBtn = document.getElementById('infoBtn');
var nextBtn = document.getElementById('nextBtn');
var focusRing = document.getElementById('focusRing');
var thumb = document.getElementById('thumb0');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 실제 tab 순서 — 이 배열이 곧 트랩의 앞뒤 경계다
var order = [closeBtn, prevBtn, infoBtn, nextBtn];
var open = false;
var timers = [];
var lastRingRect = null;
function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
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);
}
// FLIP: 링의 지금 실제 위치와 목표 요소의 실제 위치를 측정해 그 차만큼 되돌렸다가 0 으로 애니메이션한다
function glideRingTo(el) {
var boxRect = lightbox.getBoundingClientRect();
var toRect = el.getBoundingClientRect();
var toX = toRect.left - boxRect.left;
var toY = toRect.top - boxRect.top;
var toW = toRect.width;
var toH = toRect.height;
var fromRect = lastRingRect || { x: toX, y: toY, w: toW, h: toH };
focusRing.style.width = toW + 'px';
focusRing.style.height = toH + 'px';
focusRing.style.transform = 'translate(' + toX + 'px,' + toY + 'px)';
var dx = fromRect.x - toX;
var dy = fromRect.y - toY;
var sx = fromRect.w / toW;
var sy = fromRect.h / toH;
focusRing.style.opacity = '1';
animate(LIGHTBOX_FOCUS_RING_MS, function (e) {
var ix = dx * (1 - e);
var iy = dy * (1 - e);
var isx = sx + (1 - sx) * e;
var isy = sy + (1 - sy) * e;
focusRing.style.transform = 'translate(' + (toX + ix).toFixed(2) + 'px,' + (toY + iy).toFixed(2) + 'px) scale(' + isx.toFixed(4) + ',' + isy.toFixed(4) + ')';
}, function () {
lastRingRect = { x: toX, y: toY, w: toW, h: toH };
});
}
order.forEach(function (el) {
el.addEventListener('focus', function () { glideRingTo(el); });
});
function openFrom() {
if (open) return;
open = true;
lastRingRect = null;
focusRing.style.opacity = '0';
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(220, function (e) { backdrop.style.opacity = (e * 0.78).toFixed(3); }, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(200, 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';
focusRing.style.opacity = '0';
open = false;
thumb.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 = [];
}
}
thumb.addEventListener('click', function () { human(); openFrom(); });
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
// 진짜 포커스 트랩: 끝에서 Tab 하면 처음으로, 처음에서 Shift+Tab 하면 끝으로 감긴다
lightbox.addEventListener('keydown', function (e) {
if (!open) return;
if (e.key === 'Escape') { human(); closeLightbox(); return; }
if (e.key !== 'Tab') return;
var i = order.indexOf(document.activeElement);
if (i === -1) return;
if (e.shiftKey && i === 0) {
e.preventDefault();
human();
order[order.length - 1].focus({ preventScroll: true });
} else if (!e.shiftKey && i === order.length - 1) {
e.preventDefault();
human();
order[0].focus({ preventScroll: true });
}
});
function cycle() {
openFrom();
var i = 0;
function step() {
if (i >= order.length) {
// 끝에서 다시 처음으로 — 실제 트랩과 같은 감김을 시연
order[0].focus({ preventScroll: true });
timers.push(setTimeout(closeLightbox, STEP_HOLD_MS));
return;
}
order[i].focus({ preventScroll: true });
i += 1;
timers.push(setTimeout(step, STEP_HOLD_MS));
}
timers.push(setTimeout(step, 260));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './KeyboardFocusTrapRing.module.scss';
const LIGHTBOX_FOCUS_RING_MS = 220; // 포커스 링이 다음 요소로 미끄러지는 시간
const STEP_HOLD_MS = 260;
const LOOP_MS = 2000;
type RingRect = { x: number; y: number; w: number; h: number };
export default function KeyboardFocusTrapRing() {
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 closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const prevBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.prevBtn);
const infoBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.infoBtn);
const nextBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.nextBtn);
const focusRing0 = scene0.querySelector<HTMLSpanElement>('.' + styles.focusRing);
const thumb0 = scene0.querySelector<HTMLButtonElement>('.' + styles.thumb);
if (!backdrop0 || !lightbox0 || !closeBtn0 || !prevBtn0 || !infoBtn0 || !nextBtn0 || !focusRing0 || !thumb0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const closeBtn = closeBtn0;
const prevBtn = prevBtn0;
const infoBtn = infoBtn0;
const nextBtn = nextBtn0;
const focusRing = focusRing0;
const thumb = thumb0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 실제 tab 순서 — 이 배열이 곧 트랩의 앞뒤 경계다
const order = [closeBtn, prevBtn, infoBtn, nextBtn];
let open = false;
let timers: number[] = [];
let lastRingRect: RingRect | null = null;
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 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);
}
// FLIP: 링의 지금 실제 위치와 목표 요소의 실제 위치를 측정해 그 차만큼 되돌렸다가 0 으로 애니메이션한다
function glideRingTo(el: HTMLElement) {
const boxRect = lightbox.getBoundingClientRect();
const toRect = el.getBoundingClientRect();
const toX = toRect.left - boxRect.left;
const toY = toRect.top - boxRect.top;
const toW = toRect.width;
const toH = toRect.height;
const fromRect: RingRect = lastRingRect || { x: toX, y: toY, w: toW, h: toH };
focusRing.style.width = toW + 'px';
focusRing.style.height = toH + 'px';
focusRing.style.transform = 'translate(' + toX + 'px,' + toY + 'px)';
const dx = fromRect.x - toX;
const dy = fromRect.y - toY;
const sx = fromRect.w / toW;
const sy = fromRect.h / toH;
focusRing.style.opacity = '1';
animate(LIGHTBOX_FOCUS_RING_MS, (e) => {
const ix = dx * (1 - e);
const iy = dy * (1 - e);
const isx = sx + (1 - sx) * e;
const isy = sy + (1 - sy) * e;
focusRing.style.transform = 'translate(' + (toX + ix).toFixed(2) + 'px,' + (toY + iy).toFixed(2) + 'px) scale(' + isx.toFixed(4) + ',' + isy.toFixed(4) + ')';
}, () => {
lastRingRect = { x: toX, y: toY, w: toW, h: toH };
});
}
const focusHandlers = order.map((el) => () => glideRingTo(el));
order.forEach((el, i) => el.addEventListener('focus', focusHandlers[i]));
function openFrom() {
if (open) return;
open = true;
lastRingRect = null;
focusRing.style.opacity = '0';
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(220, (e) => { backdrop.style.opacity = (e * 0.78).toFixed(3); }, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(200, (e) => { backdrop.style.opacity = (0.78 * (1 - e)).toFixed(3); }, () => {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
focusRing.style.opacity = '0';
open = false;
thumb.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 = [];
}
}
function onThumbClick() { human(); openFrom(); }
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
thumb.addEventListener('click', onThumbClick);
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
// 진짜 포커스 트랩: 끝에서 Tab 하면 처음으로, 처음에서 Shift+Tab 하면 끝으로 감긴다
function onLightboxKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') { human(); closeLightbox(); return; }
if (e.key !== 'Tab') return;
const i = order.indexOf(document.activeElement as HTMLButtonElement);
if (i === -1) return;
if (e.shiftKey && i === 0) {
e.preventDefault();
human();
order[order.length - 1].focus({ preventScroll: true });
} else if (!e.shiftKey && i === order.length - 1) {
e.preventDefault();
human();
order[0].focus({ preventScroll: true });
}
}
lightbox.addEventListener('keydown', onLightboxKeydown);
function cycle() {
openFrom();
let i = 0;
function step() {
if (i >= order.length) {
// 끝에서 다시 처음으로 — 실제 트랩과 같은 감김을 시연
order[0].focus({ preventScroll: true });
timers.push(window.setTimeout(closeLightbox, STEP_HOLD_MS));
return;
}
order[i].focus({ preventScroll: true });
i += 1;
timers.push(window.setTimeout(step, STEP_HOLD_MS));
}
timers.push(window.setTimeout(step, 260));
}
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); });
order.forEach((el, i) => el.removeEventListener('focus', focusHandlers[i]));
thumb.removeEventListener('click', onThumbClick);
closeBtn.removeEventListener('click', onCloseClick);
backdrop.removeEventListener('click', onBackdropClick);
lightbox.removeEventListener('keydown', onLightboxKeydown);
};
}, []);
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} /></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="키보드로만 움직이는 라이트박스">
<span className={styles.fullImg} />
<div className={styles.toolbar}>
<button className={styles.prevBtn + ' ' + styles.ctrlBtn} type="button">이전</button>
<button className={styles.infoBtn + ' ' + styles.ctrlBtn} type="button">정보</button>
<button className={`${styles.nextBtn} ${styles.ctrlBtn} ${styles['ctrlBtn--primary']}`} type="button">다음</button>
</div>
<button className={styles.closeBtn} type="button">닫기</button>
<span className={styles.focusRing} aria-hidden="true" />
</div>
</div>
);
}07 · フォーカスリングが滑りながら閉じ込められるライトボックス — 計測 累積面積 39.817% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
var fromRect = lastRingRect || { x: toX, y: toY, w: toW, h: toH };
focusRing.style.width = toW + 'px';
focusRing.style.height = toH + 'px';
focusRing.style.transform = 'translate(' + toX + 'px,' + toY + 'px)';
var dx = fromRect.x - toX;
var dy = fromRect.y - toY;
var sx = fromRect.w / toW;
var sy = fromRect.h / toH;
focusRing.style.opacity = '1';
animate(LIGHTBOX_FOCUS_RING_MS, function (e) {
var ix = dx * (1 - e);
var iy = dy * (1 - e);
var isx = sx + (1 - sx) * e;
var isy = sy + (1 - sy) * e;
focusRing.style.transform = 'translate(' + (toX + ix).toFixed(2) + 'px,' + (toY + iy).toFixed(2) + 'px) scale(' + isx.toFixed(4) + ',' + isy.toFixed(4) + ')';
}, function () {
lastRingRect = { x: toX, y: toY, w: toW, h: toH };
});
08サムネイル列が追従して動くライトボックス
ライトボックス下部の小さなサムネイル帯では、今見ている写真のサムネイルだけに枠が付き、画像が切り替わるたびにその枠を中心に帯全体が再配置されます。082のスライダー自体のサムネイルトラックとは異なる実演で、こちらはスライダー本体ではなく既に開いているライトボックスに付随する補助の帯です。
<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>
</ul>
<div class="backdrop" id="backdrop"></div>
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="サムネイル列と一緒に見る">
<span class="fullImg" id="fullImg"></span>
<div class="strip" id="strip">
<div class="stripTrack" id="stripTrack">
<button class="stripThumb" id="stripThumb0" type="button" aria-label="1枚目の写真"><span class="thumbImg thumbImg--a"></span></button>
<button class="stripThumb" id="stripThumb1" type="button" aria-label="2枚目の写真"><span class="thumbImg thumbImg--b"></span></button>
<button class="stripThumb" id="stripThumb2" type="button" aria-label="3枚目の写真"><span class="thumbImg thumbImg--c"></span></button>
<button class="stripThumb" id="stripThumb3" type="button" aria-label="4枚目の写真"><span class="thumbImg thumbImg--d"></span></button>
<button class="stripThumb" id="stripThumb4" type="button" aria-label="5枚目の写真"><span class="thumbImg thumbImg--e"></span></button>
</div>
</div>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
// 계산 겹: index.html 의 centerOffsetFor() 가 활성 썸네일의 실측 offsetLeft·offsetWidth 와
// 스트립의 실측 clientWidth 로 "가운데로 오는 이동량"을 매 전환마다 다시 계산한다(index*고정폭 아님).
@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;
}
.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: 72px;
height: 72px;
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); }
&--d { background: linear-gradient(135deg, $subject-sky, $subject-mint); }
&--e { background: linear-gradient(135deg, $subject-lilac, $subject-blue); }
}
.backdrop {
position: absolute;
inset: 0;
background: $subject-ink;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
visibility: hidden;
}
.fullImg {
display: block;
width: 210px;
height: 108px;
border-radius: $r-card;
&--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); }
&--d { background: linear-gradient(135deg, $subject-sky, $subject-mint); }
&--e { background: linear-gradient(135deg, $subject-lilac, $subject-blue); }
}
.strip {
position: relative;
width: 240px;
overflow: hidden;
}
.stripTrack {
display: flex;
gap: $sp-2;
width: max-content;
will-change: transform;
}
.stripThumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
flex: 0 0 auto;
border-radius: $r-sm;
.thumbImg { width: 40px; height: 40px; opacity: .55; }
&.is-active .thumbImg {
opacity: 1;
outline: 2px solid $color;
outline-offset: 2px;
}
&:focus-visible .thumbImg { outline: 2px solid $color; outline-offset: 2px; }
}
.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-cream;
background: rgba($subject-ink, .82);
cursor: pointer;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
@media (max-width: 340px) {
.scene { width: 220px; height: 124px; }
.fullImg { width: 150px; height: 80px; }
.strip { width: 170px; }
.stripThumb .thumbImg { width: 30px; height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 스트립 중앙 정렬 이동은 index.html 의 animate() 가 reduce 시 즉시 최종 위치로 처리한다.
.closeBtn, .thumb, .stripThumb { 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;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 72px;
height: 72px;
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);
}
.thumbImg--d {
background: linear-gradient(135deg, #6ec8ff, #4cd4a6);
}
.thumbImg--e {
background: linear-gradient(135deg, #b9a0ff, #2f6df6);
}
.backdrop {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
visibility: hidden;
}
.fullImg {
display: block;
width: 210px;
height: 108px;
border-radius: 16px;
}
.fullImg--a {
background: linear-gradient(135deg, #2f6df6, #b9a0ff);
}
.fullImg--b {
background: linear-gradient(135deg, #4cd4a6, #6ec8ff);
}
.fullImg--c {
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
}
.fullImg--d {
background: linear-gradient(135deg, #6ec8ff, #4cd4a6);
}
.fullImg--e {
background: linear-gradient(135deg, #b9a0ff, #2f6df6);
}
.strip {
position: relative;
width: 240px;
overflow: hidden;
}
.stripTrack {
display: flex;
gap: 8px;
width: max-content;
will-change: transform;
}
.stripThumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
flex: 0 0 auto;
border-radius: 8px;
}
.stripThumb .thumbImg {
width: 40px;
height: 40px;
opacity: 0.55;
}
.stripThumb.is-active .thumbImg {
opacity: 1;
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.stripThumb:focus-visible .thumbImg {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.closeBtn {
position: absolute;
top: 8px;
right: 8px;
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
color: #fff7e6;
background: rgba(23, 20, 26, 0.82);
cursor: pointer;
}
.closeBtn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.fullImg {
width: 150px;
height: 80px;
}
.strip {
width: 170px;
}
.stripThumb .thumbImg {
width: 30px;
height: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.closeBtn, .thumb, .stripThumb {
transition: none;
}
}(function () {
var SYNC_MS = 320;
var HOLD_MS = 340;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var fullImg = document.getElementById('fullImg');
var strip = document.getElementById('strip');
var stripTrack = document.getElementById('stripTrack');
var closeBtn = document.getElementById('closeBtn');
var thumb = document.getElementById('thumb0');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var stripThumbs = [].slice.call(document.querySelectorAll('.stripThumb'));
var imgClassByIdx = ['a', 'b', 'c', 'd', 'e'];
var COUNT = stripThumbs.length;
var open = false;
var idx = 0;
var currentX = 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 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);
}
// 활성 썸네일을 줄 가운데로 오게 하는 이동량을 그때그때 실측한다 — index * 고정폭이 아니다.
// (082/256 의 정적 배열형 스트립과 다른 지점: 여기는 화면 폭이 바뀌거나 썸네일 크기가 달라져도
// offsetLeft·clientWidth 실측으로 다시 계산되므로 항상 정중앙에 맞는다.)
function centerOffsetFor(el) {
var thumbLeft = el.offsetLeft;
var thumbW = el.offsetWidth;
var stripW = strip.clientWidth;
var trackW = stripTrack.scrollWidth;
var target = stripW / 2 - thumbLeft - thumbW / 2;
var minOffset = Math.min(0, stripW - trackW);
return clamp(target, minOffset, 0);
}
function setStripX(px) { stripTrack.style.transform = 'translateX(' + px.toFixed(2) + 'px)'; }
function goTo(nextIdx, onDone) {
nextIdx = clamp(nextIdx, 0, COUNT - 1);
idx = nextIdx;
fullImg.className = 'fullImg fullImg--' + imgClassByIdx[idx];
stripThumbs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
var target = centerOffsetFor(stripThumbs[idx]);
var fromX = currentX;
animate(SYNC_MS, function (e) {
var x = fromX + (target - fromX) * e;
setStripX(x);
}, function () {
currentX = target;
if (onDone) onDone();
});
}
function openFrom() {
if (open) return;
open = true;
idx = 0;
currentX = 0;
setStripX(0);
fullImg.className = 'fullImg fullImg--a';
stripThumbs.forEach(function (t, i) { t.classList.toggle('is-active', i === 0); });
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(220, function (e) { backdrop.style.opacity = (e * 0.78).toFixed(3); }, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(200, 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;
thumb.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 = [];
}
}
thumb.addEventListener('click', function () { human(); openFrom(); });
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
stripThumbs.forEach(function (t, i) {
t.addEventListener('click', function () { human(); goTo(i); });
});
document.addEventListener('keydown', function (e) {
if (!open) return;
if (e.key === 'Escape') { human(); closeLightbox(); }
else if (e.key === 'ArrowRight') { human(); goTo(idx + 1); }
else if (e.key === 'ArrowLeft') { human(); goTo(idx - 1); }
});
function cycle() {
openFrom();
timers.push(setTimeout(function () { goTo(2); }, 300));
timers.push(setTimeout(function () { goTo(4); }, 300 + SYNC_MS + HOLD_MS));
timers.push(setTimeout(closeLightbox, 300 + (SYNC_MS + HOLD_MS) * 2));
}
if (!reduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
}
})();import { useEffect, useRef } from 'react';
import styles from './ThumbnailStripSync.module.scss';
const SYNC_MS = 320;
const HOLD_MS = 340;
const LOOP_MS = 2000;
export default function ThumbnailStripSync() {
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 fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const strip0 = scene0.querySelector<HTMLDivElement>('.' + styles.strip);
const stripTrack0 = scene0.querySelector<HTMLDivElement>('.' + styles.stripTrack);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumb0 = scene0.querySelector<HTMLButtonElement>('.' + styles.thumb);
const stripThumbs = Array.from(scene0.querySelectorAll<HTMLButtonElement>('.' + styles.stripThumb));
if (!backdrop0 || !lightbox0 || !fullImg0 || !strip0 || !stripTrack0 || !closeBtn0 || !thumb0 || stripThumbs.length === 0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const fullImg = fullImg0;
const strip = strip0;
const stripTrack = stripTrack0;
const closeBtn = closeBtn0;
const thumb = thumb0;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const imgClassByIdx = ['a', 'b', 'c', 'd', 'e'];
const fullImgClassByIdx = [styles['fullImg--a'], styles['fullImg--b'], styles['fullImg--c'], styles['fullImg--d'], styles['fullImg--e']];
const COUNT = stripThumbs.length;
let open = false;
let idx = 0;
let currentX = 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 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);
}
// 활성 썸네일을 줄 가운데로 오게 하는 이동량을 그때그때 실측한다 — index * 고정폭이 아니다.
function centerOffsetFor(el: HTMLElement) {
const thumbLeft = el.offsetLeft;
const thumbW = el.offsetWidth;
const stripW = strip.clientWidth;
const trackW = stripTrack.scrollWidth;
const target = stripW / 2 - thumbLeft - thumbW / 2;
const minOffset = Math.min(0, stripW - trackW);
return clamp(target, minOffset, 0);
}
function setStripX(px: number) { stripTrack.style.transform = 'translateX(' + px.toFixed(2) + 'px)'; }
function goTo(nextIdxRaw: number, onDone?: () => void) {
const nextIdx = clamp(nextIdxRaw, 0, COUNT - 1);
idx = nextIdx;
fullImg.className = styles.fullImg + ' ' + fullImgClassByIdx[idx];
stripThumbs.forEach((t, i) => { t.classList.toggle(styles['is-active'], i === idx); });
const target = centerOffsetFor(stripThumbs[idx]);
const fromX = currentX;
animate(SYNC_MS, (e) => {
const x = fromX + (target - fromX) * e;
setStripX(x);
}, () => {
currentX = target;
if (onDone) onDone();
});
}
function openFrom() {
if (open) return;
open = true;
idx = 0;
currentX = 0;
setStripX(0);
fullImg.className = styles.fullImg + ' ' + styles['fullImg--a'];
stripThumbs.forEach((t, i) => { t.classList.toggle(styles['is-active'], i === 0); });
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(220, (e) => { backdrop.style.opacity = (e * 0.78).toFixed(3); }, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(200, (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;
thumb.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 = [];
}
}
function onThumbClick() { human(); openFrom(); }
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
thumb.addEventListener('click', onThumbClick);
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
const stripHandlers = stripThumbs.map((_, i) => () => { human(); goTo(i); });
stripThumbs.forEach((t, i) => t.addEventListener('click', stripHandlers[i]));
function onKeydown(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') { human(); closeLightbox(); }
else if (e.key === 'ArrowRight') { human(); goTo(idx + 1); }
else if (e.key === 'ArrowLeft') { human(); goTo(idx - 1); }
}
document.addEventListener('keydown', onKeydown);
function cycle() {
openFrom();
timers.push(window.setTimeout(() => { goTo(2); }, 300));
timers.push(window.setTimeout(() => { goTo(4); }, 300 + SYNC_MS + HOLD_MS));
timers.push(window.setTimeout(closeLightbox, 300 + (SYNC_MS + HOLD_MS) * 2));
}
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); });
thumb.removeEventListener('click', onThumbClick);
closeBtn.removeEventListener('click', onCloseClick);
backdrop.removeEventListener('click', onBackdropClick);
stripThumbs.forEach((t, i) => t.removeEventListener('click', stripHandlers[i]));
document.removeEventListener('keydown', onKeydown);
};
}, []);
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>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="썸네일 줄과 함께 보기">
<span className={styles.fullImg} />
<div className={styles.strip}>
<div className={styles.stripTrack}>
<button className={styles.stripThumb} type="button" aria-label="1번째 사진"><span className={`${styles.thumbImg} ${styles['thumbImg--a']}`} /></button>
<button className={styles.stripThumb} type="button" aria-label="2번째 사진"><span className={`${styles.thumbImg} ${styles['thumbImg--b']}`} /></button>
<button className={styles.stripThumb} type="button" aria-label="3번째 사진"><span className={`${styles.thumbImg} ${styles['thumbImg--c']}`} /></button>
<button className={styles.stripThumb} type="button" aria-label="4번째 사진"><span className={`${styles.thumbImg} ${styles['thumbImg--d']}`} /></button>
<button className={styles.stripThumb} type="button" aria-label="5번째 사진"><span className={`${styles.thumbImg} ${styles['thumbImg--e']}`} /></button>
</div>
</div>
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}08 · サムネイル列が追従して動くライトボックス — 計測 累積面積 39.872% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function goTo(nextIdx, onDone) {
nextIdx = clamp(nextIdx, 0, COUNT - 1);
idx = nextIdx;
fullImg.className = 'fullImg fullImg--' + imgClassByIdx[idx];
stripThumbs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
var target = centerOffsetFor(stripThumbs[idx]);
var fromX = currentX;
animate(SYNC_MS, function (e) {
var x = fromX + (target - fromX) * e;
setStripX(x);
}, function () {
currentX = target;
if (onDone) onDone();
});
}
09モーション削減を即座に反映するライトボックス
モーションを減らす設定になっていると、拡大・スワイプ・弾む動きをすべて省略し、画像と背景がスイッチのように瞬時に現れて消えるようにします。

<main class="stage">
<div class="scene is-demo" id="scene">
<div class="switchRow">
<button class="switch" id="switchBtn" type="button" role="switch" aria-checked="false" aria-label="モーション削減シミュレーション">
<span class="switchKnob" id="switchKnob"></span>
</button>
<p class="switchLabel" id="switchLabel">モーション通常</p>
</div>
<ul class="grid" id="grid">
<li><button class="thumb" id="thumb0" type="button" aria-haspopup="dialog"><span class="thumbImg"></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="モーション削減の比較">
<span class="fullImg" id="fullImg"></span>
<p class="modeNote" id="modeNote">通常モーションで開いた</p>
<button class="closeBtn" id="closeBtn" type="button">閉じる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-lilac;
// 계산 겹: index.html 은 실제 `matchMedia('(prefers-reduced-motion: reduce)')` 결과와 화면의
// 시뮬레이션 스위치를 OR 로 합친 effectiveReduce() 하나로 열기/닫기 트윈 실행 여부를 결정한다 —
// 실제 설정이 바뀌는 change 이벤트와 스위치 클릭 둘 다 같은 함수로 수렴한다.
@include stage($stage-ink);
.scene {
position: relative;
width: 320px;
height: 180px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: linear-gradient(160deg, #232a3a, $stage-ink 85%);
}
.switchRow {
position: absolute;
top: $sp-2;
left: $sp-2;
display: flex;
align-items: center;
gap: $sp-2;
z-index: 2;
}
.switch {
appearance: none;
border: none;
width: 36px;
height: 20px;
border-radius: $r-pill;
background: rgba($subject-cream, .28);
padding: $sp-1;
cursor: pointer;
position: relative;
&.is-on { background: $color; }
}
.switchKnob {
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
background: $subject-cream;
transform: translateX(0);
transition: transform $dur-quick $ease-out;
.switch.is-on & { transform: translateX(16px); }
}
.switchLabel {
margin: 0;
font-size: 9px;
font-weight: 700;
color: $subject-cream;
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.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: 72px;
height: 72px;
border-radius: $r-sm;
box-shadow: $shadow-press;
background: linear-gradient(135deg, $subject-lilac, $subject-blue);
}
.backdrop {
position: absolute;
inset: 0;
background: $subject-ink;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
visibility: hidden;
}
.fullImg {
display: block;
width: 190px;
height: 110px;
border-radius: $r-card;
background: linear-gradient(135deg, $subject-lilac, $subject-blue 55%, $subject-sky);
}
.modeNote {
margin: 0;
font-size: 10px;
font-weight: 700;
color: $subject-cream;
background: rgba(#000, .38);
padding: $sp-1 $sp-3;
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; }
.fullImg { width: 140px; height: 84px; }
.switchLabel { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용 — 열기/닫기 트윈은 index.html 의 effectiveReduce() 가 실제 매체쿼리를 읽어
// reduce 시 animate() 를 건너뛰고 즉시 최종 상태로 처리한다. 스위치 노브의 슬라이드도 여기서 끈다.
.switchKnob { transition: none; }
.closeBtn, .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: #17141a;
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, #232a3a, #17141a 85%);
}
.switchRow {
position: absolute;
top: 8px;
left: 8px;
display: flex;
align-items: center;
gap: 8px;
z-index: 2;
}
.switch {
appearance: none;
border: none;
width: 36px;
height: 20px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.28);
padding: 4px;
cursor: pointer;
position: relative;
}
.switch.is-on {
background: #b9a0ff;
}
.switchKnob {
display: block;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff7e6;
transform: translateX(0);
transition: transform 220ms ease-out;
}
.switch.is-on .switchKnob {
transform: translateX(16px);
}
.switchLabel {
margin: 0;
font-size: 9px;
font-weight: 700;
color: #fff7e6;
}
.grid {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
}
.thumb {
appearance: none;
border: none;
background: none;
padding: 0;
cursor: pointer;
}
.thumb:focus-visible .thumbImg {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.thumbImg {
display: block;
width: 72px;
height: 72px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
background: linear-gradient(135deg, #b9a0ff, #2f6df6);
}
.backdrop {
position: absolute;
inset: 0;
background: #17141a;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.lightbox {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
visibility: hidden;
}
.fullImg {
display: block;
width: 190px;
height: 110px;
border-radius: 16px;
background: linear-gradient(135deg, #b9a0ff, #2f6df6 55%, #6ec8ff);
}
.modeNote {
margin: 0;
font-size: 10px;
font-weight: 700;
color: #fff7e6;
background: rgba(0, 0, 0, 0.38);
padding: 4px 12px;
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 #b9a0ff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.scene {
width: 220px;
height: 124px;
}
.fullImg {
width: 140px;
height: 84px;
}
.switchLabel {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.switchKnob {
transition: none;
}
.closeBtn, .thumb {
transition: none;
}
}(function () {
var OPEN_MS = 420;
var CLOSE_MS = 320;
var HOLD_MS = 380;
var LOOP_MS = 2000;
var scene = document.getElementById('scene');
var backdrop = document.getElementById('backdrop');
var lightbox = document.getElementById('lightbox');
var fullImg = document.getElementById('fullImg');
var modeNote = document.getElementById('modeNote');
var closeBtn = document.getElementById('closeBtn');
var thumb = document.getElementById('thumb0');
var switchBtn = document.getElementById('switchBtn');
var switchLabel = document.getElementById('switchLabel');
// 실제 OS/브라우저 설정 — 이 값이 true 면 시뮬레이션 스위치와 무관하게 항상 즉시 전환한다
var mql = window.matchMedia('(prefers-reduced-motion: reduce)');
var realReduce = mql.matches;
// 시연용 시뮬레이션 스위치 — 실제 설정이 "보통"이어도 이 편의 라이트박스만 강제로 즉시 전환해 차이를 비교한다
var simulateReduce = false;
function effectiveReduce() { return realReduce || simulateReduce; }
var open = false;
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 animate(duration, onFrame, onDone) {
if (effectiveReduce()) { 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 updateSwitchUI() {
switchBtn.setAttribute('aria-checked', simulateReduce ? 'true' : 'false');
switchBtn.classList.toggle('is-on', simulateReduce);
if (realReduce) {
switchLabel.textContent = '実際の設定:モーション削減オン';
} else {
switchLabel.textContent = simulateReduce ? 'モーション削減シミュレーションオン' : 'モーション通常';
}
}
function setSimulate(on) {
simulateReduce = on;
updateSwitchUI();
}
function openFrom() {
if (open) return;
open = true;
var reduceNow = effectiveReduce();
modeNote.textContent = reduceNow ? '即時切り替え(モーション削減)' : '通常モーションで開いた';
fullImg.style.transform = 'scale(.82)';
fullImg.style.opacity = '0';
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(OPEN_MS, function (e) {
fullImg.style.transform = 'scale(' + (0.82 + 0.18 * e).toFixed(4) + ')';
fullImg.style.opacity = e.toFixed(3);
backdrop.style.opacity = (0.74 * e).toFixed(3);
}, function () {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(CLOSE_MS, function (e) {
fullImg.style.opacity = (1 - e).toFixed(3);
backdrop.style.opacity = (0.74 * (1 - e)).toFixed(3);
}, function () {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumb.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 = [];
}
}
thumb.addEventListener('click', function () { human(); openFrom(); });
closeBtn.addEventListener('click', function () { human(); closeLightbox(); });
backdrop.addEventListener('click', function () { human(); closeLightbox(); });
switchBtn.addEventListener('click', function () { human(); setSimulate(!simulateReduce); });
document.addEventListener('keydown', function (e) {
if (open && e.key === 'Escape') { human(); closeLightbox(); }
});
// 실제 설정이 바뀌면(운영체제·브라우저 쪽) 즉시 반영한다 — 시뮬레이션과 별개의 진짜 경로
function onRealChange(e) {
realReduce = e.matches;
updateSwitchUI();
}
if (mql.addEventListener) mql.addEventListener('change', onRealChange);
else if (mql.addListener) mql.addListener(onRealChange);
function cycle() {
setSimulate(false);
openFrom();
timers.push(setTimeout(closeLightbox, OPEN_MS + HOLD_MS));
timers.push(setTimeout(function () {
setSimulate(true);
openFrom();
}, OPEN_MS + HOLD_MS + CLOSE_MS + 160));
timers.push(setTimeout(closeLightbox, OPEN_MS + HOLD_MS + CLOSE_MS + 160 + HOLD_MS));
}
updateSwitchUI();
if (!realReduce) {
cycle();
timers.push(setInterval(function () {
if (scene.classList.contains('is-demo')) cycle();
}, LOOP_MS));
} else {
// 실제 설정이 이미 줄이기라면 루프를 돌리지 않고 정적 상태로 둔다 — 실사용 경로 그대로
modeNote.textContent = '即時切り替え(モーション削減)';
}
})();import { useEffect, useRef } from 'react';
import styles from './ReducedMotionInstant.module.scss';
const OPEN_MS = 420;
const CLOSE_MS = 320;
const HOLD_MS = 380;
const LOOP_MS = 2000;
export default function ReducedMotionInstant() {
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 fullImg0 = scene0.querySelector<HTMLSpanElement>('.' + styles.fullImg);
const modeNote0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.modeNote);
const closeBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.closeBtn);
const thumb0 = scene0.querySelector<HTMLButtonElement>('.' + styles.thumb);
const switchBtn0 = scene0.querySelector<HTMLButtonElement>('.' + styles.switch);
const switchLabel0 = scene0.querySelector<HTMLParagraphElement>('.' + styles.switchLabel);
if (!backdrop0 || !lightbox0 || !fullImg0 || !modeNote0 || !closeBtn0 || !thumb0 || !switchBtn0 || !switchLabel0) return undefined;
const scene = scene0;
const backdrop = backdrop0;
const lightbox = lightbox0;
const fullImg = fullImg0;
const modeNote = modeNote0;
const closeBtn = closeBtn0;
const thumb = thumb0;
const switchBtn = switchBtn0;
const switchLabel = switchLabel0;
// 실제 OS/브라우저 설정 — 이 값이 true 면 시뮬레이션 스위치와 무관하게 항상 즉시 전환한다
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
let realReduce = mql.matches;
// 시연용 시뮬레이션 스위치 — 실제 설정이 "보통"이어도 이 편의 라이트박스만 강제로 즉시 전환해 차이를 비교한다
let simulateReduce = false;
function effectiveReduce() { return realReduce || simulateReduce; }
let open = false;
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 animate(duration: number, onFrame: (e: number) => void, onDone?: () => void) {
if (effectiveReduce()) { 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 updateSwitchUI() {
switchBtn.setAttribute('aria-checked', simulateReduce ? 'true' : 'false');
switchBtn.classList.toggle(styles['is-on'], simulateReduce);
if (realReduce) {
switchLabel.textContent = '실제 설정: 모션 줄이기 켜짐';
} else {
switchLabel.textContent = simulateReduce ? '모션 줄이기 시뮬레이션 켜짐' : '모션 보통';
}
}
function setSimulate(on: boolean) {
simulateReduce = on;
updateSwitchUI();
}
function openFrom() {
if (open) return;
open = true;
const reduceNow = effectiveReduce();
modeNote.textContent = reduceNow ? '즉시 전환 (모션 줄이기)' : '보통 모션으로 열림';
fullImg.style.transform = 'scale(.82)';
fullImg.style.opacity = '0';
lightbox.style.visibility = 'visible';
lightbox.setAttribute('aria-hidden', 'false');
backdrop.style.visibility = 'visible';
animate(OPEN_MS, (e) => {
fullImg.style.transform = 'scale(' + (0.82 + 0.18 * e).toFixed(4) + ')';
fullImg.style.opacity = e.toFixed(3);
backdrop.style.opacity = (0.74 * e).toFixed(3);
}, () => {
closeBtn.focus({ preventScroll: true });
});
}
function closeLightbox() {
if (!open) return;
animate(CLOSE_MS, (e) => {
fullImg.style.opacity = (1 - e).toFixed(3);
backdrop.style.opacity = (0.74 * (1 - e)).toFixed(3);
}, () => {
lightbox.style.visibility = 'hidden';
lightbox.setAttribute('aria-hidden', 'true');
backdrop.style.visibility = 'hidden';
open = false;
thumb.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 = [];
}
}
function onThumbClick() { human(); openFrom(); }
function onCloseClick() { human(); closeLightbox(); }
function onBackdropClick() { human(); closeLightbox(); }
function onSwitchClick() { human(); setSimulate(!simulateReduce); }
function onKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') { human(); closeLightbox(); }
}
thumb.addEventListener('click', onThumbClick);
closeBtn.addEventListener('click', onCloseClick);
backdrop.addEventListener('click', onBackdropClick);
switchBtn.addEventListener('click', onSwitchClick);
document.addEventListener('keydown', onKeydown);
// 실제 설정이 바뀌면(운영체제·브라우저 쪽) 즉시 반영한다 — 시뮬레이션과 별개의 진짜 경로
function onRealChange(e: MediaQueryListEvent) {
realReduce = e.matches;
updateSwitchUI();
}
if (mql.addEventListener) mql.addEventListener('change', onRealChange);
function cycle() {
setSimulate(false);
openFrom();
timers.push(window.setTimeout(closeLightbox, OPEN_MS + HOLD_MS));
timers.push(window.setTimeout(() => {
setSimulate(true);
openFrom();
}, OPEN_MS + HOLD_MS + CLOSE_MS + 160));
timers.push(window.setTimeout(closeLightbox, OPEN_MS + HOLD_MS + CLOSE_MS + 160 + HOLD_MS));
}
updateSwitchUI();
if (!realReduce) {
cycle();
timers.push(window.setInterval(() => {
if (scene.classList.contains(styles['is-demo'])) cycle();
}, LOOP_MS));
} else {
// 실제 설정이 이미 줄이기라면 루프를 돌리지 않고 정적 상태로 둔다 — 실사용 경로 그대로
modeNote.textContent = '즉시 전환 (모션 줄이기)';
}
return () => {
timers.forEach((id) => { window.clearTimeout(id); window.clearInterval(id); });
thumb.removeEventListener('click', onThumbClick);
closeBtn.removeEventListener('click', onCloseClick);
backdrop.removeEventListener('click', onBackdropClick);
switchBtn.removeEventListener('click', onSwitchClick);
document.removeEventListener('keydown', onKeydown);
if (mql.removeEventListener) mql.removeEventListener('change', onRealChange);
};
}, []);
return (
<div className={`${styles.scene} ${styles['is-demo']}`} ref={sceneRef}>
<div className={styles.switchRow}>
<button className={styles.switch} type="button" role="switch" aria-checked="false" aria-label="모션 줄이기 시뮬레이션">
<span className={styles.switchKnob} />
</button>
<p className={styles.switchLabel}>모션 보통</p>
</div>
<ul className={styles.grid}>
<li><button className={styles.thumb} type="button" aria-haspopup="dialog"><span className={styles.thumbImg} /></button></li>
</ul>
<div className={styles.backdrop} />
<div className={styles.lightbox} role="dialog" aria-modal="true" aria-hidden="true" aria-label="모션 줄이기 비교">
<span className={styles.fullImg} />
<p className={styles.modeNote}>보통 모션으로 열림</p>
<button className={styles.closeBtn} type="button">닫기</button>
</div>
</div>
);
}09 · モーション削減を即座に反映するライトボックス — 計測 累積面積 22.44% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 実際のOS/ブラウザの設定 — この値がtrueなら、シミュレーションスイッチに関係なく常に即座に切り替わる
var mql = window.matchMedia('(prefers-reduced-motion: reduce)');
var realReduce = mql.matches;
// 実演用のシミュレーションスイッチ — 実際の設定が通常でも、このパターンのライトボックスだけを強制的に即座に切り替えて差を比較する
var simulateReduce = false;
function effectiveReduce() { return realReduce || simulateReduce; }
値がUIを変える場所 — 拡大の起点・速度の閾値・保持時間
多くの css ライトボックスの解説はフェードの長さだけで終わりますが、上の各パターンは実際に働く変数を1つだけ持っており、その1つの値を変えるだけでパターン全体の印象が変わります。
01 — 開始スケール比
scaleFromはfromRect.width / toRect.width、つまりサムネイル自身の実測幅をフルサイズ画像の幅で割った値そのものです。固定された定数ではないため、小さいサムネイルと大きいサムネイルでは同じ関数から見た目の異なる開始点が生まれます。
02 — 正規化された縦横比の値
normはfromAspect()が画像の幅と高さから作り出す1つの数値で、scaleFromもdimもその同じnormを別々の定数のペアの間で補間しただけの値です。画像の縦横比を変えれば、この2つの値は必ず一緒に動きます。
03 — 測定されたキャプションの高さ
capHは、スライドが始まる直前にcaption.getBoundingClientRect().heightで測定されます。1行のキャプションは短い距離しか動かず、3行のキャプションはずっと遠くまで動きますが、どちらも同じCAPTION_MSの時間で完了します。
04 — 確定の閾値
endDrag()が見ているのはSWIPE_COMMIT_RATIOとSWIPE_VELOCITY_PX_MSの2つの定数だけです。十分な距離のドラッグか十分な速さのフリックのどちらかが成立すれば次の画像へ確定し、どちらかの定数を下げるほど短く弱いジェスチャーでもスワイプとして認められます。
05 — 再計算されるパン範囲
panBounds(scale)はzoomTo()の中でアニメーションフレームごとに毎回再計算されており、一度きりの計算ではありません。scale自体がまだ目標値へイージング中でも、offXとoffYはその時点の可動域できちんと制限されます。
06 — 速度に比例した継続時間
flingClose()のdurationはclamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320)で決まります。速く離すほど短く俊敏なフリングになり、閾値ぎりぎりの速度で離すと上限に近い320msまで伸びます。
07 — 測定されたリングの矩形
lastRingRectはglideRingTo()が呼び出しの間で保持している唯一の状態です。これがなければリングには補間の起点となるfromRectがなく、フォーカスが移るたびに滑らずに瞬間移動するしかありません。
08 — クランプされた中心オフセット
centerOffsetFor()はgoTo()がアニメーションを始める前に必ずclamp()を通されており、これがアクティブなサムネイルを中心に寄せる際に帯の最初や最後の項目を超えて動かしてしまわないための唯一の一行です。
09 — 他のすべてのパターン自身のスイッチも動かすOR
effectiveReduce()(realReduce || simulateReduce)は、他の8つのファイルそれぞれのanimate()ヘルパーの中に独立して繰り返されている判定の、このパターンなりの版です。唯一の違いは、OS自体が通常のモーション設定のままでも比較のために即座の状態を強制できる、実演専用の2つ目のスイッチがある点です。
どこで壊れるか — アクセシビリティ・reduced-motion・フォーカス移動
07のorder配列は、閉じる・前へ・情報・次への4つのコントロールの間でキーボードフォーカスを巡回させ、glideRingTo()はそれぞれのfocusイベントのたびに実行されるため、Tabまたは Shift+Tabで次に移る先へリングが実際に滑って見え、最後のコントロールから最初へも正しく折り返します。しかし、このデモでclickハンドラーを持つのはcloseBtnだけです。prevBtn・infoBtn・nextBtnはトラップの中で他の実在するコントロールと同じようにフォーカスだけは受け取りますが、Enterキーやスペースキーを押しても何も起こりません。リングは今どこにフォーカスがあるかを正直に示していますが、4つの停止点のうち3つは、実際のギャラリーがその動作を実装するまで機能しないままです。
reduced motionで失われるもの
クリック・ドラッグ・キー操作といった各パターン自身のきっかけは、reduced motionの下でも同じように働きます。省略されるのは、その間のイージングされた動きだけです。各パターンが共有するanimate()ヘルパーが、通常のループを回す代わりに最終フレームを一度だけ呼び出して終わるためです。
- 01 サムネイル変形 — 補間が止まり、
fullImgは最終的な位置とスケールへ直接切り替わり、背景の不透明度も0.72へ一気に切り替わります。 - 02 背景フェード+スケールアップ — イージングされる拡大が止まり、画像はすでに縦横比から導かれた最終スケールで、背景も最終的な暗さですぐに表示されます。
- 03 キャプション上昇 — 二段階のアニメーションが両方止まり、画像は不透明度1で、キャプションも遅延やスライドなしにその場に表示されます。
- 04 スワイプ切り替え — ドラッグ自体は変わらず動き、距離か速度で確定する判定も同じですが、確定した後の切り替え自体がイージングなしに一気に決まります。
- 05 ズーム&パン — ダブルタップやボタンでの拡大は
scaleを変え続けますが、値はイージングせず目標へ直接飛び、パンの可動域も同じ瞬間に更新されます。 - 06 速度で閉じる — 速い解放も遅い解放も結果が即座に決まり、弧を描いて飛ぶ動きもバネのようなオーバーシュートも間に挟まりません。
- 07 フォーカストラップリング — Tabと Shift+Tabは
orderの両端で同じように折り返しますが、リングは次のコントロールへ滑らず瞬間移動します。 - 08 サムネイル帯の追従 — 画像が切り替わるたびに
centerOffsetFor()の目標へ帯は同じように再配置されますが、それが1フレームで完了し、イージングは挟まりません。 - 09 reduced-motion即時反映 — このパターンだけは何も変わりません。即座の切り替え自体がこのパターンの目的そのもので、
effectiveReduce()はOSの設定とシミュレーションスイッチをどちらの状態でも変わらず反映し続けます。
これら9つのファイルはすべて同じ一つのパスワード、nhfnnp8k、を共有しています。一度zipを解凍すれば、上で見た9パターン分の動作するコピーがそのままディスク上に揃い、ブラウザで直接開いて確認できます。
3つのバリエーションとアクセシビリティ
ここでのバリエーションは、外部のcss 画像 拡大 アニメーションのライブラリを一切必要とせず、アーカイブ内の同じファイルに含まれる定数を書き換えるだけで試せます。
| バリエーション | 値 | 感触 |
|---|---|---|
| サムネイル拡大の開始スケールを広げる | 01 LIGHTBOX_MORPH_SCALE_FROM 0.3 → 0.6 | 開始サイズを場面幅の30%から60%に広げると、サムネイルはすでに大きい状態から始まるため移動距離が短くなり、拡大の印象が弱まります。 |
| 閉じる判定のスワイプ速度閾値を下げる | 06 LIGHTBOX_FLING_VELOCITY_PX_MS 0.5 → 0.2 | 速度閾値を0.5から0.2に下げると、より遅い離し方でも投げたと判定され、閉じる条件がずっと緩くなります。 |
| フォーカスリングの移動時間を延ばす | 07 LIGHTBOX_FOCUS_RING_MS 220 → 400 | 移動時間を220msから400msに延ばすと、フォーカスリングが次の位置へよりゆっくり滑り、目で追いやすくなります。 |
9つのライトボックスはすべて同じダイアログの作法を共有しています。トリガーとなるサムネイルにはaria-haspopup="dialog"が付き、ライトボックス自体はrole="dialog"とaria-modal="true"を持ち、開閉のたびにaria-hiddenがtrueとfalseを切り替えます。スクリーンリーダーにとっては、9つのうちどのトランジションが開いていても同じ合図です。04だけは画像カウンター用にaria-live="polite"の領域を重ねており、イージングされたスライド自体を聞き取らなくても、スワイプでどの写真が今表示されているかがアナウンスされます。09のシミュレーションスイッチは、9つの中で唯一role="switch"とaria-checkedで作られたコントロールで、他のボタンのような一回きりの操作ではなく、本物のオン/オフの状態を表しているためです。
FAQ
これら9つのcssライトボックス転換は、実際の画像ホストやバックエンドが必要ですか。
いいえ。9つのファイルすべての画像はCSSのグラデーションかインラインSVGだけで作られており、ネットワークから何かを取得することも、Math.random()を使うこともありません。そのため、どのデモを再読み込みしても毎回まったく同じ数値と同じ動きになります。
prefers-reduced-motionが本当に尊重されているかは、どう確認すればよいですか。
OSのアクセシビリティ設定で「モーションを減らす」を有効にしてから上のデモを再読み込みしてみてください。9つすべてがwindow.matchMedia('(prefers-reduced-motion: reduce)')を呼び、同じanimate()のガードを通って最終状態へ直接切り替わります。09だけはさらに一歩進み、OSの設定を変えなくても画面上のスイッチだけで両方の状態を並べて比較できます。
css 画像 拡大 ポップアップのコードペン埋め込み版はありますか、それともダウンロードが必要ですか。
埋め込み版は1つのパターンだけを手早く見るには便利ですが、このページ自体には埋め込みを置いていません。アーカイブをダウンロードすれば、同じ9つのindex.htmlがすでに解凍された状態で手元に揃い、自分のディスク上でそのまま編集できます。
出典: MDN — Element.getBoundingClientRect()、MDN — Pointer events、MDN — dialog role、MDN — prefers-reduced-motion。
関連記事: 一行でコピペOK CSS アニメーション ギャラリー 9選 · 画像 スライダー css 9種 — GSAPなしでレスポンシブ対応 · 画像 拡大 表示 UI 9選 — 写真ファイルなしで作るビューア









