CSS のボタンは、「色とサイズを変数で持つ土台のクラス」を1つ作り、hover・focus-visible・active・disabled の4つの状態をそこで決めておくと、デザインを何種類に増やしても操作感がそろいます。 見た目のバリエーションは、土台に小さなクラスを1つ足すだけで作れます。

この記事では、同じ土台の上に 25 種類のボタンデザインを用意し、すべてのボタンで4つの状態が動くことを Playwright(Chromium 141)で計算値を取って確認しました。 ホバーアニメーションは、スマートフォンでタップしたあとに色が残らないよう @media (hover: hover) で囲み、動きを減らす設定(prefers-reduced-motion)にも対応しています。後半では、a タグと button の使い分け、タップ領域 44px、文字と背景のコントラスト比も、測った数値で整理します。

CSSボタンデザインの早見表

すべて <button class="btn btn--〇〇"> の形で使います。

No. デザイン クラス ホバーしたときの動き
1 塗り(基本) btn 色が少し濃くなる
2 枠線 btn btn--outline 塗りに変わる
3 文字だけ(ゴースト) btn btn--ghost 淡い背景が出る
4 淡色 btn btn--soft 背景が少し濃くなる
5 角なし btn btn--square 基本と同じ
6 ピル(丸い両端) btn btn--pill 基本と同じ
7 グラデーション btn btn--grad 色が横に流れる
8 グラデーションの枠線 btn btn--grad-border グラデーションの塗りに変わる
9 立体 btn btn--3d 少し持ち上がり、押すと沈む
10 影つき btn btn--shadow 浮き上がって影が広がる
11 ぼかさない影(ポップ) btn btn--pop 影のほうへ動く
12 光る(グロー) btn btn--glow まわりが光る
13 輪が広がる btn btn--ring 半透明の輪が出る
14 少し大きくなる btn btn--scale 1.05 倍になる
15 矢印が動く btn btn--arrow 矢印が右へ 4px 動く
16 矢印が出てくる btn btn--arrow-in 隠れていた矢印が現れる
17 背景が左から流れる btn btn--slide 左から色が入る
18 背景が下から上がる btn btn--rise 下から色が入る
19 背景が中央から広がる btn btn--center 中央から色が広がる
20 光が走る btn btn--shine 斜めの光が横切る
21 下線が伸びる btn btn--underline 左から下線が伸びる
22 アイコン付き btn + svg 基本と同じ
23 アイコンだけ btn btn--icon-only 基本と同じ
24 読み込み中 btn is-loading 操作を受け付けず、回転する輪を表示
25 全幅 btn btn--block 基本と同じ
4つの状態 いつ セレクタ この記事の土台での見た目
hover マウスを載せた @media (hover: hover) の中の .btn:hover 色を 15% 暗く
focus-visible キーボードでフォーカスした .btn:focus-visible 3px の輪(outline)
active 押している間 .btn:active 色を 30% 暗く、1px 下へ
disabled 押せない .btn:disabled, .btn[aria-disabled="true"] 透明度 0.45、操作を受け付けない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSSボタンデザインのコピペ集|ホバーアニメーションと4つの状態」

確認した環境

項目 内容
ブラウザ Chromium 141.0.7390.37(Playwright から起動)
確認方法 マウスを載せる・押す・Tab キーで移動する、の操作を行い、getComputedStyle で計算値を取得
タッチ端末 Playwright の hasTouch: true, isMobile: true(幅 390px)で再現

色の計算に使っている color-mix() は、主要ブラウザで 2023 年から使える関数です(対応状況は MDN で確認)。

土台のCSS(色・サイズを変数で変える)

最初に、すべてのボタンが共通で使う土台です。これを一度貼っておけば、あとのデザインは数行ずつ足すだけで使えます。

