06. 定番UI実装

この章では、Web制作でよく使うUIを実装します。

ここまで学んだ基礎文法、DOM操作、イベント処理を組み合わせると、実務でよく出てくるUIの多くを作れるようになります。

ただし、この章の目的は「コピペ用コードを集めること」ではありません。仕組みを理解して、案件ごとに調整できるようになることが目的です。

まず標準HTMLを検討する

UIを作る時は、最初からdivとJavaScriptだけで自作するのではなく、ブラウザが持っている標準HTMLで実現できないかを先に考えます。

作りたいUIまず検討するHTML
FAQや補足情報の開閉details + summary
ページ上に重ねるモーダルdialog
軽いナビゲーション開閉button + nav
背景を塞ぐスマホメニューdialog + nav

標準HTMLを使うと、キーボード操作、意味づけ、開閉状態などをブラウザに任せられる場面があります。

定番UIの共通パターン

Web制作の定番UIには、共通する流れがあります。

たとえば、開閉UIなら多くの場合この形になります。

開閉UIの基本形
const button = document.querySelector(".js-button");
const target = document.querySelector(".js-target");

if (button && target) {
    button.addEventListener("click", () => {
        const isOpen = button.getAttribute("aria-expanded") === "true";

        button.setAttribute("aria-expanded", String(!isOpen));
        target.hidden = isOpen;
        target.classList.toggle("is-active", !isOpen);
    });
}

メニューやドロップダウンなどは、この形を少しずつ発展させて作ります。

一方で、アコーディオンやモーダルのように標準HTMLがあるUIは、標準HTMLを土台にした方がシンプルになることがあります。

ハンバーガーメニュー

ハンバーガーメニューは、スマホサイトでよく使う開閉UIです。

ここで大事なのは、「ただナビゲーションを表示するだけなのか」「背景を塞いでメニュー操作に集中させるのか」を分けることです。

メニューの種類実装の考え方
ヘッダー内で軽く開閉するメニューbutton + navで状態を切り替える
画面全体や横から出るスマホメニューdialogの中にnavを入れる

この章では、背景を塞ぐスマホメニューとしてdialogを使います。

必要な状態は主に次の3つです。

  • ボタンが開いている状態か
  • ダイアログが開いているか
  • メニューを閉じた後、開いたボタンへフォーカスを戻すか

基本のHTML

HTML
<header class="site-header">
    <button
        class="menu-button js-menu-open"
        type="button"
        aria-expanded="false"
        aria-controls="global-menu"
    >
        メニュー
    </button>

    <dialog class="menu-dialog js-menu-dialog" id="global-menu" aria-label="グローバルメニュー">
        <div class="menu-dialog-body">
            <button class="menu-close js-menu-close" type="button">閉じる</button>

            <nav class="global-menu">
                <ul>
                    <li><a href="/">トップ</a></li>
                    <li><a href="/service/">サービス</a></li>
                    <li><a href="/contact/">お問い合わせ</a></li>
                </ul>
            </nav>
        </div>
    </dialog>
</header>

aria-expandedは、ボタンが対象を開いているかを表します。

aria-controlsは、そのボタンがどの要素を操作するかを示します。

dialogshowModal()で開くと、背景側の要素は操作できない状態になります。

基本のCSS

CSS
.menu-dialog {
    width: min(86vw, 360px);
    max-width: none;
    height: 100dvh;
    max-height: none;
    margin: 0 0 0 auto;
    border: 0;
    padding: 0;
}

.menu-dialog::backdrop {
    background: rgb(0 0 0 / 0.45);
}

.menu-dialog-body {
    min-height: 100%;
    padding: 24px;
}

見た目の切り替えはCSSに任せます。

JavaScriptでは、dialogを開く処理と閉じる処理を管理します。

基本のJavaScript

JavaScript
const menuOpenButton = document.querySelector(".js-menu-open");
const menuDialog = document.querySelector(".js-menu-dialog");

function openMenu() {
    if (!(menuDialog instanceof HTMLDialogElement)) {
        return;
    }

    menuDialog.showModal();
    menuOpenButton?.setAttribute("aria-expanded", "true");
}

function closeMenu() {
    if (!(menuDialog instanceof HTMLDialogElement)) {
        return;
    }

    menuDialog.close();
}

