JavaScriptで「クラス付け替え」入門:UIを動かす最小テク

CSSだけでも見た目は作れますが、「開く/閉じる」「選択状態」「エラー表示」などの 状態(state) を表現したくなると、最小のJSが必要になります。
そのとき最もシンプルで強いのが “クラス付け替え(class toggling)” です。

この記事では、classList.add/remove/toggle を中心に、実務で困りがちな イベント委譲・ARIA・スクロールロック・アニメの相性 まで押さえ、最後に コピペで動くハンズオン を用意します。


この記事でできるようになること

  • classList を使って状態を切り替えられる
  • “見た目(CSS)” と “状態(JS)” を分離できる
  • モーダル/ドロワー/タブを「クラス付け替え」で実装できる
  • アクセシビリティ(ARIA/フォーカス)と相性よく作れる
  • “よくあるバグ” を回避できる

1. クラス付け替えは「状態管理」の最小単位

なぜクラス付け替えが強い?

  • JSは「状態を変えるだけ」に集中できる
  • 見た目・アニメはCSSに任せられる(保守しやすい)
  • 仕様変更(色/動き)でJSをいじらなくて済む

よくある状態クラス例

  • .is-open:開いている
  • .is-active:選択中
  • .is-hidden:非表示
  • .has-error:エラー状態
  • .is-loading:ローディング中

命名はプロジェクトで統一できればOKです。


2. classList の基本API

const el = document.querySelector(".card");

// 追加
el.classList.add("is-active");

// 削除
el.classList.remove("is-active");

// 反転(あれば消す、なければ追加)
el.classList.toggle("is-active");

// 条件つき toggle(第2引数)
const shouldActive = true;
el.classList.toggle("is-active", shouldActive);

// 含まれるか
el.classList.contains("is-active"); // true/false

“toggleの第2引数”が地味に便利

たとえば「モーダルを必ず閉じる」みたいなとき、removeでも良いですが、状態を真偽で書けると読みやすいです。


3. クラス付け替え × CSSアニメの基本パターン

JSはこうするだけ:

modal.classList.add("is-open");

CSSでこう表現:

.modal { opacity: 0; pointer-events: none; }
.modal.is-open { opacity: 1; pointer-events: auto; }

ポイント

  • display: none はアニメしにくい(できなくはないが面倒)
  • 代わりに opacity + pointer-events が鉄板

4. 実務で重要:イベント設計の定石

クリックで開く / 閉じる

  • 開く:ボタンに click
  • 閉じる:×ボタン、背景クリック、Escキー

“イベント委譲”でスッキリする

たくさんのボタンにリスナーを付けず、親でまとめて処理するテク。

document.addEventListener("click", (e) => {
  const openBtn = e.target.closest("[data-open-modal]");
  if (openBtn) { /* open */ }
});

5. アクセシビリティ(最低限ここはやる)

モーダルのARIA(最低限)

  • role="dialog"
  • aria-modal="true"
  • aria-hidden を状態に合わせて更新
  • 開いたらフォーカスを中へ、閉じたら元のボタンへ戻す

タブのARIA(最低限)

  • role="tablist", role="tab", role="tabpanel"
  • aria-selected, tabindex 切り替え

ここまでやると、キーボード操作でも破綻しづらいです。


ハンズオン:クラス付け替えで「モーダル + ドロワー + タブ」を作る

作るもの:

  1. モーダル(背景クリック・Escで閉じる、スクロールロック)
  2. 右から出るドロワー
  3. タブ切り替え(active状態)

0) フォルダ構成

class-toggle-hands-on/
  index.html
  styles.css
  app.js

