コピペで完結!タブ切替 #12【タブ内を集計してバッジで表示できるタブ】
html/css/js
2025.10.05
タブ内を集計してバッジで表示できるタブ
各タブに対応するパネルの中身を集計し、その件数をタブ横のバッジに表示する仕組みです。
未読数やタスク件数など、動的に変化する情報をタブで直感的に確認できるUIとして活用できます。
コードはARIA対応済みでアクセシブル、コピペですぐに利用可能です。
- パネル内の要素数を自動で集計してタブに反映
- 未読数・通知数・タスク件数などに応用可能
- ARIA属性でアクセシブル設計
- コピペで導入できるシンプルな実装
コードについて
本記事のコードは AI(ChatGPT)による生成をベースに作成・調整しています。ご利用の環境でテストの上ご使用ください。
免責
本コードの利用に伴う不具合・損害について、当サイトは責任を負いません。自己責任にてご利用ください。
デモ
新機能をリリースしました。
メンテナンスのお知らせ。
アップデートのご案内。
※ボタン操作で「未読→既読」にし、バッジ件数を動的更新するのは[コピペ用JS]で解説。
佐藤さん:「資料確認しました!」
山田さん:「会議の件、了解です」
営業チーム:「見積を共有します」
経理:「請求書の件について」
デザイン:「バナー案アップしました」
コードをコピーして使おう!
/* =========================================
バッジ/件数表示つきタブ:CSS(フルコメント版)
-----------------------------------------
目的:
- タブのラベル右側に「件数バッジ」を表示して視認性を高める
- クリックやキーボード操作でのタブ切替に併せ、見た目を明確化
- アクセシビリティ配慮(表示/非表示は JS で hidden を制御)
方針:
- 範囲衝突を避けるため #badge-section 配下のみをスタイリング
- 色やサイズはカスタムプロパティ(CSS変数)で柔軟に調整可能
備考:
- バッジ値は静的/動的いずれでもOK(JSで更新する想定)
========================================= */
/* -----------------------------------------
#badge-section(コンポーネントのルート)
- 色・タイポ・レイアウト幅の基本設定
- カスタムプロパティで配色を集中管理
------------------------------------------ */
#badge-section{
--ink:#0f172a; /* 文字色(本文の基本色) */
--muted:#64748b; /* 補助文字(説明文・注釈など) */
--accent:#0b6bff;/* アクティブ強調色(選択タブなど) */
--border:#e5e7eb;/* 枠線色(薄いグレー) */
--panel:#ffffff; /* パネル背景色(白) */
--badge:#ef4444; /* バッジ背景色(赤系) */
--badge-ink:#fff;/* バッジ文字色(白) */
color:var(--ink); /* セクション内の既定文字色を設定 */
font-family: system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans JP","Yu Gothic UI",sans-serif; /* 可読性の高いシステムフォント */
max-width: 820px; /* セクション自体の最大幅(レイアウトを保つ) */
margin: 0 auto; /* 中央寄せ(左右の余白を同等に) */
}
/* -----------------------------------------
.tabs(タブバーのコンテナ)
- 横並び・折り返し対応
- 下地や枠線でタブ群を視覚的に囲う
------------------------------------------ */
#badge-section .tabs{
display:flex; /* タブを横並びに配置 */
gap:8px; /* タブ同士の間隔を確保 */
padding:8px; /* タブバー内側の余白 */
background:#f8fafc; /* 薄い背景で領域を区切る */
border:1px solid var(--border); /* 外枠の線でまとまりを出す */
border-radius:12px; /* 角丸で柔らかい印象に */
flex-wrap:wrap; /* 画面幅が狭い場合は折返して崩れを防ぐ */
}
/* -----------------------------------------
.tab(個々のタブ=ボタン)
- ピル型(pill)でバッジと横並び
- ホバー/アクティブ時の違いは最小限
------------------------------------------ */
#badge-section .tab{
appearance:none; /* ブラウザ既定のボタン装飾を解除 */
background:#fff; /* タブの背景は白 */
color:var(--ink); /* 文字色は基本色 */
border:1px solid var(--border); /* 薄い枠線でボタン感を出す */
border-radius:999px; /* ピル形状(太い角丸) */
padding:8px 12px; /* クリックしやすい余白 */
font-size:14px; /* 読みやすい文字サイズ */
line-height:1; /* バッジとの高さ揃えを取りやすく */
cursor:pointer; /* インタラクティブであることを示す */
display:inline-flex; /* テキストとバッジを横並びに */
gap:8px; /* テキストとバッジの間隔 */
align-items:center; /* 縦位置を中央に揃える */
transition: background .2s, border-color .2s, color .2s, transform .05s; /* 状態変化を滑らかに */
}
/* -----------------------------------------
.tab:active(クリック中のわずかな押下感)
------------------------------------------ */
#badge-section .tab:active{
transform: translateY(1px); /* 1px 下げて物理的な押下感を演出 */
}
/* -----------------------------------------
.tab[aria-selected="true"](選択タブの強調)
- アクセシビリティ属性に連動した見た目
------------------------------------------ */
#badge-section .tab[aria-selected="true"]{
background:var(--accent); /* 背景を強調色に */
border-color:var(--accent); /* 枠線も強調色で統一 */
color:#fff; /* 文字色は白でコントラスト確保 */
}
/* -----------------------------------------
.badge(件数ラベル)
- 最小幅を設けて1桁でも丸く見えるよう調整
- 2桁以上でも収まるよう横方向の余白を用意
------------------------------------------ */
#badge-section .badge{
min-width:20px; /* 1桁でも丸形を維持できる幅 */
height:20px; /* 高さ(正円に近づける) */
padding:0 6px; /* 2桁以上の幅にも対応 */
border-radius:999px; /* 完全な丸み */
background:var(--badge); /* バッジの背景色(赤) */
color:var(--badge-ink); /* バッジ内の文字色(白) */
display:inline-flex; /* 中央寄せのためにフレックス化 */
align-items:center; /* 垂直方向の中央寄せ */
justify-content:center; /* 水平方向の中央寄せ */
font-weight:700; /* 数字を視認しやすく太字に */
font-size:12px; /* 小さめでも読めるサイズ */
}
/* -----------------------------------------
.panel(タブと対応する内容領域)
- カード風に枠線と角丸、十分な内側余白
------------------------------------------ */
#badge-section .panel{
border:1px solid var(--border); /* 薄い枠線で領域を区切る */
border-radius:12px; /* 優しい角丸 */
background:var(--panel); /* 背景は白 */
padding:16px; /* 読みやすい内側余白 */
line-height:1.9; /* 行間を広めにして可読性UP */
}
/* -----------------------------------------
[role="tabpanel"][hidden]
- JS 側で hidden を付与/除去して表示切替
- display:none; で読み上げ対象にもならない(多くのスクリーンリーダでOK)
------------------------------------------ */
#badge-section [role="tabpanel"][hidden]{
display:none; /* 非表示(レイアウトからも除外) */
}
/* -----------------------------------------
デモ用スタイル(.item / .dot / .muted)
- 実運用では任意のDOM構造でOK(ここでは未読の丸点などを表現)
------------------------------------------ */
#badge-section .item{
display:flex; /* アイコンとテキストを横並び */
align-items:center; /* 縦位置の中央そろえ */
gap:10px; /* アイコンとテキスト間の余白 */
}
#badge-section .dot{
width:8px; height:8px; /* 小さな丸点のサイズ */
border-radius:999px; /* 完全な円形 */
background:#22c55e; /* 緑の丸点(未読・アクティブの例) */
}
#badge-section .muted{
color:var(--muted); /* 補助的な説明文の色 */
font-size:13px; /* 本文より少し小さいサイズ */
}
<!-- =========================================
バッジ/件数表示つきタブ:HTML
-----------------------------------------
・role="tablist" / "tab" / "tabpanel" のARIAでアクセシブルに
・各タブは aria-controls で対応するパネルIDを指す
・バッジは <span class="badge"> に数値を入れる(静的/動的どちらでも)
========================================= -->
<section id="badge-section" aria-label="バッジ付きタブ">
<!-- タブバー(バッジ付) -->
<div class="tabs" role="tablist" aria-label="通知タブ">
<button class="tab" role="tab" id="tab-news" aria-controls="panel-news" aria-selected="true">
お知らせ <span class="badge" data-badge="news">3</span>
</button>
<button class="tab" role="tab" id="tab-msg" aria-controls="panel-msg" aria-selected="false">
メッセージ <span class="badge" data-badge="msg">5</span>
</button>
<button class="tab" role="tab" id="tab-tasks" aria-controls="panel-tasks" aria-selected="false">
タスク <span class="badge" data-badge="tasks">2</span>
</button>
</div>
<!-- パネル群(初期は #panel-news を表示、他は hidden) -->
<div id="panel-news" class="panel" role="tabpanel" aria-labelledby="tab-news">
<div class="item"><span class="dot" aria-hidden="true"></span> 新機能をリリースしました。</div>
<div class="item"><span class="dot" aria-hidden="true"></span> メンテナンスのお知らせ。</div>
<div class="item"><span class="dot" aria-hidden="true"></span> アップデートのご案内。</div>
<p class="muted">※バッジ数の動的反映はJSで可能。</p>
</div>
<div id="panel-msg" class="panel" role="tabpanel" aria-labelledby="tab-msg" hidden>
<div class="item"><span class="dot" aria-hidden="true"></span> 佐藤さん:「資料確認しました!」</div>
<div class="item"><span class="dot" aria-hidden="true"></span> 山田さん:「会議の件、了解です」</div>
<div class="item"><span class="dot" aria-hidden="true"></span> 営業:「見積を共有します」</div>
<div class="item"><span class="dot" aria-hidden="true"></span> 経理:「請求書の件について」</div>
<div class="item"><span class="dot" aria-hidden="true"></span> デザイン:「バナー案アップしました」</div>
</div>
<div id="panel-tasks" class="panel" role="tabpanel" aria-labelledby="tab-tasks" hidden>
<div class="item"><span class="dot" aria-hidden="true"></span> 請求書の送付</div>
<div class="item"><span class="dot" aria-hidden="true"></span> 来週の打合せ調整</div>
</div>
</section>
(function(){
/* =========================================
バッジ/件数表示つきタブ:JS(フルコメント)
-----------------------------------------
役割:
- タブクリック/キーボード操作でパネルを切替
- aria-selected と hidden を正しく更新(アクセシブル)
- ★バッジ値を「パネル内の要素数」から動的に再計算して反映
設計メモ(バッジの動的更新):
- 各バッジは .badge 要素に data-badge="キー名" を持つ
- パネル側は #panel-キー名 というID or 下記の明示マップに対応
- 件数は各パネル内の「未読ドット .dot」数を数える例(用途に合わせて変更可)
- 表示値は 0 の扱い/上限(例:99+)などをこの関数で統一管理
- 何かを追加・削除したタイミングで refreshBadges() を再呼び出しすれば即時反映
(自動検知したい場合は MutationObserver のサンプルを末尾に用意)
========================================= */
/* ルート要素(この記事のデモ領域)を取得 */
const root = document.getElementById('badge-section');
if(!root) return; /* ガード:対象が無ければ何もしない */
/* タブ群とパネル群(ARIAロールで限定) */
const tabs = root.querySelectorAll('.tabs .tab[role="tab"]');
const panels = root.querySelectorAll('.panel[role="tabpanel"]');
/* ------------------------------------------------
バッジ件数の動的再計算ロジック
------------------------------------------------
▼キーとパネルの対応方法は2パターンのどちらかにする:
A) 命名規則方式: panel の id を "panel-" に統一
- 例:data-badge="news" → 対応パネルIDは #panel-news
B) 明示マップ方式:ここで { key: CSSセレクタ } を定義
- 例:{ news: "#panel-news", msg:"#panel-msg", tasks:"#panel-tasks" }
このデモは「明示マップ方式」で可読性を優先。
------------------------------------------------- */
const PANEL_MAP = {
news: '#panel-news',
msg: '#panel-msg',
tasks: '#panel-tasks'
};
/* ▼「何を数えるか」はこのセレクタで決める(用途に合わせて変更)
- 例)".dot" を「未読アイコン」とみなし、その数をバッジ表示
- 例)".item" の総数を表示したいなら COUNT_TARGET を ".item" に変更 */
const COUNT_TARGET = '.dot';
/* ▼バッジの表示ルール(要件に応じて調整)
- ZERO_MODE: 0件のときの表示方針("show"|"hide"|"dash")
show … "0" と表示
hide … バッジを非表示(aria-hidden を付与)
dash … "–" などに置換(記号は ZERO_MARK で指定)
- CAP: 上限(99 なら 100以上は "99+" に丸める)
*/
const ZERO_MODE = 'show'; /* "show" | "hide" | "dash" */
const ZERO_MARK = '–'; /* ZERO_MODE="dash" の際の記号 */
const CAP = 99; /* 上限(null で無制限) */
/* 件数を整形して文字列を返す(上限・ゼロ時の扱いを一本化) */
function formatCount(n){
if(n === 0){
if(ZERO_MODE === 'hide') return '0'; /* 値は返すが後で非表示にする */
if(ZERO_MODE === 'dash') return ZERO_MARK;
return '0'; /* "show" は "0" を表示 */
}
if(typeof CAP === 'number' && n > CAP) return CAP + '+';
return String(n);
}
/* バッジ数をパネルの内容から計算して反映(必要なときに呼ぶ) */
function refreshBadges(){
/* data-badge="news" 等を持つ全バッジを走査 */
const badges = root.querySelectorAll('.badge[data-badge]');
badges.forEach(badge => {
const key = badge.getAttribute('data-badge'); /* 例:"news" */
const panelSel = PANEL_MAP[key]; /* 例:"#panel-news" */
if(!panelSel) return; /* マップ外はスキップ */
const panel = root.querySelector(panelSel);
if(!panel) return; /* パネル未存在はスキップ */
/* ▼ここが「動的件数」の算出ポイント(必要に応じて置換OK)
- 今回は panel 内の COUNT_TARGET(.dot) 要素数を数える */
const rawCount = panel.querySelectorAll(COUNT_TARGET).length;
/* ▼表示文字列に整形(上限・ゼロ時の扱いを適用) */
const display = formatCount(rawCount);
/* ▼描画:textContent を上書き */
badge.textContent = display;
/* ▼ゼロ時に非表示にする運用が必要なら aria-hidden を使う
- ZERO_MODE="hide" のときのみ非表示、他は表示 */
if(ZERO_MODE === 'hide' && rawCount === 0){
badge.setAttribute('aria-hidden','true'); /* スクリーンリーダーにも非表示意図を伝える */
badge.style.display = 'none'; /* 視覚的にも消す */
}else{
badge.removeAttribute('aria-hidden'); /* 表示に戻す */
badge.style.display = ''; /* display 指定を解除 */
}
/* ▼必要なら data-count 属性に生値を保持しておくと便利
- スタイル条件([data-count="0"] など)での分岐に使える */
badge.dataset.count = String(rawCount);
});
}
/* タブをアクティブ化して対応パネルを表示(ARIA連動) */
function activate(tab){
/* すべてのタブの選択状態を更新(クリックされたタブのみ true) */
tabs.forEach(t => t.setAttribute('aria-selected', String(t === tab)));
/* 対象パネルIDを取得し、表示/非表示を切替 */
const id = tab.getAttribute('aria-controls');
panels.forEach(p => p.hidden = (p.id !== id));
/* ▼(任意)切替のたびに件数見直ししたい場合は有効化
- パネル内で「閲覧済みにする→.dot削除」のようなUIがある場合に便利 */
// refreshBadges();
}
/* クリックで切替 */
tabs.forEach(tab => tab.addEventListener('click', () => activate(tab)));
/* キーボード操作(左右, Home, End) */
tabs.forEach((tab, i) => {
tab.addEventListener('keydown', (e) => {
let ni = i;
if(e.key === 'ArrowRight') ni = (i + 1) % tabs.length;
if(e.key === 'ArrowLeft') ni = (i - 1 + tabs.length) % tabs.length;
if(e.key === 'Home') ni = 0;
if(e.key === 'End') ni = tabs.length - 1;
if(ni !== i){
e.preventDefault();
tabs[ni].focus();
activate(tabs[ni]);
}
});
});
/* 初期状態:aria-selected="true" のタブ(無ければ先頭)を採用 */
const first = root.querySelector('.tabs .tab[aria-selected="true"]') || tabs[0];
if(first) activate(first);
/* 初回のバッジ反映(静的HTMLの数値を「実数で上書き」したい時に呼ぶ) */
refreshBadges();
/* ------------------------------------------------
(任意)自動更新:MutationObserver の例
------------------------------------------------
- パネル内の .dot(や COUNT_TARGET)の増減を自動監視して
バッジを即時更新したい場合に有効
- 頻繁にDOMが更新されるページではコストに注意
------------------------------------------------- */
// const mo = new MutationObserver(() => refreshBadges());
// panels.forEach(p => mo.observe(p, { childList:true, subtree:true, attributes:true }));
// ※不要なら上の3行はコメントのままにしてください
})();
コピペで完結!タブ切替 #12【タブ内を集計してバッジで表示できるタブ】
コメント