if (menuOpenButton && menuDialog instanceof HTMLDialogElement) {
    menuOpenButton.addEventListener("click", openMenu);
}

showModal()で開くと、Escキーで閉じる挙動もブラウザが持ってくれます。

ただし、閉じた後にボタンへフォーカスを戻したり、aria-expandedを戻したりする処理は書いておくと安心です。

閉じた後の状態を戻す
menuDialog.addEventListener("close", () => {
    menuOpenButton?.setAttribute("aria-expanded", "false");
    menuOpenButton?.focus();
});

メニューリンクを押したら閉じる

ページ内リンクやアンカーリンクを押した後、メニューを閉じたいことがあります。

メニューリンクで閉じる
const menuLinks = menuDialog.querySelectorAll("a");

menuLinks.forEach((link) => {
    link.addEventListener("click", () => {
        closeMenu();
    });
});

閉じるボタンでも同じ関数を使います。

閉じるボタン
const menuCloseButton = menuDialog.querySelector(".js-menu-close");

menuCloseButton?.addEventListener("click", closeMenu);

背景クリックで閉じる

背景をクリックした時に閉じたい場合は、クリックされた要素がdialog自身かどうかを確認します。

背景クリックで閉じる
menuDialog.addEventListener("click", (event) => {
    if (event.target === menuDialog) {
        closeMenu();
    }
});

dialogの中身をクリックした時は閉じず、背景部分をクリックした時だけ閉じるようにしています。

アコーディオン

アコーディオンは、質問と回答、補足情報、よくある質問などでよく使います。

単純な開閉なら、まずdetailssummaryを使います。

detailsは開閉する箱、summaryはクリックできる見出しです。開閉状態はopen属性で管理されます。

基本のHTML

HTML
<div class="accordion-list">
    <details class="accordion">
        <summary class="accordion-summary">
            質問1
        </summary>

        <div class="accordion-content">
            <p>回答1が入ります。</p>
        </div>
    </details>

    <details class="accordion">
        <summary class="accordion-summary">
            質問2
        </summary>

        <div class="accordion-content">
            <p>回答2が入ります。</p>
        </div>
    </details>
</div>

この形なら、開閉するだけであればJavaScriptは不要です。

detailsには、開いている時だけopen属性が付きます。

開いている状態
<details class="accordion" open>
    <summary class="accordion-summary">
        質問1
    </summary>

    <div class="accordion-content">
        <p>回答1が入ります。</p>
    </div>
</details>

CSSでは、このopen属性を使って見た目を切り替えられます。

CSS
.accordion {
    border-bottom: 1px solid #ddd;
}

.accordion-summary {
    cursor: pointer;
    padding: 16px 0;
}

.accordion-content {
    padding-bottom: 16px;
}

.accordion[open] .accordion-summary {
    color: #245f99;
}

1つだけ開くパターン

FAQでは、常に1つだけ開く仕様にしたい場合もあります。

その場合は、toggleイベントを使って、開かれたもの以外を閉じます。

1つだけ開く
const accordionGroups = document.querySelectorAll(".js-accordion-group");

accordionGroups.forEach((group) => {
    const accordions = group.querySelectorAll(".js-accordion");

    accordions.forEach((accordion) => {
        accordion.addEventListener("toggle", () => {
            if (!accordion.open) {
                return;
            }

            accordions.forEach((otherAccordion) => {
                if (otherAccordion !== accordion) {
                    otherAccordion.open = false;
                }
            });
        });
    });
});

この場合、HTML側にはグループと各detailsにクラスを付けておきます。

1つだけ開く場合のHTML
<div class="accordion-list js-accordion-group">
    <details class="accordion js-accordion">
        <summary>質問1</summary>
        <p>回答1が入ります。</p>
    </details>

    <details class="accordion js-accordion">
        <summary>質問2</summary>
        <p>回答2が入ります。</p>
    </details>
</div>

「複数開けるのか」「1つだけ開くのか」は、実装前に確認しましょう。

タブ切り替え

タブは、ボタンとパネルを連動させるUIです。

クリックされたタブをアクティブにし、対応するパネルだけを表示します。

基本のHTML

