07フォーカスリングが滑りながら閉じ込められるライトボックス
ライトボックス内でタブや矢印キーによってフォーカスが次のボタンへ移るたびに、フォーカス枠は瞬時に移動せず前の位置から次の位置へ滑るように動き、今どこに焦点があるか目で追い続けられる。
使いどころキーボード操作の画像ビューアー、アクセシビリティ重視のギャラリー
getBoundingClientRectfocus trapwill-change: transform

<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 に同梱 · css ライトボックス 拡大アニメーション 9種まとめ
このデモが属する記事
css ライトボックス 拡大アニメーション 9種まとめこの css ライトボックス 9種のうち、1つはクリックしたサムネイルの位置からそのまま拡大し、1つは下向きスワイプの速度を計算して閉じるか戻すかを決め、1つはキーボード移動のたびにフォーカスリングが次の位置へ滑ります。 この記事のデモをすべて見る


