CSSで、ボタンに光が流れる反射を付ける方法
ボタンに光が流れる反射は、ボタンの ::before に白い帯のグラデーションを描き、マウスを乗せたときに transform でボタンの左の外から右の外まで動かすと作れます。ボタンには position: relative と overflow: hidden を付けて、帯の位置の基準をボタンにし、はみ出した部分を切り取ります。マウスを外したときに光が逆向きに戻らない書き方と、マウスを乗せなくても数秒おきに光を流す書き方もあわせて紹介します。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開き、マウスとキーボードで操作して、getComputedStyle() で読んだ ::before の transform と、スクリーンショットの画素の変化を記録しました。Firefox と Safari、スマートフォンの実機でのタッチ操作は試していません。

白い帯を ::before に描いて、乗せたときに動かす
HTML は button 1つです。
<button class="shine" type="button">申し込む</button>
.shine {
position: relative; /* ::before の位置の基準にする */
overflow: hidden; /* ボタンの外に出た光を切り取る */
padding: 14px 32px;
border: none;
border-radius: 8px;
font: inherit;
font-weight: bold;
color: #fff;
background: #067a98;
cursor: pointer;
}
.shine::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
/* 真ん中だけが白く、両端は透明な帯 */
background: linear-gradient(90deg, rgb(255 255 255 / 0), rgb(255 255 255 / 0.6), rgb(255 255 255 / 0));
transform: translateX(-150%) skewX(-20deg); /* 最初はボタンの左の外に置く */
}
.shine:hover::before,
.shine:focus-visible::before {
transform: translateX(300%) skewX(-20deg); /* ボタンの右の外まで動かす */
transition: transform 600ms ease-in-out; /* 乗せたときだけ動かす */
}
光の正体は、::before で作った中身のない擬似要素です。ボタンの高さいっぱい、幅はボタンの 40% の帯にして、linear-gradient() で左右の端を透明に、真ん中を半透明の白にしています。端がぼけるので、帯の形がくっきり見えず、光が当たったように見えます。
skewX() は、要素を横方向にゆがめる関数です。-20deg ゆがめて帯を斜めにすると、光が斜めに差し込んだ形になります。
translateX() のパーセントは、帯そのものの幅が基準です。検証環境ではボタンの幅が 136px、帯の幅はその 40% の 54.4px でした。-150% で左に 81.6px ずらすと、帯はボタンの左の外に出ます。マウスを乗せると 300%、つまり右に 163.2px の位置まで動き、ボタンの右の外に抜けます。
マウスを乗せてから、transition を一時停止して 100ms ずつ進め、::before の横の移動距離と、乗せる前の画面から色が変わった画素の数を調べました。
| 乗せてから | 横の移動距離 | 色が変わった画素(ボタンの中) | 色が変わった画素(ボタンの外) |
|---|---|---|---|
| 0ms | -81.6px | 0 | 0 |
| 100ms | -67.8px | 0 | 0 |
| 200ms | -24.9px | 1,469 | 0 |
| 300ms | 40.8px | 2,534 | 0 |
| 400ms | 106.4px | 1,487 | 0 |
| 500ms | 149.4px | 0 | 0 |
| 600ms | 163.2px | 0 | 0 |
0ms、100ms、500ms、600ms の時点では、ボタンの見た目は乗せる前と同じで、色が変わった画素があったのは 200ms から 400ms の時点だけでした。帯がボタンの外にある間は、ボタンの見た目が変わらないためです。ease-in-out は、ゆっくり始まって加速し、終わりに向かって減速する動きです。表でも、最初の 100ms は 13.7px しか動かず、200ms から 300ms の間は 65.7px 動いています。
光が流れている途中で、ボタンの真ん中にある要素を document.elementFromPoint() で調べると、ボタンそのものでした。そこをクリックすると、click もボタンに届きました。光のせいでクリックできなくなることはありませんでした。
overflow: hidden がないと、光がボタンの外に見える
帯は、乗せる前も流れ終わった後も、ボタンの外に置いてあります。overflow: hidden を付けると、ボタンからあふれた部分は、ボタンのパディングボックスの端で切り取られて見えなくなります。
白い背景のページで overflow: hidden を外したボタンを撮ると、ボタンの外で白から色が変わった画素は 0 個で、はみ出した帯は見えませんでした。ところが、暗い背景の上に置くと、乗せる前から帯がボタンの左に見えていました。

