02다이얼로그 포커스 트랩 — 열린 상태에서 이동 범위 값을 안쪽으로 가두는 모달
모달이 열린 상태가 되면 탭 이동이 가능한 요소 목록을 다시 구해 그 범위 값 안에서만 순서가 돌게 하고, 마지막 요소에서 이동한 상태가 되면 위치 값이 첫 요소로 되돌아갑니다.
쓰임확인 모달, 결제·로그인 팝업
[data-f]preventScroll--i

<main class="stage">
<div class="ft is-demo" id="ft">
<button class="ft__open" id="ftOpen" type="button">모달 열기</button>
<div class="ft__overlay" id="ftOverlay" hidden>
<div class="ft__dialog" role="dialog" aria-modal="true" aria-label="설정 확인" id="ftDialog">
<div class="ft__marker" id="ftMarker" style="--i: 0" aria-hidden="true"></div>
<button class="ft__field" data-f type="button">알림 켜기</button>
<button class="ft__field" data-f type="button">공유 허용</button>
<button class="ft__field ft__close" data-f type="button" id="ftClose">닫기</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 다이얼로그 포커스 트랩 — 마커는 --i(현재 포커스 순서 값)로만 이동한다.
// 열릴 때마다 JS 가 [data-f] 개수를 다시 재고 그 범위 안에서만 --i 가 순환한다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
$row-h: 34px;
$row-step: 40px;
@include stage($stage-yellow);
.ft {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.ft__open {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-5;
font-family: inherit;
font-weight: 700;
font-size: 12px;
color: $stage-paper;
background: $subject-ink;
cursor: pointer;
}
.ft__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, .38);
}
.ft__dialog {
position: relative;
width: min(84%, 240px);
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
}
.ft__marker {
position: absolute;
left: $sp-2;
right: $sp-2;
top: $sp-4;
width: calc(100% - #{$sp-4});
height: $row-h;
border-radius: $r-sm;
background: rgba(47, 109, 246, .16);
border: 2px solid $color;
box-sizing: border-box;
transform: translateY(calc(var(--i, 0) * #{$row-step}));
transition: transform $duration $easing;
pointer-events: none;
}
.ft__field {
position: relative;
z-index: 1;
height: $row-h;
box-sizing: border-box;
border: 0;
border-radius: $r-sm;
padding: 0 $sp-3;
font-family: inherit;
font-weight: 600;
font-size: 12px;
text-align: left;
color: $subject-ink;
background: transparent;
cursor: pointer;
}
.ft__field:focus-visible { outline: none; }
@media (max-width: 340px) {
.ft__dialog { padding: $sp-3; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ft__marker { transition: none !important; }
}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);
}
.ft {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.ft__open {
border: 0;
border-radius: 999px;
padding: 8px 20px;
font-family: inherit;
font-weight: 700;
font-size: 12px;
color: #ffffff;
background: #17141a;
cursor: pointer;
}
.ft__overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(23, 20, 26, 0.38);
}
.ft__dialog {
position: relative;
width: min(84%, 240px);
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ft__marker {
position: absolute;
left: 8px;
right: 8px;
top: 16px;
width: calc(100% - 16px);
height: 34px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.16);
border: 2px solid #2f6df6;
box-sizing: border-box;
transform: translateY(calc(var(--i, 0) * 40px));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.ft__field {
position: relative;
z-index: 1;
height: 34px;
box-sizing: border-box;
border: 0;
border-radius: 8px;
padding: 0 12px;
font-family: inherit;
font-weight: 600;
font-size: 12px;
text-align: left;
color: #17141a;
background: transparent;
cursor: pointer;
}
.ft__field:focus-visible {
outline: none;
}
@media (max-width: 340px) {
.ft__dialog {
padding: 12px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ft__marker {
transition: none !important;
}
}(function () {
var root = document.getElementById('ft');
var openBtn = document.getElementById('ftOpen');
var overlay = document.getElementById('ftOverlay');
var dialog = document.getElementById('ftDialog');
var marker = document.getElementById('ftMarker');
var closeBtn = document.getElementById('ftClose');
var focusables = [];
var index = 0;
var autoplay = null;
// 계산 겹: 열릴 때마다 탭 이동 가능한 요소 목록을 다시 구해 범위(0..len-1) 를 정하고,
// 그 범위 밖으로 나가면 값이 되돌아 순환한다(모듈러 연산) — 고정 개수를 가정하지 않는다.
function measure() {
focusables = Array.prototype.slice.call(dialog.querySelectorAll('[data-f]'));
}
function setIndex(i) {
var len = focusables.length;
index = ((i % len) + len) % len;
marker.style.setProperty('--i', String(index));
focusables[index].focus({ preventScroll: true });
}
function open() {
measure();
overlay.hidden = false;
setIndex(0);
}
function close() {
overlay.hidden = true;
openBtn.focus({ preventScroll: true });
}
openBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
open();
});
closeBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
close();
});
dialog.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { root.classList.remove('is-demo'); stopAutoplay(); close(); return; }
if (e.key !== 'Tab') { return; }
e.preventDefault();
root.classList.remove('is-demo');
stopAutoplay();
setIndex(index + (e.shiftKey ? -1 : 1));
});
function stopAutoplay() {
if (autoplay) { clearInterval(autoplay); autoplay = null; }
}
// 자동 루프 — 실사용과 같은 open()/setIndex()/close() 를 반복 호출한다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
var step = 0;
autoplay = setInterval(function () {
if (overlay.hidden) { open(); step = 0; return; }
step += 1;
if (step > 4) { close(); return; }
setIndex(index + 1);
}, 420);
open();
}
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DialogFocusTrap.module.scss";
// 다이얼로그 포커스 트랩 — Tab 방향 값(+1/-1)으로 다음 인덱스를 순환 계산해 그 요소로 포커스를 옮긴다.
// 계산 겹: 열릴 때마다 [data-f] 개수를 다시 재고, (i % len + len) % len 순환 인덱스로만 --i 를 옮긴다.
export default function DialogFocusTrap() {
const rootRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const openBtnRef = useRef<HTMLButtonElement>(null);
const markerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const indexRef = useRef(0);
const autoplayRef = useRef<number | null>(null);
const openRef = useRef(open);
openRef.current = open;
function focusables(): HTMLElement[] {
const d = dialogRef.current;
if (!d) return [];
return Array.prototype.slice.call(d.querySelectorAll<HTMLElement>("[data-f]"));
}
function setIndex(i: number) {
const list = focusables();
const len = list.length;
if (!len) return;
const next = ((i % len) + len) % len;
indexRef.current = next;
markerRef.current?.style.setProperty("--i", String(next));
list[next].focus({ preventScroll: true });
}
function openDialog() {
setOpen(true);
window.setTimeout(() => setIndex(0), 0);
}
function closeDialog() {
setOpen(false);
openBtnRef.current?.focus({ preventScroll: true });
}
function stopAutoplay() {
if (autoplayRef.current) {
window.clearInterval(autoplayRef.current);
autoplayRef.current = null;
}
}
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
let step = 0;
autoplayRef.current = window.setInterval(() => {
if (!openRef.current) { openDialog(); step = 0; return; }
step += 1;
if (step > 4) { closeDialog(); return; }
setIndex((indexRef.current ?? 0) + 1);
}, 420);
openDialog();
}
return () => stopAutoplay();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
rootRef.current?.classList.remove(styles["is-demo"]);
stopAutoplay();
closeDialog();
return;
}
if (e.key !== "Tab") return;
e.preventDefault();
rootRef.current?.classList.remove(styles["is-demo"]);
stopAutoplay();
setIndex((indexRef.current ?? 0) + (e.shiftKey ? -1 : 1));
}
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.ft} ${styles["is-demo"]}`}>
<button
ref={openBtnRef}
className={styles.ft__open}
type="button"
onClick={() => { rootRef.current?.classList.remove(styles["is-demo"]); stopAutoplay(); openDialog(); }}
>
모달 열기
</button>
{open && (
<div className={styles.ft__overlay}>
<div className={styles.ft__dialog} ref={dialogRef} role="dialog" aria-modal="true" aria-label="설정 확인" onKeyDown={onKeyDown}>
<div ref={markerRef} className={styles.ft__marker} style={{ "--i": 0 } as CSSProperties} aria-hidden="true" />
<button className={styles.ft__field} data-f type="button">알림 켜기</button>
<button className={styles.ft__field} data-f type="button">공유 허용</button>
<button
className={`${styles.ft__field} ${styles.ft__close}`}
data-f
type="button"
onClick={() => { rootRef.current?.classList.remove(styles["is-demo"]); stopAutoplay(); closeDialog(); }}
>
닫기
</button>
</div>
</div>
)}
</div>
</main>
);
}02 다이얼로그 포커스 트랩 — 열린 상태에서 이동 범위 값을 안쪽으로 가두는 모달 — 측정 누적면적 20.114% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지
이 데모가 속한 글
마이크로 인터랙션 사이트 UI 패턴 9종 — 토스트부터 스크롤 스파이까지마이크로 인터랙션 사이트에 흔한 아홉 개 중 하나는 알림이 쌓인 순서 값으로 사라지는 시점을 다시 구해 뒤 알림 위치를 당기고, 하나는 스크롤된 위치에서 각 제목의 겹침 비율 값을 비교해 활성 항목을 바꾸며, 또 하 이 글의 데모 전부 보기


