CSSで、押すと波紋が広がるボタンを作る方法
押すと波紋が広がるボタンは、::after で描いた円を、ふだんは大きく広げて透明にしておき、押している間(:active)だけ大きさ0・不透明にしておくと作れます。マウスのボタンを離すと :active が外れ、transition で元の大きく透明な円に戻っていきます。この戻る動きが、波紋の広がりに見えます。アニメーションは CSS だけで動き、数行の JavaScript を足すと、押した位置から広げられます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、マウスとキーボードで実際に押しました。離したあとの動きは、アニメーションを一時停止して時刻を指定し、getComputedStyle() の値とスクリーンショットの画素を読みました。Firefox と Safari、スマートフォンの実機でのタッチ操作は試していません。

コード全体
CSS は次のとおりです。
.ripple-button {
position: relative;
overflow: hidden; /* 波紋を、ボタンの形で切り取る */
padding: 14px 32px;
border: none;
border-radius: 999px;
background: #067a98;
color: #fff;
font-size: 18px;
cursor: pointer;
}
/* 波紋の円。ふだんは大きく広がって透明になっている */
.ripple-button::after {
content: "";
position: absolute;
left: var(--x, 50%); /* 押した位置。JavaScript がなければ真ん中 */
top: var(--y, 50%);
width: 250%;
aspect-ratio: 1;
border-radius: 50%;
background: rgb(255 255 255 / 0.5);
transform: translate(-50%, -50%) scale(1);
opacity: 0;
transition: transform 0.6s ease-out, opacity 0.6s ease-out;
pointer-events: none;
}
/* 押している間は、大きさ0・不透明にしておく。この切り替えには transition を付けない */
.ripple-button:active::after {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
transition: none;
}
/* 動きを減らす設定の人には、波紋を出さない */
@media (prefers-reduced-motion: reduce) {
.ripple-button::after {
display: none;
}
}
HTML はボタンにクラスを付けるだけです。押した位置から広げたいときは、次の JavaScript も読み込みます。
<button class="ripple-button">送信する</button>
<script src="ripple.js"></script>
document.querySelectorAll(".ripple-button").forEach((button) => {
// マウスや指で押した位置を、--x と --y に入れる
button.addEventListener("pointerdown", (event) => {
const rect = button.getBoundingClientRect();
button.style.setProperty("--x", `${event.clientX - rect.left}px`);
button.style.setProperty("--y", `${event.clientY - rect.top}px`);
});
// Space キーで押したときは、位置を消して真ん中から広げる
button.addEventListener("keydown", (event) => {
if (event.key === " ") {
button.style.removeProperty("--x");
button.style.removeProperty("--y");
}
});
});
円の大きさは、ボタンの幅の250%(aspect-ratio: 1 で正円)です。検証したボタン(幅およそ137px、高さ52px)では直径およそ342px、半径およそ171pxです。ボタンの対角線(およそ146px)より半径が長いので、ボタンのどこを押しても、円は反対側の角まで届きます。pointer-events: none は、円がクリックを受け取らないようにする指定です(pointer-events の説明)。
離したときに、大きさ0から広がっていく
:active は、ボタンが押されている間に当てはまる擬似クラスです。マウスでは、主ボタンを押し下げたときに始まります。
押している間は transition: none なので、円はすぐに大きさ0・不透明になります。離すと :active が外れ、ふだんの指定にある transition(0.6秒)で、大きく透明な円に戻ります。押して100ミリ秒後に離し、離した瞬間からの ::after を読むと、次のようになりました。
| 離してからの時間 | 大きさ(scale) |
不透明度 | 押す前と色が違う画素 |
|---|---|---|---|
| 0秒 | 0 | 1 | 0 |
| 0.15秒 | 0.378 | 0.622 | 4,525 |
| 0.3秒 | 0.685 | 0.315 | 6,465 |
| 0.45秒 | 0.907 | 0.093 | 6,302 |
| 0.6秒 | 1 | 0 | 0 |
円が広がりながら薄くなり、0.6秒でボタンは押す前と同じ見た目に戻りました。ただし、押している間は円の大きさが0なので、何も見えません。押して100ミリ秒後も500ミリ秒後も、押す前と色が違う画素は0個でした。波紋は、離した瞬間から広がります。
:active に @keyframes を付けると、短いクリックで波紋が消える
押したときに @keyframes のアニメーションを流す書き方もあります。
.ripple-button:active::after {
animation: ripple 0.6s ease-out;
}
@keyframes ripple {
from { transform: translate(-50%, -50%) scale(0); opacity: 1; }
to { transform: translate(-50%, -50%) scale(1); opacity: 0; }
}
この書き方は、押している間に波紋が広がります。押して100ミリ秒後には、大きさがおよそ0.22まで広がり、色が違う画素は3,416個ありました。ところが、そこで離すと :active が外れて、アニメーションそのものがなくなります。離した直後の document.getAnimations() は空で、色が違う画素は0個でした。ふつうのクリックのように短く押すと、波紋は広がりきる前に消えます。
transition で戻す書き方なら、押していた時間が100ミリ秒でも500ミリ秒でも、離したあとに0.6秒の transition が同じように流れました。
押した瞬間から広げたいときは、クラスを付け直す
押している間にも波紋を見せ、短いクリックでも最後まで広げたいときは、JavaScript でクラスを付け、そのクラスにアニメーションを付けます。:active を使わないので、離してもアニメーションは止まりません。
.ripple-button.is-rippling::after {
animation: ripple 0.6s ease-out;
}
@keyframes ripple {
from {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
}
JavaScript は、上の ripple.js の代わりに、次のように書きます。
document.querySelectorAll(".ripple-button").forEach((button) => {
button.addEventListener("pointerdown", (event) => {
const rect = button.getBoundingClientRect();
button.style.setProperty("--x", `${event.clientX - rect.left}px`);
button.style.setProperty("--y", `${event.clientY - rect.top}px`);
// クラスを外して付け直し、アニメーションを最初から流す
button.classList.remove("is-rippling");
void button.offsetWidth;
button.classList.add("is-rippling");
});
button.addEventListener("animationend", () => {
button.classList.remove("is-rippling");
});
});
::after のふだんの指定と .ripple-button の指定は、最初の CSS から transition と :active のルールを除いたものです。@keyframes の説明にあるとおり、to を書かないと、ブラウザはその要素のもとのスタイルを終わりの状態に使います。ここでは ::after のふだんの値(大きさ1、不透明度0)で終わります。
押して100ミリ秒後は大きさがおよそ0.26まで広がっていて、離して50ミリ秒後も、アニメーションは止まらずにおよそ0.42まで進みました。離して750ミリ秒後には、アニメーションが終わってクラスも外れていました。
void button.offsetWidth; の行は、続けて押したときのためにあります。1回目から200ミリ秒後に2回目を押し、その30ミリ秒後を見ると、この行があるときはアニメーションが最初からやり直され、大きさはおよそ0.05でした。この行を消すと、1回目のアニメーションがそのまま続いていて、大きさはおよそ0.56でした。クラスを外してすぐ付け直すだけでは、アニメーションは最初からやり直されませんでした。
この書き方は pointerdown でクラスを付けるので、Space キーで押しても波紋は出ませんでした。離して30ミリ秒後に、動いているアニメーションは1つもありませんでした。
overflow: hidden がないと、波紋がボタンの外にはみ出す
::after の円は、ボタンよりずっと大きく作っています。ボタンに overflow: hidden がないと、円はボタンの外まで描かれます。

濃い色(#1f2a30)の背景で、離して0.15秒の時点を比べると、ボタンの外で色が変わった画素は、overflow: hidden ありでは0個、なしでは8,618個でした。
はみ出しは、見た目だけの問題ではありません。幅240pxの画面で、押していないときのページの横幅(scrollWidth)を読むと、overflow: hidden ありでは画面と同じ240pxでしたが、なしでは270pxでした。透明で見えない円のせいで、ページが画面より30px広くなり、右へ30pxスクロールできました。
overflow の説明によると、hidden では、あふれた内容は要素のパディングボックスで切り取られます。上の比較の画像のとおり、円は border-radius: 999px の丸い端に沿って切り取られました。
押した位置は、カスタムプロパティで渡す
円の中心は left: var(--x, 50%) と top: var(--y, 50%) で決めています。var() の説明にあるとおり、2つ目の値は、カスタムプロパティが定義されていないときに使われる代替値です。
JavaScript を読み込むと、pointerdown で押した位置がボタンの左上からの距離で入ります。ボタンの左上から (30, 20) を押すと ::after の left と top は 30px と 20px、(120, 30) を押すと 120px と 30px でした。JavaScript を読み込まないページで (30, 20) を押すと、68.4219px と 26px で、ボタンの真ん中から広がりました。
keydown の処理は、キーボードで押したときのためのものです。pointerdown だけの書き方では、一度マウスでボタンの左端近く(左から10px)を押したあとに Space キーで押すと、left は 10px のままで、キーボードで押したのに左端から波紋が広がりました。keydown で位置を消すと、left は 68.4219px になり、真ん中から広がりました。
キーボードでは、Space キーを押している間は :active が当てはまり、離すと波紋が広がりました。Enter キーでは :active が当てはまらず、click は起きましたが波紋は出ませんでした。
動きを減らす設定では、波紋を出さない
OS で動きを減らす設定にしている人には、prefers-reduced-motion で円そのものを消します。Playwright で reducedMotion: 'reduce' にして開くと、::after の display は none になり、押して離しても、アニメーションは1つも作られませんでした。設定がない(no-preference)ときは、transform と opacity の2つの transition が作られました。
対応しているブラウザ
MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で、この記事のコードで使っている主な CSS と DOM の機能を調べました。いちばん新しい版から使えるようになったのは aspect-ratio で、Chrome 88、Firefox 89、Safari 15 からです。rgb(255 255 255 / 0.5) のように値を空白で区切る書き方は Chrome 65、Firefox 52、Safari 12.1 から、prefers-reduced-motion は Chrome 74、Firefox 63、Safari 10.1 から、pointerdown は Chrome 55、Firefox 59、Safari 13 から使えます。