ボタンの外にある暗い背景のうち、色が変わった画素を数えると、overflow: hidden なしでは 10,915 個(倍率 2 で撮った画素の数)、ありでは 0 個でした。
position: relative がないと、光の大きさの基準が画面になる
::before の top、left、width、height のパーセントは、包含ブロックが基準です。position: absolute の要素の包含ブロックは、position が static 以外の、いちばん近い祖先になります。ボタンに position: relative を付けているのは、この基準をボタンにするためです。
ボタンの position を static に戻して、400×560px の画面で ::before の大きさを読むと、幅は 160px、高さは 560px でした。ボタンの 40% ではなく画面の幅の 40%、高さは画面の高さそのものになっています。position を付けた祖先がないので、包含ブロックが、ビューポートの寸法を持つ初期包含ブロックになったためです。光の帯が、ボタンではなく画面の大きさで作られてしまいます。
マウスを外したとき、光を逆向きに戻さない
記事の CSS では、transition を :hover の側にだけ書いています。こうすると、transition が効くのは乗せたときだけになります。
マウスを外した直後の ::before を読むと、横の移動距離はすぐに -81.6px(左の外)に戻り、transition は起きていませんでした。右の外から左の外へ一瞬で戻るので、戻る途中の光は見えません。
transition を元の状態(.shine::before)にも書くと、外したときにも 600ms の transition が起きます。
| 外してから | 横の移動距離 |
|---|---|
| 0ms | 163.2px(右の外) |
| 300ms | 40.8px(ボタンの真ん中あたり) |
| 600ms | -81.6px(左の外) |
光が右から左へ逆向きに流れて戻ってしまいました。反射が一方向に流れるように見せたいなら、transition は :hover の側にだけ書きます。
なお、:hover の側にだけ書いた場合でも、光が流れている途中(300ms)でマウスを外すと、光はその場で消えて左の外に戻りました。
キーボードで移ったときにも光らせる
セレクターに :focus-visible も並べているのは、Tab キーでボタンに移ったときにも光らせるためです。:focus-visible は、フォーカスを目に見える形で示すべきだとブラウザーが判断したときに当てはまる擬似クラスです。
新しく開いたページで Tab キーを押すと、ボタンにフォーカスが移って :focus-visible が当てはまり、光の transition が始まりました。流し終えた時点の横の移動距離は 163.2px(右の外)で、マウスを乗せたときと同じ位置です。
一方、別に新しく開いたページで、マウスでボタンをクリックすると、フォーカスはボタンに移りましたが、:focus-visible は当てはまりませんでした。マウスを外すと、フォーカスはボタンに残ったまま、光は transition なしで左の外に戻りました。
マウスを乗せなくても、数秒おきに流す
マウスを乗せなくても光を流したいときは、@keyframes で動きを決め、animation で繰り返します。
@keyframes shine-sweep {
0% {
transform: translateX(-150%) skewX(-20deg);
}
25%,
100% {
transform: translateX(300%) skewX(-20deg);
}
}
.shine--auto::before {
animation: shine-sweep 3s ease-in-out infinite;
}
<button class="shine shine--auto" type="button">申し込む</button>
1回分を 3 秒にして、最初の 25%(750ms)で左の外から右の外まで動かし、残りの 75% は右の外に置いたままにしています。これで、光が流れた後に 2.25 秒の間が空き、また流れる、の繰り返しになります。
アニメーションを一時停止して時間を進めながら、横の移動距離を読みました。
| 始まってから | 横の移動距離 |
|---|---|
| 0ms | -81.6px(左の外) |
| 250ms | -24.9px |
| 500ms | 106.4px |
| 750ms | 163.2px(右の外) |
| 1500ms | 163.2px |
| 2999ms | 163.2px |
| 3000ms | -81.6px(左の外に戻って2回目) |
| 3375ms | 40.8px |
750ms から 2999ms までは右の外で止まっていて、3000ms で左の外に戻り、2回目が始まりました。infinite を付けているので、Web Animations API で読むと、繰り返しの回数は無限(Infinity)になっていました。右の外から左の外へ戻る瞬間は、どちらもボタンの外なので見えません。
動きを減らす設定の人には流さない
OS で動きを減らす設定をしている人には、光を流さないようにしておきます。prefers-reduced-motion は、ユーザーが余計な動きを減らすよう求めているかを調べるメディア特性です。
@media (prefers-reduced-motion: reduce) {
.shine::before {
display: none;
}
}
Playwright で prefers-reduced-motion: reduce を再現すると、::before の display は none になりました。自動で流すボタンのアニメーションはなくなり、マウスを乗せても transition は起きませんでした。光は飾りで、ボタンの働きには関わらないので、帯ごと消しています。





