CSSのボタンの状態とは、ボタンがホバー(:hover)、キーボードでのフォーカス(:focus-visible)、押下(:active)、無効(:disabled または aria-disabled)、読み込み中のときに見せるスタイルです。1つのボタンですべての状態を示し、コピーできる素のCSSとTailwind、さらにアクセシブルなアウトライン、ゴースト、閉じる、アイコンのボタンも紹介します。
ボタンには、デザインすべき状態が6つあります。通常、ホバー、フォーカス表示、押下、無効、読み込み中です。ネット上のコード例の多くは、そのうち1つか2つを、別々のボタンと別々のコードで示しているだけです。このガイドでは最初から最後まで1つのボタンを使い、すべての状態を並べて見せ、それぞれにコピーして使える素のCSSとTailwindを用意しています。続いて、よく検索されるバリエーション(アウトライン、ゴースト、閉じる、アイコン)と、見た目の選択にとどまらないdisabledとaria-disabledの違いを解説します。
下の各タイルは、それぞれ1つの状態で描かれているので、ひと目で見比べられます。グリッドの下にある実際に動くボタンでは、状態を固定したり、自分のマウスやキーボードで試したりできます。
| 状態 | CSSセレクター | Tailwindのバリアント | きっかけ |
|---|---|---|---|
| 通常 | .btn | (接頭辞なし) | なし |
| ホバー | .btn:hover | hover: | ボタンの上にポインターがあるとき。タッチ画面には本当の意味でのホバーはありません。 |
| フォーカス表示 | .btn:focus-visible | focus-visible: | ブラウザーが表示すべきと判断したフォーカス。通常はキーボードによるフォーカス(Tab) |
| フォーカス | .btn:focus | focus: | あらゆるフォーカス。ほとんどのブラウザーではマウスのクリックも含む |
| 押下 | .btn:active | active: | マウスのボタン、指、Spaceキーが押し続けられている間 |
| 無効 | .btn:disabled | disabled: | disabled属性 |
| 無効(ARIA) | .btn[aria-disabled="true"] | aria-disabled: | aria-disabled="true"属性 |
| 読み込み中 | .btn[aria-busy="true"] | aria-busy: | 処理が進んでいる間の、自分のスクリプト |
素のCSSでは、セレクターの詳細度が同じ場合に順序が重要です。:hoverを最初に、次に:focus-visible、最後に:activeを置くと、押下がホバーより優先されます。Tailwindはバリアントを自動で並べ替えてくれます。
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 2.5rem; /* 40px, above the 24px WCAG minimum */
padding: 0.625rem 1rem;
border: 1px solid transparent; /* keeps size equal to the outline variant */
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
font: 600 0.875rem/1.25rem system-ui, sans-serif;
cursor: pointer;
transition: background-color 150ms, transform 150ms;
}
/* hover only when the button can actually be used */
.btn:hover:not(:disabled, [aria-disabled="true"]) {
background: #1d4ed8;
}
/* keyboard focus; mouse clicks do not show the ring */
.btn:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.btn:active:not(:disabled, [aria-disabled="true"]) {
background: #1e40af;
transform: translateY(1px);
}
.btn:disabled,
.btn[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.btn[aria-busy="true"] {
cursor: progress;
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn:active { transform: none; }
}
大事なのは3つの細部です。:not(:disabled, [aria-disabled="true"])のガードは、無効なボタンがホバーや押下に反応しないようにします。反応してしまうと、クリックできるように見えてしまいます。透明な枠線によって、塗りつぶしのボタンがアウトラインのボタンと同じ大きさに保たれるので、バリエーションを切り替えてもレイアウトがずれません。そして:focusではなく:focus-visibleを使うことで、マウスの利用者にはクリック後にリングが表示されず、キーボードの利用者には常に表示されます。
使い方は次のとおりです:<button type="button" class="btn">Save changes</button>。typeは必ず指定しましょう。フォーム内の<button>は既定でtype="submit"になり、クリックするとフォームを送信してしまいます。
<button type="button"
class="inline-flex items-center justify-center gap-2 rounded-lg border border-transparent
bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white transition
hover:bg-blue-700 active:translate-y-px active:bg-blue-800
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600
disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-blue-600 disabled:active:translate-y-0
aria-disabled:cursor-not-allowed aria-disabled:opacity-50
motion-reduce:transition-none">
Save changes
</button>
これらのクラスはTailwind 3.4と4で動作します。v4について注意点が2つあります。hover:はホバーに対応した端末でのみ適用されます(@media (hover: hover))。また、v4のベーススタイルはボタンにcursor: defaultを与えるので、手の形のカーソルにしたい場合はcursor-pointerを追加してください。1pxのringなど、名前や既定値が変わったほかの項目はTailwind v4チートシートを参照してください。
WCAG 2.4.7「フォーカスの可視化」(レベルAA)は、キーボードのフォーカスが見えることを求めています。outline: noneでブラウザーのアウトラインを消し、代わりに何も加えなければ不合格です。信頼できる指針は次のとおりです。
:focus-visibleを使いましょう。現在の主要ブラウザーはすべて対応しているので、以前の:focus:not(:focus-visible)という回避策はもう必要ありません。box-shadowよりoutlineを使いましょう。Windowsの強制カラー(ハイコントラスト)モードでは、ボックスの影は消されますが、アウトラインは表示されたままです。outline-offsetを2pxにすると、ボタンとリングの間に隙間ができるので、青いボタンに青いリングを付けても見分けられます。読み込み中のボタンがすべきことは4つあります。処理が進んでいることを示す、2回目の送信を防ぐ、キーボードのフォーカスを元の場所に保つ、そして処理が終わったらスクリーンリーダーの利用者に伝える、の4つです。リクエスト中にdisabledを設定すると、3つ目が崩れます。ボタンからフォーカスが外れ、一部のブラウザーではキーボードの利用者がページの先頭まで戻されてしまいます。代わりにaria-disabledを使い、スクリプト側でガードしましょう。
<button type="submit" class="btn" aria-disabled="true" aria-busy="true">
<svg class="spinner" viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-opacity=".3" stroke-width="3"/>
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
</svg>
Saving
</button>
<!-- one live region on the page, outside the button -->
<p class="visually-hidden" role="status">Saving your changes</p>
.spinner { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation-duration: 2.4s; } /* slow it, keep a sign of progress */
}
.visually-hidden {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (button.getAttribute("aria-disabled") === "true") return; // ignore double clicks
button.setAttribute("aria-disabled", "true");
button.setAttribute("aria-busy", "true");
status.textContent = "Saving your changes";
try {
await save(new FormData(form));
status.textContent = "Saved";
} catch {
status.textContent = "Could not save. Try again.";
} finally {
button.removeAttribute("aria-disabled");
button.removeAttribute("aria-busy");
}
});
ラベルはスピナーだけに置き換えず、動詞(「保存中」「送信中」)のまま残しましょう。ボタンの幅は固定し(min-widthを付けると、文字が変わっても縮みません)、結果はボタンの文字を変えるだけでなく、ステータス領域を通じて伝えます。aria-busyは要素が更新中であることを示すヒントですが、スクリーンリーダーによって扱いが異なります。そのため、実際の読み上げはステータスメッセージに任せています。
どちらも、支援技術にボタンを使用不可として読み上げさせます(多くの場合「淡色表示」や「使用不可」)。違いは動作です。MDNの説明によれば、HTMLのdisabled属性はすべての機能を止めますが、aria-disabled="true"は無効の状態を伝えるだけなので、動作は自分で止める必要があります。
disabled | aria-disabled="true" | |
|---|---|---|
| Tabで移動できるか | できない。タブ順から外れる | できる。フォーカス可能なまま |
| クリックとキーボードによる実行 | ブラウザーが止める | 発生する。スクリプトで無視する必要がある |
| フォームを送信するか | しない。nameとvalueは送られず、フォームの既定のボタンであれば、入力欄でEnterを押しても送信されない | する。preventDefault()を呼ばない限り |
| フォーカス時にツールチップや説明を出せるか | 出せない。フォーカスを受け取れない | 出せる。aria-describedbyを使う |
| スタイル指定の手がかり | :disabled / disabled: | [aria-disabled="true"] / aria-disabled: |
リンク(<a>)で使えるか | 使えない。disabledはリンクでは無効 | 使える。hrefの削除と組み合わせる |
目安として、ボタンが本当に使えず、その理由を誰も知る必要がない場合はdisabledを使います。たとえば、オフになっているセクション内のフォームコントロールです。利用者がボタンを見つけて何が足りないのかを知る必要がある場合(「続けるにはメールアドレスを入力してください」)や、読み込み中にはaria-disabledを使います。どちらも使わないのが最善であることもよくあります。ボタンは有効なままにしておき、入力が足りない状態で押されたら、わかりやすいエラーメッセージを表示するのです。
button.addEventListener("click", (event) => {
if (button.getAttribute("aria-disabled") === "true") {
event.preventDefault(); // also stops a submit button from submitting
return;
}
doTheThing();
});
コントラストについて:WCAG 1.4.3では、非アクティブ(無効)なコンポーネント内の文字は最低コントラストの規定から除外されています。そのため、不透明度50%で薄くしたボタンも認められます。それでもラベルは読めるようにしておく価値があります。利用者は、まだ何ができないのかを確認する必要があるからです。
アウトラインボタンは透明な塗りと見える枠線を持ち、ゴーストボタンはホバーされるまでどちらも持ちません。どちらも、塗りつぶしのメインボタンの隣に置く、重要度の低い操作に使います。はっきりした第二の選択肢(「プレビュー」)にはアウトラインを、ツールバーやメニュー内の控えめな操作(「キャンセル」「編集」)にはゴーストを使いましょう。
これらは実際のボタンです。ホバーしたりTabを押したりして、それぞれの状態を確認してください。上の.btnを土台に重ねる、各バリエーションのCSSは次のとおりです。
/* Outline: transparent fill, visible border */
.btn-outline {
background: transparent;
color: #1d4ed8;
border-color: #2563eb;
}
.btn-outline:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-outline:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }
/* Ghost: no fill, no visible border until hover */
.btn-ghost {
background: transparent;
color: #1d4ed8;
border-color: transparent;
}
.btn-ghost:hover:not(:disabled, [aria-disabled="true"]) { background: #eff6ff; }
.btn-ghost:active:not(:disabled, [aria-disabled="true"]) { background: #dbeafe; }
/* Destructive */
.btn-danger { background: #dc2626; }
.btn-danger:hover:not(:disabled, [aria-disabled="true"]) { background: #b91c1c; }
.btn-danger:focus-visible { outline-color: #dc2626; }
<!-- Outline -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-blue-600 bg-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
Outline
</button>
<!-- Ghost -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent px-4 py-2.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 active:bg-blue-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50">
Ghost
</button>
<!-- Destructive -->
<button type="button" class="inline-flex items-center gap-2 rounded-lg border border-transparent bg-red-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-red-700 active:bg-red-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 disabled:opacity-50">
Delete project
</button>
WCAG 1.4.11「非テキストのコントラスト」は、コントロールを識別するのに必要な部分について、隣接する色との間に3:1のコントラストを求めています。白の上のblue-600の枠線はこれを満たします。ゴーストボタンは操作されていないときに境界がないため、クリックできると見せるにはラベルと配置に頼るしかありません。ツールバー、ダイアログ、カードのフッターなど、利用者が操作を予想する場所に置き、文字色は通常の文字のコントラスト(4.5:1)を満たす濃さに保ちましょう。
Xアイコンには文字がないので、アクセシブルな名前が必要です。ボタンにaria-label="Close"を、SVGにaria-hidden="true"を付けると、スクリーンリーダーは何も読まなかったりファイル名を読んだりせず、「閉じる、ボタン」と読み上げます。ページに閉じるボタンがいくつもある場合は、「Cookieのお知らせを閉じる」「ダイアログを閉じる」のように具体的な名前にしましょう。
変更を保存しました。
<button type="button" class="btn-close" aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
.btn-close {
display: inline-grid;
place-items: center;
width: 2.5rem;
height: 2.5rem; /* 40px target; WCAG 2.2 AA minimum is 24 by 24 */
padding: 0;
border: 0;
border-radius: 9999px;
background: transparent;
color: #334155;
cursor: pointer;
}
.btn-close:hover { background: rgb(15 23 42 / 0.08); }
.btn-close:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
<button type="button" aria-label="Close"
class="inline-grid size-10 place-items-center rounded-full text-slate-700 hover:bg-slate-900/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg viewBox="0 0 24 24" class="size-5" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
アイコンだけでなく、押せる範囲の大きさを決めましょう。WCAG 2.2のレベルAAである2.5.8「ターゲットのサイズ(最低限)」は、ターゲットを24×24 CSSピクセル以上にするよう求めています(小さなターゲットの周りに十分な間隔がある場合などの例外があります)。上の40pxのボタンは、描かれたXを20pxに保ちつつ、余裕をもってこれを満たしています。ラベルなしで「x」の文字や「×」記号だけを中身にするのは避けましょう。スクリーンリーダーはそれを「エックス」や「かける」と読み上げます。
<!-- Icon + text: the text is the name, the icon is decoration -->
<button type="button" class="btn">
<svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 24 24">...</svg>
Download PDF
</button>
<!-- Icon only, option 1: aria-label -->
<button type="button" class="btn btn-icon" aria-label="Download PDF">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
</button>
<!-- Icon only, option 2: visually hidden text (gets translated by browser translation tools) -->
<button type="button" class="btn btn-icon">
<svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24">...</svg>
<span class="visually-hidden">Download PDF</span>
</button>
.btn svg { flex: none; } /* keep icons from squashing */
.btn-icon {
width: 2.5rem;
padding: 0; /* square: same width and height */
}
<button type="button" class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-4 shrink-0" aria-hidden="true" ...></svg>
Download PDF
</button>
<button type="button" aria-label="Download PDF" class="inline-grid size-10 place-items-center rounded-lg bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
<svg class="size-5" aria-hidden="true" ...></svg>
</button>
aria-hidden="true")、テキストを名前にします。アイコンに余白を付けるのではなくgapを使えば、アイコンを削除したり右側へ移したりしても間隔が崩れません。aria-labelか、視覚的に隠したテキストで名前を付けます。titleに頼ってはいけません。ブラウザーはそれを予備の名前として使いますが、そのツールチップはマウスのホバー時にしか表示されず、キーボードのフォーカスやタッチでは出ません。focusable="false"を追加しましょう。最近のブラウザーでは不要ですが、付けても害はありません。<div>ではなく、<button>(操作用)か<a href>(移動用)になっている。type="button"になっている。prefers-reduced-motionを尊重している。ボタンを見ながらデザインしてCSSをコピーするなら、CSSボタンジェネレーターを使いましょう。既製のセットなら、ボタンコンポーネントやアニメーション付きボタンをご覧ください。このガイドと相性の良いものが2つあります。メイン、サブ、アウトライン、ゴースト、アイコン、読み込み中、破壊的操作のスタイルをそろえたHTMLとCSSのボタンセットと、コピーして使えるHTMLとCSS付きのCSSボタンスタイル集です。ページのどこにボタンを置き、何と書くべきかについては、行動喚起(CTA)の例を読んでください。
疑似クラス:disabledを対象にします。例:.btn:disabled { opacity: 0.5; cursor: not-allowed; }。aria-disabledも使う場合は、同じルールに[aria-disabled="true"]を追加します。ホバーと押下のスタイルは:not(:disabled)でガードし、無効なボタンがホバーや押下で色を変えないようにしましょう。
disabledはボタンをタブ順から外し、クリックを止め、フォームの送信も防ぎます。aria-disabled="true"は、ボタンが使用不可であることを支援技術に伝えるだけです。フォーカスもクリックもできるままなので、実行はスクリプトで無視する必要があります。ボタンにフォーカスして使えない理由を知る必要がある場合や、リクエストの読み込み中にはaria-disabledを使いましょう。
見えるリングには:focus-visibleを使います。ブラウザーはキーボードのフォーカスでは表示し、マウスのクリック後には通常表示しないので、クリックのたびにリングが出ることなく、キーボードの利用者は常に現在位置がわかります。WCAG 2.4.7は見えるフォーカスインジケーターを求めているので、見える代わりのものを加えずにアウトラインを消してはいけません。
本物のbutton要素に、type="button"とaria-label="Close"(または"Close dialog"のようなより具体的な名前)を付け、Xアイコンにはaria-hidden="true"を付けます。クリックできる範囲は、WCAG 2.2 AAの最低基準である24×24 CSSピクセル以上にします。40〜44ピクセルあれば、さらに押しやすくなります。
アウトラインボタンは透明な背景と見える枠線を持つので、操作されていないときでもボタンとして認識できます。ゴーストボタンはホバーされるまで背景も見える枠線もないので、テキストと配置に頼ります。はっきりしたサブの操作にはアウトラインを、ツールバーやメニュー内の控えめな操作にはゴーストを使いましょう。
aria-hidden="true"を付けたスピナーを追加し、ラベルを「保存中」のような動詞に変え、aria-disabled="true"を設定して、フォーカスをボタンに残したまま2回目のクリックを無視します。結果はrole="status"の領域を通じて伝えます。リクエストが完了または失敗したら、これらの属性を取り除きます。
変更を保存しています
保存しました
状態を固定
ライブ(試してみる)
保存中
変更を保存
ホバーする、Tabで移動する、押す、またはクリックして読み込み中を表示。
{state}の状態を表示しています。
クリックイベントは発生しましたが、スクリプトが無視しました。続けるにはメールアドレスを入力してください。
両方をクリックしてから、Tabで移動してみてください。到達できるのは片方だけです。
続ける
受け取ったクリックイベント:{n}
無料で始める · クレジットカード不要