1) index.html(コピペOK)

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>JS クラス付け替えハンズオン</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>
  <header class="header">
    <div class="container header__inner">
      <h1>JSでクラス付け替え(Hands-on)</h1>
      <div class="header__actions">
        <button class="btn btn--ghost" data-open-drawer>ドロワー</button>
        <button class="btn" data-open-modal>モーダル</button>
      </div>
    </div>
  </header>

  <main class="container">
    <section class="section">
      <h2>タブ(is-active の切り替え)</h2>

      <div class="tabs" data-tabs>
        <div class="tablist" role="tablist" aria-label="サンプルタブ">
          <button class="tab is-active" role="tab" aria-selected="true" tabindex="0" data-tab="a">概要</button>
          <button class="tab" role="tab" aria-selected="false" tabindex="-1" data-tab="b">詳細</button>
          <button class="tab" role="tab" aria-selected="false" tabindex="-1" data-tab="c">FAQ</button>
        </div>

        <div class="panels">
          <section class="panel is-active" role="tabpanel" data-panel="a">
            <h3>概要</h3>
            <p>JSはクラスを付け替えるだけ。見た目はCSSに任せます。</p>
          </section>
          <section class="panel" role="tabpanel" data-panel="b">
            <h3>詳細</h3>
            <p>イベント委譲とdata属性で、拡張しやすい実装にできます。</p>
          </section>
          <section class="panel" role="tabpanel" data-panel="c">
            <h3>FAQ</h3>
            <p>よくある閉じ方:Esc、背景クリック、×ボタン。</p>
          </section>
        </div>
      </div>
    </section>

    <section class="section">
      <h2>状態クラスの例</h2>
      <ul class="list">
        <li><code>.is-open</code>:開いている</li>
        <li><code>.is-active</code>:選択中</li>
        <li><code>.is-hidden</code>:非表示</li>
        <li><code>.is-loading</code>:読み込み中</li>
      </ul>
    </section>
  </main>

  <!-- Modal -->
  <div class="overlay" data-modal aria-hidden="true">
    <div class="modal" role="dialog" aria-modal="true" aria-label="サンプルモーダル">
      <div class="modal__head">
        <h2 class="modal__title">モーダル</h2>
        <button class="icon-btn" aria-label="閉じる" data-close-modal>×</button>
      </div>
      <p class="muted">背景クリック or Esc でも閉じられます。</p>
      <div class="row">
        <button class="btn btn--ghost" data-close-modal>キャンセル</button>
        <button class="btn">OK</button>
      </div>
    </div>
  </div>

  <!-- Drawer -->
  <div class="drawer" data-drawer aria-hidden="true">
    <div class="drawer__panel" role="dialog" aria-modal="true" aria-label="サンプルドロワー">
      <div class="drawer__head">
        <h2 class="drawer__title">ドロワー</h2>
        <button class="icon-btn" aria-label="閉じる" data-close-drawer>×</button>
      </div>
      <p class="muted">右からスライドして出てくるUI。</p>
      <ul class="list">
        <li><a href="#">メニュー1</a></li>
        <li><a href="#">メニュー2</a></li>
        <li><a href="#">メニュー3</a></li>
      </ul>
    </div>
    <div class="drawer__backdrop" data-close-drawer></div>
  </div>

  <script src="app.js"></script>
</body>
</html>

2) styles.css(コピペOK)

*,
*::before,
*::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }

:root{
  --bg: #0b1020;
  --surface: rgba(255,255,255,0.06);
  --border: rgba(255,255,255,0.12);
  --text: #eaf0ff;
  --muted: rgba(234,240,255,0.75);
  --primary: #4f7cff;
  --primary2: #8aa6ff;
  --radius: 16px;
  --shadow: 0 18px 50px rgba(0,0,0,0.35);
}

body{
  background: radial-gradient(1000px 400px at 25% 0%, rgba(79,124,255,0.25), transparent),
              radial-gradient(900px 400px at 80% 10%, rgba(138,166,255,0.18), transparent),
              var(--bg);
  color: var(--text);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans JP", sans-serif;
  line-height: 1.7;
}

.container{ max-width: 980px; margin: 0 auto; padding: 0 16px; }
.muted{ color: var(--muted); }
.section{ padding: 24px 0; }

.header{
  position: sticky;
  top: 0;
  backdrop-filter: blur(10px);
  background: rgba(11,16,32,0.75);
  border-bottom: 1px solid var(--border);
}
.header__inner{
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 16px 0;
}
.header__actions{ display: flex; gap: 10px; }

