02Dialog focus trap — a modal confining its tab order to a recomputed range
Once a modal opens, the list of focusable elements inside it is recomputed and the tab order is confined to that range, so moving past the last element wraps the focus value back to the first.
Best forConfirmation modals, checkout or login popups
[data-f]preventScroll--i

<main class="stage">
<div class="ft is-demo" id="ft">
<button class="ft__open" id="ftOpen" type="button">Open modal</button>
<div class="ft__overlay" id="ftOverlay" hidden>
<div class="ft__dialog" role="dialog" aria-modal="true" aria-label="Confirm settings" id="ftDialog">
<div class="ft__marker" id="ftMarker" style="--i: 0" aria-hidden="true"></div>
<button class="ft__field" data-f type="button">Turn on notifications</button>
<button class="ft__field" data-f type="button">Allow sharing</button>
<button class="ft__field ft__close" data-f type="button" id="ftClose">Close</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 Dialog focus trap — a modal confining its tab order to a recomputed range — Measured area 20.114% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · Micro Interactions: 9 Essential UI Patterns
The post this demo lives in
Micro Interactions: 9 Essential UI PatternsMicro interactions: One toast recomputes its dismiss time from its stack order; one compares each heading's viewport ove See all demos in this post