HTML
<div class="tabs js-tabs">
    <div class="tabs-list" role="tablist">
        <button class="tabs-button js-tab-button" type="button" role="tab" aria-selected="true" aria-controls="tab-panel-news" data-tab="news">
            お知らせ
        </button>
        <button class="tabs-button js-tab-button" type="button" role="tab" aria-selected="false" aria-controls="tab-panel-event" data-tab="event">
            イベント
        </button>
    </div>

    <div class="tabs-panel js-tab-panel" id="tab-panel-news" role="tabpanel" data-tab-panel="news">
        お知らせの内容
    </div>
    <div class="tabs-panel js-tab-panel" id="tab-panel-event" role="tabpanel" data-tab-panel="event" hidden>
        イベントの内容
    </div>
</div>

data-tabdata-tab-panelで、ボタンとパネルの対応関係を持たせています。

JavaScript

JavaScript
const tabs = document.querySelectorAll(".js-tabs");

tabs.forEach((tabGroup) => {
    const buttons = tabGroup.querySelectorAll(".js-tab-button");
    const panels = tabGroup.querySelectorAll(".js-tab-panel");

    buttons.forEach((button) => {
        button.addEventListener("click", () => {
            const targetName = button.dataset.tab;

            buttons.forEach((currentButton) => {
                const isSelected = currentButton === button;

                currentButton.classList.toggle("is-active", isSelected);
                currentButton.setAttribute("aria-selected", String(isSelected));
            });

            panels.forEach((panel) => {
                const isTarget = panel.dataset.tabPanel === targetName;

                panel.hidden = !isTarget;
                panel.classList.toggle("is-active", isTarget);
            });
        });
    });
});

このコードでは、クリックされたボタンのdata-tabを読み取り、同じ名前のdata-tab-panelを持つパネルだけを表示しています。

モーダル

モーダルは、ページの上に重ねて表示するUIです。

お問い合わせ導線、画像拡大、動画表示、注意事項などで使います。

モーダルは、まずdialogを使う方針で考えます。

dialogshowModal()で開くと、背景側を操作できない状態になり、Escキーで閉じる挙動もブラウザが持ってくれます。

基本のHTML

HTML
<button class="js-modal-open" type="button" data-modal-target="sample-modal">
    モーダルを開く
</button>

<dialog class="modal js-modal" id="sample-modal" aria-labelledby="sample-modal-title">
    <div class="modal-body">
        <h2 id="sample-modal-title">モーダルタイトル</h2>
        <p>モーダルの内容が入ります。</p>
        <button class="js-modal-close" type="button">閉じる</button>
    </div>
</dialog>

開くボタンには、どのモーダルを開くかをdata-modal-targetで持たせています。

dialog自体にaria-labelledbyを付けて、モーダルの見出しと紐づけています。

基本のCSS

CSS
.modal {
    width: min(92vw, 640px);
    border: 0;
    padding: 0;
}

.modal::backdrop {
    background: rgb(0 0 0 / 0.45);
}

.modal-body {
    padding: 32px;
}

背景の見た目は::backdropで指定できます。

JavaScript

JavaScript
const modalOpenButtons = document.querySelectorAll(".js-modal-open");
const modals = document.querySelectorAll(".js-modal");
let currentModalOpenButton = null;

function openModal(modal, button) {
    currentModalOpenButton = button;
    modal.showModal();
    document.body.classList.add("is-modal-open");
}

function closeModal(modal) {
    modal.close();
}

modalOpenButtons.forEach((button) => {
    button.addEventListener("click", () => {
        const targetId = button.dataset.modalTarget;
        const modal = document.getElementById(targetId);

        if (modal instanceof HTMLDialogElement) {
            openModal(modal, button);
        }
    });
});

modals.forEach((modal) => {
    if (!(modal instanceof HTMLDialogElement)) {
        return;
    }

    const closeButton = modal.querySelector(".js-modal-close");

    closeButton?.addEventListener("click", () => {
        closeModal(modal);
    });

    modal.addEventListener("close", () => {
        document.body.classList.remove("is-modal-open");
        currentModalOpenButton?.focus();
        currentModalOpenButton = null;
    });
});

この基本形では、開く処理と閉じる処理を関数に分けています。

