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切り替え
ここまでやると、キーボード操作でも破綻しづらいです。
ハンズオン:クラス付け替えで「モーダル + ドロワー + タブ」を作る
作るもの:
- モーダル(背景クリック・Escで閉じる、スクロールロック)
- 右から出るドロワー
- タブ切り替え(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」で使うと事故が少ない
コメントを残す