<main class='main'>
<div class='item'>
<div class='box'></div>
<button type='button' class='trigger js-modal-toggle'>モーダルウィンドウを開く</button>
</div>
</main>
<div class='modal js-modal' role='dialog' aria-modal='true' aria-labelledby='modal-title01'>
<div class='modal-container'>
<h2 id='modal-title01' class='modal-title'>モーダルウィンドウタイトル</h2>
<p>
<a href='#' class='modal-link'>モーダルウィンドウ内フォーカス可能要素</a>
</p>
<button type='button' aria-label='モーダルウィンドウを閉じる' class='modal-trigger js-modal-toggle'></button>
</div>
</div>
.main {
height: 100dvh;
display: grid;
place-items: center;
padding: 30px;
.item {
min-width: 300px;
}
.box {
aspect-ratio: 1;
background-color: #ccc;
}
.trigger {
width: 100%;
display: block;
margin-inline: auto;
margin-top: 20px;
color: #333;
background-color: #fff;
padding: 16px;
border-radius: 30px;
font-weight: 700;
}
.link {
color: #fff;
text-decoration: underline;
text-align: center;
padding-top: 30px;
}
}
.modal {
position: fixed;
inset: 0;
z-index: calc(infinity);
width: 100%;
height: 100%;
opacity: 0;
visibility: hidden;
overflow: clip auto;
transition: opacity 0.3s ease, visibility 0.3s ease;
&.is-open {
opacity: 1;
visibility: visible;
&::before {
opacity: 1;
}
}
&::before {
content: "";
width: 100%;
height: 100%;
background-color: rgba(124, 255, 255, 0.3);
opacity: 0;
transition: opacity 0.3s ease;
position: fixed;
inset: 0;
}
.modal-container {
display: grid;
place-content: center;
place-items: center;
row-gap: 30px;
width: 600px;
aspect-ratio: 1;
position: absolute;
inset: 0;
margin: auto;
z-index: 1;
background-color: #fff;
.modal-title {
font-weight: 700;
font-size: 36px;
}
.modal-link {
color: #333;
text-decoration: underline;
}
.modal-trigger {
display: grid;
grid-template-areas: "line";
place-items: center;
width: 60px;
aspect-ratio: 1;
border: 2px solid #333;
position: absolute;
top: 10px;
right: 10px;
&::before,
&::after {
grid-area: line;
content: "";
width: 40px;
height: 2px;
background-color: #333;
}
&::before {
rotate: 45deg;
}
&::after {
rotate: -45deg;
}
}
}
}
* {
&:focus {
outline: 2px solid blue;
}
}
body {
width: 100%;
background-color: #333;
}
button {
background-color: transparent;
outline: none;
border: none;
appearance: none;
cursor: pointer;
}
View Compiled
class Modal {
constructor({
dialog = "",
toggleTrigger = "",
mainContent = ["main"]
}) {
// DOM要素
this.dialog = document.querySelector(dialog);
if (!this.dialog) return;
this.toggleTriggers = document.querySelectorAll(toggleTrigger);
this.body = document.body;
this.main = mainContent.map((selector) => document.querySelector(selector));
// フォーカス可能要素(参考:https://github.com/ghosh/Micromodal/blob/master/lib/src/index.js)
this.focusableEls = this.dialog.querySelectorAll(
'a[href], area[href], input:not([disabled]):not([type="hidden"]):not([aria-hidden]), select:not([disabled]):not([aria-hidden]), textarea:not([disabled]):not([aria-hidden]), button:not([disabled]):not([aria-hidden]), iframe, object, embed, [contenteditable], [tabindex]:not([tabindex^="-"])'
);
this.lastFocusedEl = null;
// フラグ
this.isOpen = this.dialog.classList.contains("is-open");
this.isAnimating = false;
}
// 初期化
init() {
this.toggleTriggers.forEach((trigger) => {
trigger.addEventListener("click", this._toggle.bind(this));
});
this.dialog.addEventListener("click", (e) => {
if (e.target === this.dialog) {
this._toggle();
}
});
this.dialog.addEventListener("keydown", this._handleKeyAction.bind(this));
}
// 破棄
destroy() {
this.toggleTriggers.forEach((trigger) => {
trigger.removeEventListener("click", this._toggle.bind(this));
});
this.dialog.removeEventListener("click", (e) => {
if (e.target === this.dialog) {
this._toggle();
}
});
this.dialog.removeEventListener(
"keydown",
this._handleKeyAction.bind(this)
);
if (this.lastFocusedEl) this.lastFocusedEl = null;
}
// 背面スクロール抑制
_scrollFixed(boolean) {
let scrollY;
if (boolean) {
scrollY = window.scrollY;
this.body.style.position = "fixed";
this.body.style.top = `-${scrollY}px`;
} else {
scrollY = this.body.style.top;
this.body.style.removeProperty("position");
this.body.style.removeProperty("top");
window.scrollTo(0, parseInt(scrollY || "0") * -1);
}
}
// アニメーションの待機
async _waitAnimation(target) {
const animations = target.getAnimations();
if (animations.length === 0) {
return Promise.resolve();
} else {
await Promise.allSettled(
animations.map((animation) => animation.finished)
);
}
}
// キーボード操作
_handleKeyAction(e) {
const firstFocusableEl = this.focusableEls[0];
const lastFocusableEl = this.focusableEls[this.focusableEls.length - 1];
switch (e.key) {
// Tab(フォーカストラップ)
case "Tab":
// Shift + Tab(戻る)
if (e.shiftKey) {
if (document.activeElement === firstFocusableEl) {
e.preventDefault();
// ダイアログ内で最初のフォーカス可能な要素の場合、最後のフォーカス可能要素にフォーカスを移す
lastFocusableEl.focus();
}
} else {
// Tab(進む)
if (document.activeElement === lastFocusableEl) {
e.preventDefault();
// ダイアログ内で最後のフォーカス可能な要素の場合、最初のフォーカス可能要素にフォーカスを移す
firstFocusableEl.focus();
}
}
break;
// Esc(閉じる)
case "Escape":
e.preventDefault();
this._toggle();
break;
}
}
// モーダルウィンドウの開閉
async _toggle() {
if (this.isAnimating) return;
this.isAnimating = true;
this.isOpen = !this.isOpen;
this.dialog.classList.toggle("is-open", this.isOpen);
this.main.forEach((el) => {
el && (el.inert = this.isOpen);
});
this._scrollFixed(this.isOpen);
if (this.isOpen) {
this.lastFocusedEl = document.activeElement;
await this._waitAnimation(this.dialog);
this.focusableEls[0].focus();
this.isAnimating = false;
} else {
this.lastFocusedEl.focus();
await this._waitAnimation(this.dialog);
this.lastFocusedEl = null;
this.isAnimating = false;
}
}
}
// クラスの実行
const modal = new Modal({
dialog: ".js-modal",
toggleTrigger: ".js-modal-toggle",
mainContent: ["main"]
});
modal.init();
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.