閉じた時はcloseイベントで状態を戻し、モーダルを開いたボタンへフォーカスを戻します。

背景クリックで閉じる

背景クリックで閉じたい場合は、dialog自身がクリックされたかを確認します。

背景クリックで閉じる
modals.forEach((modal) => {
    modal.addEventListener("click", (event) => {
        if (event.target === modal) {
            closeModal(modal);
        }
    });
});

Escキーで閉じる挙動はdialogが持っています。独自にEscキー処理を書く必要があるのは、閉じる前に確認を出したい場合などです。

ドロップダウンメニュー

ドロップダウンは、グローバルナビや絞り込みメニューなどで使います。

PCではhoverで開くデザインもありますが、クリックやキーボード操作を考えると、JavaScriptで開閉状態を管理する方が安全な場面があります。

基本のHTML

HTML
<div class="dropdown js-dropdown">
    <button class="dropdown-button js-dropdown-button" type="button" aria-expanded="false" aria-controls="dropdown-menu">
        サービス
    </button>
    <div class="dropdown-menu js-dropdown-menu" id="dropdown-menu" hidden>
        <a href="/service/site/">サイト制作</a>
        <a href="/service/lp/">LP制作</a>
        <a href="/service/support/">運用サポート</a>
    </div>
</div>

JavaScript

JavaScript
const dropdowns = document.querySelectorAll(".js-dropdown");

dropdowns.forEach((dropdown) => {
    const button = dropdown.querySelector(".js-dropdown-button");
    const menu = dropdown.querySelector(".js-dropdown-menu");

    if (!button || !menu) {
        return;
    }

    button.addEventListener("click", () => {
        const isOpen = button.getAttribute("aria-expanded") === "true";

        button.setAttribute("aria-expanded", String(!isOpen));
        menu.hidden = isOpen;
        menu.classList.toggle("is-active", !isOpen);
    });
});

外側クリックで閉じる

ドロップダウンでは、メニュー外をクリックしたら閉じる仕様がよくあります。

外側クリックで閉じる
document.addEventListener("click", (event) => {
    dropdowns.forEach((dropdown) => {
        const button = dropdown.querySelector(".js-dropdown-button");
        const menu = dropdown.querySelector(".js-dropdown-menu");

        if (!button || !menu) {
            return;
        }

        const isInside = dropdown.contains(event.target);

        if (!isInside) {
            button.setAttribute("aria-expanded", "false");
            menu.hidden = true;
            menu.classList.remove("is-active");
        }
    });
});

dropdown.contains(event.target)で、クリックされた要素がドロップダウンの内側かどうかを判定しています。

スムーススクロール

ページ内リンクをクリックした時、対象位置までなめらかにスクロールする処理です。

ブラウザ標準のCSSでも実現できます。

CSS
html {
    scroll-behavior: smooth;
}

ただし、固定ヘッダー分の位置調整や、条件付きの処理が必要な場合はJavaScriptを使うことがあります。

HTML
<a class="js-smooth-scroll" href="#contact">お問い合わせへ</a>

<section id="contact">お問い合わせ</section>
JavaScript
const smoothScrollLinks = document.querySelectorAll(".js-smooth-scroll");

smoothScrollLinks.forEach((link) => {
    link.addEventListener("click", (event) => {
        const href = link.getAttribute("href");

        if (!href || !href.startsWith("#")) {
            return;
        }

        const target = document.querySelector(href);

        if (!target) {
            return;
        }

        event.preventDefault();

        const headerHeight = 80;
        const targetY = target.getBoundingClientRect().top + window.scrollY - headerHeight;

        window.scrollTo({
            top: targetY,
            behavior: "smooth",
        });
    });
});

getBoundingClientRect().topは、現在の画面内での要素の位置を返します。

そこにwindow.scrollYを足すことで、ページ全体での位置に変換しています。

ページトップボタン

ページトップボタンは、一定以上スクロールしたら表示し、クリックでページ上部へ戻るUIです。

HTML
<button class="page-top js-page-top" type="button" hidden>ページトップへ</button>
JavaScript
const pageTopButton = document.querySelector(".js-page-top");

