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 원작 · zip 에 SCSS·React 포함 · css 이미지 확대 애니메이션 9종 — 라이트박스 전환 모음
이 데모가 속한 글
css 이미지 확대 애니메이션 9종 — 라이트박스 전환 모음css 이미지 확대 애니메이션 아홉 가지 중 하나는 클릭한 썸네일이 있던 자리에서 그대로 커지며 펼쳐지고, 하나는 아래로 스와이프한 속도를 계산해 닫을지 되돌아갈지를 정하며, 하나는 키보드로 넘길 때마다 포커스 링이 이 글의 데모 전부 보기


