css ボタン 押したら変わる 9選 — 状態遷移の実装
css ボタン 押したら変わる、つまり押した瞬間から結果が出るまでボタン自身が姿を変えて伝える作りを9つ集めました。押したあとが本番です。色だけ変える見せかけではなく、実際に固まり、回り、戻ります。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 読み込みから完了まで一巡
- 02 条件が揃うと開くボタン
- 03 オンとオフを一つのボタンが持つ
- 04 数字が転がるいいねボタン
- 05 ボタン自身が進捗バーになる
- 06 長押しで初めて消える削除
- 07 主な操作と別の道が並ぶボタン
- 08 選んだ区画へ滑る錠剤
- 09 コピーしたと一瞬だけ言うボタン
並び順は人気順ではなく、ボタン一つが経験する順番です。まず押されて答えを待ち、元へ戻ります(01)。次に、まだ押せない理由を示し(02)、押されたまま留まることを覚えます(03)。真ん中の三つは、押した結果が画面に残る場合です。数字として残り(04)、数秒の進捗として残り(05)、取り消せないからもう一度確かめます(06)。最後の三つは、ボタンが一つきりではないときです。隣に別の道が並び(07)、互いに排他的な三つの区画の一つになり(08)、押したという事実を一瞬だけ伝えて黙ります(09)。九つに共通する原則は一つです。いまの状態はクラスではなく aria-busy・aria-pressed・aria-disabled・aria-checked・aria-expanded のうち場面に合う属性一か所だけに書き、見た目はその属性を読んで決めます。二か所に書けば必ずずれますし、ずれるのはいつも目に見えない側です。
01読み込みから完了まで一巡
一度押すとラベルがスピナー付きの「保存中」に変わり、1.2秒後にチェックへ切り替わり、その2秒後に元のラベルへ戻ります。回っている間は disabled なので二度押せず、aria-busy が処理中であることを伝えます。状態名を変える関数が三つの属性をまとめて更新するので、見た目と支援技術が食い違う隙がありません。
function 상태(s) {
btn.setAttribute('data-state', s);
btn.setAttribute('aria-busy', String(s === 'busy'));
btn.disabled = s !== 'idle';
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
clearTimeout(t1);
clearTimeout(t2);
상태('busy');
t1 = setTimeout(function () { 상태('done'); }, 1200);
t2 = setTimeout(function () { 상태('idle'); }, 3200);
});
02条件が揃うと開くボタン
入力欄がメールアドレスの形式になるまでボタンは薄く固まっていますが、disabled ではなく aria-disabled を使うのでフォーカスは受け取ります。固まったまま押しても何も起きず、代わりに理由が吹き出しで1.4秒だけ表示されます。本物の disabled はフォーカス自体を受け取れないので、その説明を読み上げる機会がそもそも訪れません。
function 검사() {
var ok = mail.checkValidity() && mail.value.length > 0;
btn.setAttribute('aria-disabled', String(!ok));
return ok;
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (!검사()) {
btn.setAttribute('data-why', '1');
setTimeout(function () { btn.removeAttribute('data-why'); }, 1400);
return;
}
btn.setAttribute('data-sent', '1');
});
03オンとオフを一つのボタンが持つ
ボタンの中に二つの顔が同じ位置で重なっていて、どちらが見えるかは aria-pressed を読むセレクタだけが決めます。スクリプトがするのはその属性を反転させることだけなので、色もアイコンも読み上げられる状態もすべて同じ値から決まります。同じ向きに続けて押してもはね返りが毎回走るように、クラスを外し、リフローを一度強制してから付け直します。
.tp:not(.is-demo) .tp__btn[aria-pressed="false"] .tp__face[data-face="off"],
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] .tp__face[data-face="on"] { opacity: 1; }
.tp:not(.is-demo) .tp__btn[aria-pressed="true"] { background-color: $color; color: $stage-yellow; }
.tp__btn.is-pop {
animation-name: tp-tap;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes tp-tap {
0% { transform: scale(.94); }
60% { transform: scale(1.03); }
100% { transform: scale(1); }
}
04数字が転がるいいねボタン
数字の窓は高さを一行分に固定し、その中に古い値と新しい値を縦に積んでいます。いいねを押しても textContent は書き換えず、積んだ列を22px 持ち上げて新しい値を下から見せます。桁ごとに文字幅が違うと窓が揺れるので、font-variant-numeric: tabular-nums で幅を揃えます。
.lk__win {
display: block;
overflow: hidden;
height: 22px;
width: 46px;
}
.lk__roll { display: block; transform: translateY(0); }
.lk__n { display: block; height: 22px; font-variant-numeric: tabular-nums; }
.lk:not(.is-demo) .lk__roll { transition: transform $duration $easing; }
.lk:not(.is-demo) .lk__btn[aria-pressed="true"] .lk__roll { transform: translateY(-22px); }
05ボタン自身が進捗バーになる
パーセントは変数一つにだけ書き、満ちる幅も画面に出る数字も同じ変数を読みます。ところが --p: 0 と置くだけでは、ブラウザがその値を文字としてしか扱わないので、0から100へ滑らかに変化せず飛び飛びになります。@property で整数として登録して初めて補間され、同じ変数を counter-reset に渡せば数字としても表示できます。
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl__fill {
background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-variant-numeric: tabular-nums;
}
06長押しで初めて消える削除
押している間だけ周りの輪が stroke-dashoffset で一周描かれ、描き切ったときだけ削除が実行されます。途中で手を離すと同じトランジションがそのまま巻き戻り、何もなかったことになります。押した瞬間に setPointerCapture を呼んでおかないと、指がボタンの外へ出たときに取り消しイベントを受け取れず、輪が勝手に満ち続けます。
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
root.setAttribute('data-state', 'idle');
btn.setPointerCapture(e.pointerId);
root.classList.add('is-holding');
clearTimeout(끝);
끝 = setTimeout(function () { root.setAttribute('data-state', 'done'); root.classList.remove('is-holding'); }, 1200);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(function (t) {
btn.addEventListener(t, function () { clearTimeout(끝); root.classList.remove('is-holding'); });
});
07主な操作と別の道が並ぶボタン
一つの枠に入っていますが中身は button が二つで、一覧を開くのは右の矢印側だけです。左の広い側はいちばんよく使う操作をすぐ実行し、一覧を閉じるだけなので aria-expanded を true にはしません。一覧そのものは role="menu" で各行が role="menuitem" なので、キーボードからも同じ経路で開けます。
<div class="sp__group" role="group" aria-label="送信の選択">
<button class="sp__main" id="sp-main" type="button">
<span class="sp__t">今すぐ送信</span>
</button>
<button class="sp__more" id="sp-more" type="button" aria-haspopup="menu" aria-expanded="false" aria-controls="sp-menu" aria-label="送信方法をもっと見る">
<span class="sp__caret" aria-hidden="true"></span>
</button>
</div>
<ul class="sp__menu" id="sp-menu" role="menu" aria-labelledby="sp-more">
<li role="none"><button class="sp__item" type="button" role="menuitem">予約して送信</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">下書きに保存</button></li>
<li role="none"><button class="sp__item" type="button" role="menuitem">送信前にプレビュー</button></li>
</ul>
08選んだ区画へ滑る錠剤
三つの区画は flex: 0 0 auto で幅80px に固定してあり、錠剤はその倍数だけ動きます。均等配分の flex: 1 1 0 と固定の移動幅を混ぜると、実際に描かれた区画の幅が見積もりとずれて、錠剤が隣のラベルにはみ出します。どの区画が選ばれているかは aria-checked に書き、文字色も同じ属性から決めます。
.sg__pill {
position: absolute;
width: $칸;
border-radius: $r-pill;
transform: translateX(0);
}
.sg__seg {
flex: 0 0 auto;
width: $칸;
height: 36px;
}
.sg:not(.is-demo) .sg__pill { transition: transform $duration $easing; }
.sg:not(.is-demo) .sg__bar[data-at="1"] .sg__pill { transform: translateX($칸); }
.sg:not(.is-demo) .sg__bar[data-at="2"] .sg__pill { transform: translateX($칸 * 2); }
09コピーしたと一瞬だけ言うボタン
navigator.clipboard.writeText の処理が決着したあとにラベルが変わり、1.6秒後に静かに戻ります。権限がブロックされた環境でも人には反応が見えるべきなので、成功と失敗の両方に同じ仕上げ関数を渡します。一瞬だけ変わるラベルは目にしか届かないため、同じ言葉が role="status" の領域へも一度だけ流れます。
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var 끝내기 = function () {
root.setAttribute('data-copied', '1');
clearTimeout(되돌림);
되돌림 = setTimeout(function () { root.removeAttribute('data-copied'); }, 1600);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(src.textContent).then(끝내기, 끝내기);
} else {
끝내기();
}
});
どこで壊れるか — 落とし穴
状態が変わる部品は、まず代表画像で崩れます。デモごとに24枚のフレームを取り、そのうち前の一枚と最も大きく変わった一枚を代表に選ぶのですが、07の最初の版はループで一覧を開いてからまた閉じていました。一覧が消えるフレームは開くフレームより必ず大きく変わるので、代表画像はオレンジ地に閉じたボタンが一つ残っただけの場面になりました。直し方は、ループでは一覧を閉じないことです。8%で一度だけ開いたらそのままにして、行ごとに違う遅延で translateY を戻し、そのあとは背景色で行を一つずつなぞらせました。結果は累積面積24.58%、動いたフレーム12/23で、代表画像は一覧が開いた場面になりました。24枚は2秒を等分した標本なので、1枚あたり4.1667%の刻みになります。
二つ目は、カットとフレーム数の綱引きです。状態が切り替わる瞬間を steps(1, end) で断つと半透明に重なった幽霊フレームは消えますが、カットは差のあるフレームを二枚しか作りません。そこで九つすべてに、カットの隣に transform で動く小さな動きを一つずつ添えました。01は回り続けるスピナー、03は scale の反動、09はコピーされた瞬間だけ広がる縁です。09は戻りも一度にはせず、76%でアイコン、84%で色とラベルに分けて断ちました。戻る変化が起きる変化より小さくないと、代表画像が狙いと違う側に寄ってしまいます。三つ目は、眺めているだけでは絶対に気づけない落とし穴でした。02のループはダミーの入力文字を steps(8, end) で出しますが、幅を108px にしたところ最後の一文字が半分で切れ、[email protected] と読めてしまいました。120px に広げて初めてアドレスが丸ごと出ました。九つ分のソースが入った圧縮ファイルのパスワードは sqph35dy で、ここで直した値がそのまま入っています。
四つ目は大きさです。320×200の舞台では余白を差し引くと縦に使えるのは174px だけで、07の一覧は絶対配置なので親の高さ計算に入りません。見た目は問題なくても文書の高さだけが伸びて格子のマスにスクロールバーが出るため、包む箱に一覧の高さ分の padding-bottom をあらかじめ空けてあります。狭い画面ではその余白を116px から100px へ減らします。狭い側では間隔を切り上げず、切り捨てるのが決まりです。
アクセシビリティ
動きを減らす設定(prefers-reduced-motion: reduce)が有効になっていると、九つとも自動再生だけを止め、いまの状態はそのまま残します。01のスピナーは回転を止め、05の進捗は空のまま止まり、06の輪は育たずに一度で満ちます。状態を伝える属性はどちらの経路でも切れません。
| 項目 | マウス | キーボード | 状態が書かれる場所 |
|---|---|---|---|
| 01 非同期の一巡 | クリック | Enter · Space | aria-busy · disabled |
| 02 条件の錠 | クリック | Tab でフォーカス維持 | aria-disabled · aria-describedby |
| 03 トグル | クリック | Enter · Space | aria-pressed |
| 04 いいね | クリック | Enter · Space | aria-pressed · aria-live |
| 05 進捗 | クリック | Enter · Space | role="progressbar" · aria-valuenow |
| 06 長押し | 押したまま | Enter · Space | role="status" |
| 07 スプリット | 左右を別々に | Tab · Escape | aria-expanded · aria-haspopup |
| 08 セグメント | クリック | ← → キー | role="radio" · aria-checked |
| 09 コピー | クリック | Enter · Space | aria-live="polite" |
押下状態をクラスではなく属性に書く理由は MDN の aria-pressed の解説にまとまっています。05が使うカスタムプロパティの登録も MDN の @property の解説に文法と補間の条件が書かれていて、syntax を落とすと登録そのものが無視されます。手で押して始まる部品はクリックのカテゴリに、画面で一つだけ押してほしいボタンは CTA のカテゴリに集めてあります。
FAQ
押せないボタンには disabled と aria-disabled のどちらを使いますか
伝える理由があるなら aria-disabled、ないなら disabled です。本物の disabled はフォーカスを受け取れないのでキーボードがそのボタンに届かず、aria-describedby で結んだ理由も読み上げられません。ただし aria-disabled ではブラウザがクリックを止めてくれないので、押されても何もしないコードを自分で書く必要があります。
完了表示はどれくらい出しておくのが適切ですか
2秒あれば十分です。01は応答を1.2秒に置き、完了を2秒見せてから戻しますが、これより短いと結果を見落とし、長いと次の操作が妨げられているように感じます。応答時間が読めない処理では、完了への切り替えは応答そのものに合わせ、表示時間だけを固定にするのが安全です。
九つを一つのプロジェクトに同居させても大丈夫ですか
項目ごとにクラスの接頭辞が違うので衝突しません。ただし05の @property --p は文書全体に一度だけ登録される名前なので、別の場所で同じ名前を違う syntax で登録し直すと後勝ちになります。進捗ボタンを複数使う予定があるなら、--dl-p のように部品名を付けた名前に変えておくほうが安全です。