if (pageTopButton) {
    window.addEventListener("scroll", () => {
        const shouldShow = window.scrollY > 300;

        pageTopButton.hidden = !shouldShow;
        pageTopButton.classList.toggle("is-visible", shouldShow);
    });

    pageTopButton.addEventListener("click", () => {
        window.scrollTo({
            top: 0,
            behavior: "smooth",
        });
    });
}

このコードはシンプルですが、実務ではフッターと重ならない位置調整が必要になることもあります。

ヘッダー制御

スクロール位置に応じて、ヘッダーの見た目を変える処理もよくあります。

スクロールでヘッダーを変える
const header = document.querySelector(".js-header");

if (header) {
    window.addEventListener("scroll", () => {
        const isScrolled = window.scrollY > 80;

        header.classList.toggle("is-scrolled", isScrolled);
    });
}

下にスクロールしたら隠し、上にスクロールしたら表示するパターンもあります。

スクロール方向でヘッダーを切り替える
const header = document.querySelector(".js-header");
let previousScrollY = window.scrollY;

if (header) {
    window.addEventListener("scroll", () => {
        const currentScrollY = window.scrollY;
        const isScrollingDown = currentScrollY > previousScrollY;
        const isPastHeader = currentScrollY > 120;

        header.classList.toggle("is-hidden", isScrollingDown && isPastHeader);

        previousScrollY = currentScrollY;
    });
}

UI実装でよく使う関数分け

コードが長くなってきたら、開く処理、閉じる処理、切り替える処理を関数に分けると読みやすくなります。

関数分け
function openMenu() {
    menuDialog.showModal();
    menuOpenButton.setAttribute("aria-expanded", "true");
}

function closeMenu() {
    menuDialog.close();
    menuOpenButton.setAttribute("aria-expanded", "false");
}

function toggleMenu() {
    if (menuDialog.open) {
        closeMenu();
    } else {
        openMenu();
    }
}

関数名を読むだけで、何をしているかがわかるようにします。

よくある失敗

定番UI実装でよくある失敗を整理しておきます。

  • 見た目のクラスだけ切り替えて、aria-expandedhiddenを更新していない
  • detailsdialogで済むUIを、すべて自作してしまう
  • 複数設置した時に、最初の1つしか動かない
  • ページに対象要素がない時にエラーになる
  • 開く処理と閉じる処理があちこちに重複している
  • メニューを開いたまま別リンクへ移動して、状態が残る
  • Escキーや外側クリックで閉じられない
  • モーダルを閉じた後のフォーカス位置を考えていない
  • スクロール処理に重い処理を入れてしまう

最初はすべてを完璧にする必要はありません。

ただし、実務では「マウスでクリックしたら動く」だけで完成ではない、という意識を持っておきましょう。

実装前のチェックリスト

UIを実装する前に、次のことを確認すると手戻りが減ります。

  • そのUIは1ページに複数出る可能性があるか
  • detailsdialogなど、標準HTMLで作れるUIか
  • 開いた状態、閉じた状態のHTML属性はどうするか
  • ボタンと対象要素の対応関係はどう持つか
  • スマホとPCで同じ挙動か
  • メニュー外クリックやEscキーで閉じる必要があるか
  • キーボード操作で困らないか
  • CSSだけで済む動きではないか
  • ライブラリを使うべき複雑さか

仕様を確認してから書き始めると、コードが自然にシンプルになります。

この章のまとめ

この章では、Web制作でよく使う定番UIの基本形を実装しました。

  • 定番UIは、DOM操作とイベント処理の組み合わせで作れる
  • アコーディオンは、まずdetailssummaryを検討する
  • モーダルは、まずdialogを検討する
  • JavaScriptは状態を切り替え、見た目はCSSに任せる
  • 自作の開閉UIでは、クラスだけでなくaria-expandedhiddenも更新する
  • 複数設置に対応するには、コンポーネント内から要素を探す
  • モーダルやドロップダウンでは、閉じる条件を複数考える
  • スクロール処理は便利だが、重くなりすぎないよう注意する
  • 実務では、クリック以外の操作やアクセシビリティも考える

次の章では、フォーム操作とバリデーションを扱います。入力値の取得、エラー表示、送信前チェック、二重送信防止など、実務でよく出るフォームまわりの処理を学びます。