.btn{
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 10px 14px;
  border-radius: 12px;
  color: #07102a;
  font-weight: 800;
  text-decoration: none;
  background: linear-gradient(135deg, var(--primary), var(--primary2));
  border: 1px solid transparent;
  cursor: pointer;
  transition: transform 160ms ease-out, filter 160ms ease-out;
}
.btn:hover{ transform: translateY(-2px); filter: brightness(1.05); }
.btn--ghost{
  background: transparent;
  border-color: var(--border);
  color: var(--text);
}

.icon-btn{
  width: 34px;
  height: 34px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: rgba(255,255,255,0.06);
  color: var(--text);
  cursor: pointer;
}

.row{ display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.list{ padding-left: 18px; }
code{ background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 8px; }

/* ---------------------------
  Tabs
---------------------------- */
.tabs{
  border: 1px solid var(--border);
  background: var(--surface);
  border-radius: var(--radius);
  padding: 12px;
}
.tablist{ display: flex; gap: 8px; flex-wrap: wrap; }
.tab{
  padding: 8px 10px;
  border-radius: 12px;
  border: 1px solid var(--border);
  background: rgba(255,255,255,0.04);
  color: var(--text);
  cursor: pointer;
}
.tab.is-active{
  background: rgba(79,124,255,0.16);
  border-color: rgba(79,124,255,0.35);
}
.panels{ padding: 12px 4px 6px; }
.panel{ display: none; }
.panel.is-active{ display: block; }

/* ---------------------------
  Modal (overlay + animation)
---------------------------- */
.overlay{
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 16px;

  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms ease-out;
}
.overlay.is-open{
  opacity: 1;
  pointer-events: auto;
  background: rgba(0,0,0,0.45);
}

.modal{
  width: min(520px, 100%);
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: rgba(10, 16, 35, 0.92);
  box-shadow: var(--shadow);
  padding: 14px;

  transform: translateY(10px);
  opacity: 0;
  transition: transform 180ms ease-out, opacity 180ms ease-out;
}
.overlay.is-open .modal{
  transform: translateY(0);
  opacity: 1;
}

.modal__head{
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.modal__title{ margin: 0; font-size: 18px; }

/* ---------------------------
  Drawer
---------------------------- */
.drawer{
  position: fixed;
  inset: 0;
  pointer-events: none;
}
.drawer.is-open{ pointer-events: auto; }

.drawer__panel{
  position: absolute;
  top: 0; right: 0;
  width: min(360px, 86vw);
  height: 100%;
  background: rgba(10, 16, 35, 0.96);
  border-left: 1px solid var(--border);
  padding: 14px;
  transform: translateX(100%);
  transition: transform 220ms ease-out;
  box-shadow: var(--shadow);
}
.drawer.is-open .drawer__panel{
  transform: translateX(0);
}

.drawer__backdrop{
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.35);
  opacity: 0;
  transition: opacity 220ms ease-out;
}
.drawer.is-open .drawer__backdrop{
  opacity: 1;
}

.drawer__head{
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.drawer__title{ margin: 0; font-size: 18px; }

/* スクロールロック */
body.is-scroll-locked{
  overflow: hidden;
}

3) app.js(コピペOK)

"use strict";

/**
 * ユーティリティ:安全に要素取得
 */
const $ = (selector, root = document) => {
  const el = root.querySelector(selector);
  if (!el) throw new Error(`Element not found: ${selector}`);
  return el;
};

const modalOverlay = $("[data-modal]");
const drawer = $("[data-drawer]");

/** フォーカス復元用 */
let lastActiveElement = null;

/** 状態の切り替え(実体はクラス付け替え) */
function openModal() {
  lastActiveElement = document.activeElement;

  modalOverlay.classList.add("is-open");
  modalOverlay.setAttribute("aria-hidden", "false");

  document.body.classList.add("is-scroll-locked");

  // モーダル内へフォーカス移動(最低限)
  const focusable = modalOverlay.querySelector("button, a, input, [tabindex]:not([tabindex='-1'])");
  focusable?.focus();
}

function closeModal() {
  modalOverlay.classList.remove("is-open");
  modalOverlay.setAttribute("aria-hidden", "true");

  document.body.classList.remove("is-scroll-locked");

  // フォーカスを戻す
  lastActiveElement?.focus?.();
}

function openDrawer() {
  lastActiveElement = document.activeElement;

  drawer.classList.add("is-open");
  drawer.setAttribute("aria-hidden", "false");
  document.body.classList.add("is-scroll-locked");

  const focusable = drawer.querySelector("button, a, input, [tabindex]:not([tabindex='-1'])");
  focusable?.focus();
}

function closeDrawer() {
  drawer.classList.remove("is-open");
  drawer.setAttribute("aria-hidden", "true");
  document.body.classList.remove("is-scroll-locked");
  lastActiveElement?.focus?.();
}

/**
 * イベント委譲:クリックをまとめて処理
 */
document.addEventListener("click", (e) => {
  // open modal
  if (e.target.closest("[data-open-modal]")) {
    openModal();
    return;
  }

  // close modal (×/キャンセル)
  if (e.target.closest("[data-close-modal]")) {
    closeModal();
    return;
  }

  // 背景クリックで閉じる(overlay自身がクリックされたとき)
  if (e.target === modalOverlay && modalOverlay.classList.contains("is-open")) {
    closeModal();
    return;
  }

  // open drawer
  if (e.target.closest("[data-open-drawer]")) {
    openDrawer();
    return;
  }

  // close drawer (×/backdrop)
  if (e.target.closest("[data-close-drawer]")) {
    closeDrawer();
    return;
  }

  // Tabs(data-tabsの中で、data-tabが押されたら切替)
  const tabBtn = e.target.closest("[data-tabs] [data-tab]");
  if (tabBtn) {
    const tabsRoot = tabBtn.closest("[data-tabs]");
    const tabId = tabBtn.getAttribute("data-tab");

    const allTabs = [...tabsRoot.querySelectorAll("[data-tab]")];
    const allPanels = [...tabsRoot.querySelectorAll("[data-panel]")];

    // タブの active 切替 + ARIA更新
    allTabs.forEach((t) => {
      const active = t.getAttribute("data-tab") === tabId;
      t.classList.toggle("is-active", active);
      t.setAttribute("aria-selected", String(active));
      t.setAttribute("tabindex", active ? "0" : "-1");
    });

    // パネル表示切替
    allPanels.forEach((p) => {
      const active = p.getAttribute("data-panel") === tabId;
      p.classList.toggle("is-active", active);
    });

    tabBtn.focus();
  }
});

/**
 * Escで閉じる(モーダル/ドロワー両対応)
 */
document.addEventListener("keydown", (e) => {
  if (e.key !== "Escape") return;

  if (modalOverlay.classList.contains("is-open")) {
    closeModal();
    return;
  }
  if (drawer.classList.contains("is-open")) {
    closeDrawer();
    return;
  }
});

ハンズオンの理解ポイント(ここが実務で効く)

1) JSがやってるのは「クラス変更」だけ

  • openModal().is-open を付ける
  • closeModal().is-open を外す
  • 見た目(フェード/スライド)は CSS が担当

2) data属性が拡張に強い

  • [data-open-modal] みたいに、ボタンが増えてもJS変更が最小

3) イベント委譲でリスナー地獄を避ける

  • document.addEventListener("click", ...) 一発で全部捌ける

4) スクロールロックはUI品質が上がる

  • モーダル中に背景がスクロールすると体験が悪い
    body.is-scroll-locked { overflow: hidden; }

よくある落とし穴と対策

display: none でアニメが効かない

  • display はアニメしない
    opacity + pointer-events を使う(記事のモーダル方式)

クリック判定がズレる(背景クリックで閉じたい)

  • e.target === overlay を使うと確実(子要素クリックでは閉じない)

toggle多用で状態が壊れる

  • “開く/閉じる”は add/remove を基本にすると安定
  • toggle は「UIのON/OFF」で使うと事故が少ない

投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です