/* ===== 土台:色とサイズは変数で変える ===== */
.btn {
  --btn-color: #2563eb;                                            /* 主役の色 */
  --btn-on: #ffffff;                                               /* 主役の色の上に載せる文字色 */
  --btn-hover: color-mix(in srgb, var(--btn-color), #000 15%);     /* ホバー時の色 */
  --btn-active: color-mix(in srgb, var(--btn-color), #000 30%);    /* 押している間の色 */
  --btn-tint: color-mix(in srgb, var(--btn-color), #fff 88%);      /* 淡い背景色 */
  --btn-radius: 8px;
  --btn-font: 1rem;
  --btn-py: 0.625em;
  --btn-px: 1.25em;
  --btn-speed: 0.2s;

  position: relative;
  box-sizing: border-box;              /* a と button で高さをそろえる */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  min-height: 44px;                    /* タップ領域 */
  min-width: 44px;
  padding: var(--btn-py) var(--btn-px);
  border: 2px solid var(--btn-color);
  border-radius: var(--btn-radius);
  background-color: var(--btn-color);
  color: var(--btn-on);
  font: inherit;
  font-size: var(--btn-font);
  font-weight: 600;
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  user-select: none;
  touch-action: manipulation;          /* ダブルタップ拡大の待ち時間をなくす */
  -webkit-tap-highlight-color: transparent;
  transition: background-color var(--btn-speed), border-color var(--btn-speed),
              color var(--btn-speed), box-shadow var(--btn-speed), transform var(--btn-speed);
}

/* 状態1:hover(ホバーできる端末だけ) */
@media (hover: hover) {
  .btn:hover {
    background-color: var(--btn-hover);
    border-color: var(--btn-hover);
  }
}
/* 状態2:focus-visible(キーボード操作のときだけ輪を出す) */
.btn:focus-visible {
  outline: 3px solid var(--btn-color);
  outline-offset: 3px;
}
/* 状態3:active(押している間) */
.btn:active {
  background-color: var(--btn-active);
  border-color: var(--btn-active);
  transform: translateY(1px);
}
/* 状態4:disabled */
.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.45;
  cursor: not-allowed;
  pointer-events: none;                /* hover と active を止める */
}
<button type="button" class="btn">送信する</button>
<a href="/tasks" class="btn">一覧へ</a>
<button type="button" class="btn" disabled>送信する</button>

変数の一覧

変数 役割 初期値
--btn-color 主役の色。これ1つ変えれば、ホバー・押下・淡色・輪の色がすべて連動する #2563eb
--btn-on 主役の色の上に載せる文字色 #ffffff
--btn-hover ホバー時の色(主役の色に黒を 15% 混ぜる) 自動計算
--btn-active 押している間の色(黒を 30% 混ぜる) 自動計算
--btn-tint 淡い背景色(白を 88% 混ぜる) 自動計算
--btn-radius 角の丸み 8px
--btn-font / --btn-py / --btn-px 文字サイズ/上下の余白/左右の余白 1rem / 0.625em / 1.25em
--btn-speed 変化にかける時間 0.2s

余白を em で書いているので、--btn-font を変えると余白も同じ比率で変わります。

サイズ・色・形を変える

/* ===== サイズと色 ===== */
.btn--sm { --btn-font: 0.875rem; --btn-py: 0.5em; --btn-px: 1em; }
.btn--lg { --btn-font: 1.125rem; --btn-py: 0.8em; --btn-px: 1.75em; }
.btn--block { display: flex; width: 100%; }
.btn--green { --btn-color: #15803d; }
.btn--red   { --btn-color: #b91c1c; }
.btn--dark  { --btn-color: #1f2937; }

/* ===== 形 ===== */
.btn--square { --btn-radius: 0; }
.btn--pill   { --btn-radius: 999px; }
<button class="btn btn--sm">小</button>
<button class="btn btn--lg btn--green btn--pill">大きい緑のピル</button>
<button class="btn" style="--btn-color: #7c3aed">その場で色を変える</button>

実際の大きさ(計算値)です。

クラス 文字サイズ 余白(上下 左右) 高さ
btn btn--sm 14px 7px 14px 44px(min-height が効く)
btn 16px 10px 20px 44px
btn btn--lg 18px 14.4px 31.5px 54.4px
btn btn--icon-only 16px 0 44×44px
btn btn--block(親の幅 300px) 16px 10px 20px 300×44px

4つの状態の計算値

基本の btn を操作して、状態ごとに変わった値だけを抜き出しました。

normal        : bg=rgb(37, 99, 235); color=rgb(255, 255, 255); transform=none; outline=none; opacity=1
hover         : bg=color(srgb 0.123333 0.33 0.783333)
active        : bg=color(srgb 0.101569 0.271765 0.645098); transform=matrix(1, 0, 0, 1, 0, 1)
focus-visible : outline=solid 3px rgb(37, 99, 235) offset=3px
disabled      : opacity=0.45 bg=rgb(37, 99, 235)(マウスを載せても変化なし)

color(srgb 0.123333 0.33 0.783333) は color-mix() の計算結果で、RGB に直すとおよそ rgb(31, 84, 200) です。active はおよそ rgb(26, 69, 165) になります。

状態 土台での工夫 理由
hover @media (hover: hover) で囲む タッチ端末でタップ後に色が残らないようにする
focus-visible :focus ではなく :focus-visible を使う マウスでクリックしたときは輪を出さず、キーボード操作のときだけ出す
active 色と 1px の移動の両方を変える 押した手応えを、色でも動きでも伝える
disabled pointer-events: none を付ける hover と active のスタイルが当たらなくなり、デザインごとに打ち消しを書かなくて済む

マウスでクリックしたあとの計算値は :focus-visible=false / outline=none でした。outline: none でフォーカス表示を消すのではなく、:focus-visible に任せると、マウス操作の見た目とキーボード操作のわかりやすさを両立できます。

ボタンデザイン集(コピペ用)

ここからは、土台に足すクラスです。必要なものだけ貼って使えます。

枠線・ゴースト・淡色(No.2〜4)

/* ===== 2. 枠線 ===== */
.btn--outline { background-color: transparent; color: var(--btn-color); }
@media (hover: hover) {
  .btn--outline:hover { background-color: var(--btn-color); border-color: var(--btn-color); color: var(--btn-on); }
}
.btn--outline:active { background-color: var(--btn-active); color: var(--btn-on); }

/* ===== 3. 文字だけ(ゴースト) ===== */
.btn--ghost { background-color: transparent; border-color: transparent; color: var(--btn-color); }
@media (hover: hover) {
  .btn--ghost:hover { background-color: var(--btn-tint); border-color: var(--btn-tint); }
}
.btn--ghost:active { background-color: color-mix(in srgb, var(--btn-color), #fff 75%); border-color: transparent; }

/* ===== 4. 淡色 ===== */
.btn--soft { background-color: var(--btn-tint); border-color: var(--btn-tint); color: var(--btn-active); }
@media (hover: hover) {
  .btn--soft:hover { background-color: color-mix(in srgb, var(--btn-color), #fff 78%); border-color: color-mix(in srgb, var(--btn-color), #fff 78%); }
}
.btn--soft:active { background-color: color-mix(in srgb, var(--btn-color), #fff 68%); border-color: transparent; }
<button class="btn btn--outline">戻る</button>
<button class="btn btn--ghost">キャンセル</button>
<button class="btn btn--soft">下書き保存</button>
デザイン 通常 ホバー 向いている場所
塗り 背景が主役の色 15% 暗く いちばん押してほしいボタン(画面に1つ)
枠線 背景は透明、文字と枠が主役の色 塗りに変わる 2番目に大事な操作
ゴースト 文字だけ 淡い背景 キャンセル、補助的な操作
淡色 淡い背景に濃い文字 背景が少し濃く 塗りほど目立たせたくない操作

1つの画面で「塗り」は1つにして、ほかを枠線やゴーストにすると、どれを押せばよいかが伝わりやすくなります。

グラデーション(No.7〜8)

/* ===== 5. グラデーション(色が流れる) ===== */
.btn--grad {
  border: 0;
  background-image: linear-gradient(90deg, #2563eb, #7c3aed, #2563eb);
  background-size: 200% 100%;
  background-position: 0% 0;
  transition: background-position 0.4s, transform var(--btn-speed);
}
@media (hover: hover) { .btn--grad:hover { background-position: 100% 0; } }
.btn--grad:active { background-position: 100% 0; }

/* ===== 6. グラデーションの枠線 ===== */
.btn--grad-border {
  border-color: transparent;
  color: #1f2937;
  background: linear-gradient(#fff, #fff) padding-box,
              linear-gradient(90deg, #2563eb, #db2777) border-box;
}
@media (hover: hover) {
  .btn--grad-border:hover { color: #fff; border-color: transparent;
    background: linear-gradient(90deg, #2563eb, #db2777) padding-box, linear-gradient(90deg, #2563eb, #db2777) border-box; }
}
.btn--grad-border:active { border-color: transparent; color: #fff;
  background: linear-gradient(90deg, #1e40af, #9d174d) padding-box, linear-gradient(90deg, #1e40af, #9d174d) border-box; }
<button class="btn btn--grad">はじめる</button>
<button class="btn btn--grad-border">詳しく見る</button>

グラデーションそのものは transition で変化させられないため、背景を横幅の2倍にしておき、background-position を動かして色が流れるように見せています(計算値は通常 0% 0px、ホバー 100% 0px)。枠線のグラデーションは、padding-box までの白い背景と border-box までのグラデーション背景を重ね、透明な枠線の下にグラデーションを見せる書き方です。グラデーションの色の作り方は、CSS グラデーションの記事で詳しく扱っています。

立体・影・ポップ(No.9〜11)

/* ===== 7. 立体(押すと沈む) ===== */
.btn--3d { box-shadow: 0 4px 0 var(--btn-active); }
@media (hover: hover) { .btn--3d:hover { transform: translateY(-1px); box-shadow: 0 5px 0 var(--btn-active); } }
.btn--3d:active { background-color: var(--btn-hover); border-color: var(--btn-hover); transform: translateY(4px); box-shadow: 0 0 0 var(--btn-active); }

/* ===== 8. 影つき(浮き上がる) ===== */
.btn--shadow { box-shadow: 0 2px 6px rgb(0 0 0 / 0.2); }
@media (hover: hover) { .btn--shadow:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgb(0 0 0 / 0.25); } }
.btn--shadow:active { transform: translateY(0); box-shadow: 0 1px 2px rgb(0 0 0 / 0.2); }

/* ===== 9. ぼかさない影(ポップ) ===== */
.btn--pop { background-color: #fff; color: #1f2937; border-color: #1f2937; box-shadow: 4px 4px 0 #1f2937; }
@media (hover: hover) { .btn--pop:hover { background-color: #fff; border-color: #1f2937; transform: translate(2px, 2px); box-shadow: 2px 2px 0 #1f2937; } }
.btn--pop:active { background-color: #f3f4f6; border-color: #1f2937; transform: translate(4px, 4px); box-shadow: 0 0 0 #1f2937; }
.btn--pop:focus-visible { outline-color: #1f2937; }
<button class="btn btn--3d">押せる</button>
<button class="btn btn--shadow">浮き上がる</button>
<button class="btn btn--pop">ポップ</button>

状態ごとの計算値です。

デザイン 通常 ホバー 押している間
立体 影 0 4px 0、移動なし 影 0 5px 0、上へ 1px 影 0 0 0、下へ 4px
影つき 影 0 2px 6px 影 0 8px 20px、上へ 2px 影 0 1px 2px、移動なし
ポップ 影 4px 4px 0 影 2px 2px 0、右下へ 2px 影 0 0 0、右下へ 4px

立体とポップは「影の長さを減らしたぶんだけ、ボタンを同じ方向に動かす」のがコツです。 影の先端の位置が変わらないので、ボタンが台に押し込まれたように見えます。

光る・輪が広がる・大きくなる(No.12〜14)

/* ===== 10. 光る(グロー) ===== */
@media (hover: hover) { .btn--glow:hover { box-shadow: 0 0 18px color-mix(in srgb, var(--btn-color), transparent 35%); } }

/* ===== 11. 輪が広がる ===== */
@media (hover: hover) { .btn--ring:hover { box-shadow: 0 0 0 5px color-mix(in srgb, var(--btn-color), transparent 70%); } }

/* ===== 12. 少し大きくなる ===== */
@media (hover: hover) { .btn--scale:hover { transform: scale(1.05); } }
.btn--scale:active { transform: scale(0.97); }
<button class="btn btn--glow">光る</button>
<button class="btn btn--ring">輪が広がる</button>
<button class="btn btn--scale">大きくなる</button>

color-mix(in srgb, var(--btn-color), transparent 70%) は、主役の色を半透明にする書き方です。--btn-color を変えれば、光や輪の色も一緒に変わります。拡大は width や padding ではなく transform: scale() で行うと、まわりの要素を押しのけません。

矢印が動く・矢印が出てくる(No.15〜16)

/* ===== 13. 矢印が動く ===== */
.btn--arrow::after { content: "→"; transition: transform var(--btn-speed); }
@media (hover: hover) { .btn--arrow:hover::after { transform: translateX(4px); } }

/* ===== 14. 矢印が出てくる ===== */
.btn--arrow-in::after {
  content: "→";
  margin-left: -1.5em;                 /* gap と文字幅のぶんを打ち消して隠す */
  opacity: 0;
  transform: translateX(-6px);
  transition: margin-left var(--btn-speed), opacity var(--btn-speed), transform var(--btn-speed);
}
@media (hover: hover) { .btn--arrow-in:hover::after { margin-left: 0; opacity: 1; transform: translateX(0); } }
.btn--arrow-in:focus-visible::after { margin-left: 0; opacity: 1; transform: translateX(0); }
<a href="/tasks" class="btn btn--arrow">一覧を見る</a>
<a href="/next" class="btn btn--arrow-in">次へ</a>
arrow    通常   ::after transform=none
arrow    ホバー ::after transform=matrix(1, 0, 0, 1, 4, 0)
arrow-in 通常   ::after opacity=0  margin-left=-24px
arrow-in ホバー ::after opacity=1  margin-left=0px
arrow-in Tab キーでフォーカス ::after opacity=1

矢印は疑似要素(::after)で出しているので、HTML に余分な要素は要りません。「矢印が出てくる」は、ホバーだけでなく focus-visible でも矢印を出して、キーボード操作でも同じ見た目になるようにしています。

背景が流れる・光が走る(No.17〜20)

/* ===== 15〜17. 背景が流れる(左から/下から/中央から) ===== */
.btn--slide, .btn--rise, .btn--center {
  isolation: isolate;                  /* ::before を背面に置くための重なりの基準 */
  overflow: hidden;
  background-color: transparent;
  color: var(--btn-color);
}
.btn--slide::before, .btn--rise::before, .btn--center::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-color: var(--btn-color);
  transition: transform 0.3s ease;
}
.btn--slide::before  { transform: scaleX(0); transform-origin: left; }
.btn--rise::before   { transform: scaleY(0); transform-origin: bottom; }
.btn--center::before { transform: scaleX(0); transform-origin: center; }
@media (hover: hover) {
  .btn--slide:hover, .btn--rise:hover, .btn--center:hover { background-color: transparent; border-color: var(--btn-color); color: var(--btn-on); }
  .btn--slide:hover::before, .btn--center:hover::before { transform: scaleX(1); }
  .btn--rise:hover::before { transform: scaleY(1); }
}
.btn--slide:active, .btn--rise:active, .btn--center:active { background-color: var(--btn-active); color: var(--btn-on); }

/* ===== 18. 光が走る ===== */
.btn--shine { overflow: hidden; }
.btn--shine::before {
  content: "";
  position: absolute;
  top: 0;
  left: -75%;
  width: 50%;
  height: 100%;
  background: linear-gradient(100deg, transparent, rgb(255 255 255 / 0.5), transparent);
  transform: skewX(-20deg);
}
@media (hover: hover) { .btn--shine:hover::before { left: 125%; transition: left 0.6s ease; } }
<button class="btn btn--slide">左から</button>
<button class="btn btn--rise">下から</button>
<button class="btn btn--center">中央から</button>
<button class="btn btn--shine">光が走る</button>
slide   通常 ::before transform=matrix(0, 0, 0, 1, 0, 0)   ホバー ::before transform=matrix(1, 0, 0, 1, 0, 0)
rise    通常 ::before transform=matrix(1, 0, 0, 0, 0, 0)   ホバー ::before transform=matrix(1, 0, 0, 1, 0, 0)
shine   通常 ::before left=-78.5625px                      ホバー ::before left=130.938px

しくみは3つとも同じで、ボタンと同じ大きさの色つきの疑似要素を、幅または高さ 0 倍に縮めておき、ホバーで 1 倍に戻します。 違いは transform-origin(どこを起点に伸びるか)だけです。

押さえる点 書き方 理由
疑似要素を文字の後ろに置く ボタンに isolation: isolate、疑似要素に z-index: -1 ボタンの中で重なりの基準を作り、文字より後ろ・ボタンの背景より前に置く
はみ出しを切る overflow: hidden 角丸の外に色が出ないようにする
幅ではなく transform で伸ばす scaleX(0) から scaleX(1) レイアウトの再計算が起きず、動きがなめらか

重なり順のしくみは z-index の記事で、transition と keyframes の使い分けは CSS アニメーションの記事で扱っています。

下線が伸びる(No.21)

/* ===== 19. 下線が伸びる(テキストボタン) ===== */
.btn--underline {
  background-color: transparent;
  border-color: transparent;
  color: var(--btn-color);
  --btn-px: 0.25em;
}
.btn--underline::after {
  content: "";
  position: absolute;
  left: var(--btn-px);
  right: var(--btn-px);
  bottom: 8px;
  height: 2px;
  background-color: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.3s ease;
}
@media (hover: hover) {
  .btn--underline:hover { background-color: transparent; border-color: transparent; }
  .btn--underline:hover::after { transform: scaleX(1); }
}
.btn--underline:focus-visible::after { transform: scaleX(1); }
.btn--underline:active { background-color: transparent; border-color: transparent; color: var(--btn-active); }
<a href="/about" class="btn btn--underline">詳しく見る</a>

文字だけのリンク風ボタンです。見た目は文字だけでも、土台の min-height: 44px が効いているので、タップできる高さは確保されています。

アイコン付き・アイコンだけ・読み込み中(No.22〜24)

/* ===== 20. アイコン付き/21. アイコンだけ ===== */
.btn svg { width: 1.25em; height: 1.25em; flex-shrink: 0; fill: currentColor; }
.btn--icon-only { padding: 0; width: 44px; height: 44px; }

/* ===== 22. 読み込み中 ===== */
.btn.is-loading { color: transparent; pointer-events: none; }
.btn.is-loading::after {
  content: "";
  position: absolute;
  width: 1.25em;
  height: 1.25em;
  border: 2px solid var(--btn-on);
  border-right-color: transparent;
  border-radius: 50%;
  animation: btn-spin 0.7s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
<!-- アイコン付き:アイコンは飾りなので aria-hidden -->
<button class="btn">
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 4a6 6 0 104.5 10l5 5 1.4-1.4-5-5A6 6 0 0010 4z"/></svg>
  検索
</button>

<!-- アイコンだけ:文字がないので aria-label で名前を付ける -->
<button class="btn btn--icon-only" aria-label="検索">
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 4a6 6 0 104.5 10l5 5 1.4-1.4-5-5A6 6 0 0010 4z"/></svg>
</button>

<!-- 読み込み中 -->
<button class="btn is-loading" aria-busy="true">送信する</button>
項目 計算値
アイコンだけのボタンの大きさ 44×44px
読み込み中のボタンの幅 108.8px(通常時と同じ)
読み込み中の文字色 rgba(0, 0, 0, 0)(透明)
回転する輪 animation: btn-spin 0.7s infinite

読み込み中は、文字を消すのではなく透明にしています。 文字が幅を保ったままなので、ボタンの大きさが変わらず、まわりのレイアウトが動きません。is-loading はマウスとタッチの操作を止めますが、キーボードの Enter は届くので、送信の二重実行を防ぐには JavaScript 側でも処理中の再実行を止めておきます。

スマホでホバーが残る問題と対処

タッチ端末には「マウスを載せる」操作がないため、ブラウザはタップしたときに hover の状態を当て、ほかの場所をタップするまでそのままにします。 その結果、押し終わったボタンの色が変わったまま残ります。

@media で囲まない素の :hover(赤に変わる)と、この記事の土台のボタンを、タッチ端末の設定でタップして比べました。

matchMedia: (hover: hover)=false  (hover: none)=true  (pointer: coarse)=true
タップ後  @media なしのボタン : 背景 rgb(200, 0, 0)   ← ホバーの色のまま
タップ後  @media ありのボタン : 背景 rgb(37, 99, 235) ← 元の色
別の場所をタップしたあとの @media なしのボタン : 背景 rgb(37, 99, 235)

対処は、hover のスタイルを @media (hover: hover) の中に書くことです。

/* 直す前:タッチ端末でタップ後も色が残る */
.btn:hover { background-color: var(--btn-hover); }

/* 直した後:ホバーできる端末だけに当てる */
@media (hover: hover) {
  .btn:hover { background-color: var(--btn-hover); }
}
メディア特性 意味 マウスのある PC スマートフォン
(hover: hover) 主な入力手段でホバーできる 当てはまる 当てはまらない
(hover: none) 主な入力手段でホバーできない 当てはまらない 当てはまる
(pointer: coarse) 主な入力手段の精度が粗い(指) 当てはまらない 当てはまる
(any-hover: hover) つながっている入力手段のどれかでホバーできる 当てはまる マウスをつないでいれば当てはまる

タッチ端末では hover の代わりに active が「押した手応え」を伝える役目をします。 そのため、土台では active を @media の外に書き、色と 1px の移動の両方を変えています。背景が流れるボタン(No.17〜19)も、active で背景色を直接塗る指定を入れてあり、タップした瞬間に色が変わります。

動きを減らす設定(prefers-reduced-motion)への対応

OS の設定で「視差効果を減らす」「アニメーションを減らす」を選んでいる人には、動きを控えた表示を返します。

/* ===== 動きを減らす設定への対応 ===== */
@media (prefers-reduced-motion: reduce) {
  .btn, .btn::before, .btn::after { transition-duration: 0.01ms !important; }
  .btn:hover, .btn:active { transform: none; }
  .btn.is-loading::after { animation-duration: 1.5s; }
}

設定を再現して計算値を取りました。

reduced: transition-duration = 1e-05s(0.01ms)
reduced: btn--scale をホバーしたときの transform = none
reduced: 読み込み中の輪の animation-duration = 1.5s
対象 動きを減らす設定のとき 理由
色の変化 一瞬で切り替わる 色が変わること自体は、状態を伝えるために残す
拡大・移動 行わない 位置や大きさが動く表現を控える
読み込み中の輪 ゆっくり回す 処理中であることを伝える役目があるので、止めずに速度を落とす

transition: none ではなく 0.01ms にしているのは、transition の終了を待つ JavaScript(transitionend)がある場合にも、イベントが発生するようにするためです。

aタグとbuttonの使い分け

見た目が同じでも、「別のページへ移動する」なら a、「その場で何かを実行する」なら button です。キーボードでの動きが違うことを確かめました。

各要素に Tab キーで移動し、Enter と Space を1回ずつ押して、click イベントが何回起きたかを数えています。

要素 Tab で止まるか Enter Space click の回数
<button type="button"> 止まる 実行される 実行される 2
<a href="..."> 止まる 実行される 実行されない 1
<a>(href なし) 止まらない なし なし 0
<div class="btn"> 止まらない なし なし 0
<button disabled> 止まらない なし なし 0
<a href="..." aria-disabled="true"> 止まる 実行される 実行されない 1
やりたいこと 使う要素
別のページ・別の場所へ移動する <a href="...">
フォームを送信する <button type="submit">
開閉、保存、削除、モーダルを開く <button type="button">
新しいタブで開けるようにしたい <a href="...">(button では開けない)

div や href のない a は、Tab キーで止まらず、キーボードでは押せません。 見た目だけボタンにするのではなく、役割に合った要素を選ぶと、フォーカスもキー操作も最初から付いてきます。フォームの中の button は type を省くと送信ボタンになるので、送信以外の用途では type="button" を書いておきます。

リンクを押せない状態にするとき

表の最後の行に注目してください。aria-disabled="true" と pointer-events: none を付けたリンクは、マウスでは押せなくなりましたが、Tab キーでフォーカスでき、Enter で移動しました(location.hash が切り替わることを確認)。pointer-events はマウスとタッチだけを止めるためです。

リンクを押せない状態にするときは、href を外します。

<!-- 押せるとき -->
<a href="/next" class="btn">次へ</a>

<!-- 押せないとき:href を外すと、Tab でも止まらず Enter でも移動しない -->
<a class="btn" role="link" aria-disabled="true">次へ</a>

href を外した a は、確認した環境で Tab キーの移動先に入らず、click も起きませんでした。

aとbuttonで高さがそろわないとき

土台に box-sizing: border-box を入れているのは、次の違いを吸収するためです。

要素 box-sizing の初期値 min-height: 44px を付けたときの高さ(余白 10px+枠 2px)
button border-box(ブラウザの既定スタイル) 44px
a content-box 68px(44 + 余白 20 + 枠 4)
a + box-sizing: border-box border-box 44px

box-sizing を書かずに測ったところ、同じクラスを付けた a だけ高さが 68px になりました。a と button を並べて使う土台では、box-sizing: border-box を明示しておくと高さがそろいます。

タップ領域44pxとコントラスト

タップ領域

指で押すボタンは、見た目より「押せる範囲」の大きさが大切です。

基準 大きさ
WCAG 2.2 達成基準 2.5.8(レベル AA) 24×24 CSS px 以上
WCAG 2.2 達成基準 2.5.5(レベル AAA) 44×44 CSS px 以上
この記事の土台 min-height: 44px; min-width: 44px;

余白と文字サイズだけで作った小さなボタン(padding: 4px 8px; font-size: 12px)は、実測で 32×29px でした。土台の btn--sm は、文字サイズが 14px でも高さ 44px を保っています。文字サイズを小さくしても、min-height で押せる範囲は小さくしない、という考え方です。見た目を小さくしたいテキストリンク風のボタンも、高さだけは確保しておくと押しやすくなります。

コントラスト比

文字と背景のコントラスト比は、通常の大きさの文字で 4.5:1 以上(WCAG 2.2 達成基準 1.4.3)、ボタンの枠線など部品の輪郭は 3:1 以上(1.4.11)が目安です。--btn-color に入れる色ごとに、白文字との比を計算しました。

--btn-color 白文字との比(通常) 白文字との比(ホバー) 白い背景の上の枠線・文字(枠線ボタン) 4.5:1
#2563eb(青) 5.17 6.64 5.17 満たす
#15803d(緑) 5.02 6.44 5.02 満たす
#b91c1c(赤) 6.47 8.15 6.47 満たす
#1f2937(濃いグレー) 14.68 15.84 14.68 満たす
#3b82f6(明るい青) 3.68 4.85 3.68 届かない
#22c55e(明るい緑) 2.28 3.14 2.28 届かない
#f59e0b(オレンジ) 2.15 2.96 2.15 届かない

明るい色を主役にしたいときは、文字色のほうを変えます。 --btn-on: #1f2937; と濃い文字にすると、明るい緑やオレンジでも比を確保できます。

.btn--amber { --btn-color: #f59e0b; --btn-on: #1f2937; }

disabled のボタンは、透明度 0.45 にした結果、白文字との比が 1.96 になります。押せない部品はコントラストの基準の対象外とされていますが、なぜ押せないのかが文字で読める場所(ボタンの近くの説明文など)を用意しておくと、利用者が次に何をすればよいか迷いません。

仕上げのチェックリスト

確認すること 見るところ
Tab キーでフォーカスの輪が見える :focus-visible の outline
マウスを載せたときと押したときで見た目が違う hover と active
スマートフォンでタップ後に色が残らない @media (hover: hover)
押せないボタンにマウスを載せても変化しない disabled の pointer-events: none
高さが 44px 以上ある min-height
文字と背景の比が 4.5:1 以上 --btn-color と --btn-on
移動は a、実行は button になっている HTML の要素
動きを減らす設定で拡大・移動が止まる prefers-reduced-motion

よくある質問

Q. ホバーアニメーションがスマホで残ってしまいます。どう直しますか?

A. hover のスタイルを @media (hover: hover) { ... } の中に移します。タッチ端末では (hover: hover) が当てはまらないので、タップ後に色が残らなくなります。押した手応えは :active で付けます。

Q. outline: none でフォーカスの枠を消してもよいですか?

A. 消すと、キーボードで操作している人が今どこにいるかわからなくなります。:focus-visible を使えば、マウスでクリックしたときは輪が出ず、Tab キーで移動したときだけ輪が出ます。この記事の土台はその形になっています。

Q. ボタンの色を変えるには、どこを直せばよいですか?

A. --btn-color を変えるだけです。ホバー・押下・淡色・輪の色は color-mix() で自動計算されます。クラスで .btn--green { --btn-color: #15803d; } と用意するか、style="--btn-color: #7c3aed" とその場で指定します。明るい色のときは --btn-on も濃い色にします。

Q. a タグに disabled は使えますか?

A. a に disabled 属性はありません。aria-disabled="true" と pointer-events: none だけでは、キーボードの Enter で移動できることを確認しました。押せない状態にするときは href を外し、role="link" aria-disabled="true" を付けます。

まとめ

  • 土台のクラスを1つ作り、色・サイズを CSS 変数にする。 --btn-color を変えるだけで、ホバーや押下の色まで連動する
  • hover・focus-visible・active・disabled の4状態を土台でそろえる。 デザインを増やしても操作感が変わらない
  • hover は @media (hover: hover) で囲む。 タッチ端末でタップ後に色が残らなくなる
  • フォーカスの輪は :focus-visible で出す。 outline: none で消さない
  • 背景が流れる・下線が伸びる動きは、疑似要素を transform: scale で伸ばす。 違いは transform-origin だけ
  • 移動は a、実行は button。 並べて使うなら box-sizing: border-box で高さをそろえる
  • 高さは 44px 以上、文字と背景の比は 4.5:1 以上。 明るい色のボタンは文字色を濃くする

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSSボタンデザインのコピペ集|ホバーアニメーションと4つの状態」

株式会社RJCでは、CSSのボタンデザインを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?