CSSで、枠線が回りながら光るボタンを作る方法
枠線が回りながら光るボタンは、conic-gradient() の始まりの角度を @property で登録したカスタムプロパティ --angle にして、@keyframes で 0deg から 360deg まで動かすと作れます。光は、同じグラデーションを filter: blur() でぼかした疑似要素です。@property を書かないと枠が回らず、inherits: false にすると疑似要素だけ止まり、背景色のある場所に置くと枠ごと消えることがあります。それぞれの直し方も、この記事のコードに入れています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開き、アニメーションを一時停止して時刻を指定し、スクリーンショットの画素の色や getComputedStyle() の値を読みました。Firefox と Safari、スマートフォンのブラウザ、アニメーション中の描画の負荷は試していません。

コード全体
CSS は次のとおりです。
/* --angle を「角度」として登録する。登録すると、アニメーションで角度が少しずつ変わる */
@property --angle {
syntax: "<angle>";
inherits: true; /* ::before と ::after にも同じ角度を渡す */
initial-value: 0deg;
}
.glow-button {
position: relative;
isolation: isolate; /* ::before と ::after を、ボタンの中だけで重ねる */
padding: 14px 32px;
border: none;
border-radius: 999px;
background: none;
color: #1f2a30;
font-size: 18px;
cursor: pointer;
animation: border-spin 3s linear infinite;
}
.glow-button::before,
.glow-button::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
}
/* 光:枠と同じグラデーションをぼかして、ボタンの外ににじませる */
.glow-button::before {
background: conic-gradient(from var(--angle), #d4eef4 0% 60%, #00c8e8 85%, #d85878);
filter: blur(10px);
opacity: 0.6;
transition: opacity 0.2s;
}
/* 枠:border-box に回るグラデーション、padding-box に白を敷く */
.glow-button::after {
border: 3px solid transparent;
background:
linear-gradient(#fff, #fff) padding-box,
conic-gradient(from var(--angle), #d4eef4 0% 60%, #00c8e8 85%, #d85878) border-box;
}
/* ポインターを乗せたときと、キーボードで選んだときは強く光らせる */
.glow-button:hover::before,
.glow-button:focus-visible::before {
opacity: 1;
}
@keyframes border-spin {
to {
--angle: 360deg;
}
}
/* 動きを減らす設定の人には、回さない */
@media (prefers-reduced-motion: reduce) {
.glow-button {
animation: none;
}
}
HTML は、ふつうのボタンにクラスを付けるだけです。
<button class="glow-button">送信する</button>
ボタンそのものには枠も背景も付けず、2つの疑似要素を後ろに重ねています。::after が枠と中の白い塗り、::before がその外ににじむ光です。
枠は、白い背景と回るグラデーションの2枚で描く
::after には、背景を2枚重ねています。上の1枚は白の linear-gradient(#fff, #fff) で、padding-box を指定して枠線の内側にだけ描きます。下の1枚は conic-gradient() で、border-box を指定して枠線の下まで描きます。枠線は 3px solid transparent で透明にしてあるので、枠線の部分には下のグラデーションが見えます。background の説明にあるとおり、カンマで区切って並べた背景は、先に書いたものがいちばん上に重なります。padding-box と border-box で描く範囲が決まることは、background-clip の説明に書かれています。
conic-gradient() は、中心のまわりを時計回りに色が変わるグラデーションです。始まりは12時の方向で、from に角度を書くと、その分だけ時計回りにずれます。この記事のグラデーションは、一周の60%までが薄い水色で、85% の位置で水色、終わりでピンクになります。終わりのピンクと始まりの薄い水色がとなり合うので、ピンクの先頭からうしろへ水色の尾が伸びた、光の帯に見えます。
@property で登録しないと、枠は回らない
@keyframes で --angle を 0deg から 360deg まで動かしても、@property で登録していないと枠は回りません。上の CSS から、先頭のコメントと @property のルール(合わせて6行)を消したものと比べました。
アニメーションを止めて時刻を指定し、--angle の計算値と、枠の上下左右の4点の色を読みました。@property があると、--angle は 0.75秒ごとに 0deg、90deg、180deg、270deg と増えました。水色(#56d8ed など)が見える位置は、左、上、右、下と時計回りに移っています。
| 時刻 | @property あり |
@property なし |
|---|---|---|
| 0秒 | 0deg、左が水色 |
値なし、4点とも白 |
| 0.75秒 | 90deg、上が水色 |
値なし、4点とも白 |
| 1.5秒 | 180deg、右が水色 |
360deg、左が水色 |
| 2.25秒 | 270deg、下が水色 |
360deg、左が水色 |
@property がないと、0秒と 0.75秒では --angle に値がなく、conic-gradient(from var(--angle), …) の背景が描かれずに、枠が消えました(4点とも白)。1.5秒と 2.25秒では 360deg で、4点の色は同じでした。360deg は一周なので、0deg のときと同じ見た目です。2周目も同じで、3秒と 3.75秒では枠が消え、4.5秒と 5.25秒では 360deg で止まった枠が見えました。枠は回らずに、1.5秒ごとに消えたり現れたりします。
syntax: "<angle>" で角度だと登録すると、0deg と 360deg のあいだの値が計算され、表のとおり少しずつ回ります。initial-value は、syntax が "*" 以外なら必須です。書かないと @property のルール全体が無効になると、MDN の @property のページに書かれています。
inherits: false にすると、疑似要素の枠と光が止まる
MDN の @property のページにある、カスタムプロパティをアニメーションさせる例では inherits: false ですが、この作り方では inherits: true にします。アニメーションを付けているのはボタン(.glow-button)で、枠と光を描いているのは ::before と ::after だからです。
inherits: false に変えて同じように測ると、ボタンの --angle は 90deg、180deg、270deg と増えましたが、::before と ::after の --angle は、どの時刻でも初期値の 0deg のままでした。枠の4点の色も変わらず、枠は止まって見えます。inherits の説明にあるとおり、false にしたカスタムプロパティは、既定では値を受け継ぎません。測った値のとおり、ボタンで動いている角度が ::before と ::after に届かなくなります。
inherits: true にすると、3つとも同じ角度になりました(0.75秒で、ボタン、::before、::after のどれも 90deg)。
背景色のある場所で枠が消えるときは isolation: isolate
疑似要素には z-index: -1 を付けて、ボタンの文字の後ろに置いています。ただし、ボタンに isolation: isolate がないと、背景色を付けた div(重ね合わせコンテキストを作るプロパティを付けていないもの)の中では、疑似要素がその背景の後ろに隠れてしまいます。

薄い水色(#eefafd)の背景の中にボタンを置いて、1.5秒の時点を撮りました。isolation がないと、枠の右端の色も、ボタンの中の余白の色も、まわりと同じ #eefafd でした。ボタンの外側 20px の範囲で、まわりと色が違う画素は 0 個で、光も見えません。isolation: isolate を付けると、枠の右端は水色の #5ad8ed、中は白の #ffffff になり、外側の光の画素は 3,264 個になりました。
isolation: isolate は、要素に新しい重ね合わせコンテキストを必ず作らせる指定です。MDN の重ね合わせコンテキストの説明によると、子の z-index は、親の重ね合わせコンテキストの中でだけ意味を持ちます。ボタンが重ね合わせコンテキストを作ると、z-index: -1 の疑似要素はボタンの中で文字の後ろに置かれ、ボタンの外の背景より後ろには回りません。
ポインターを乗せたときとキーボードで選んだときに、光を強くする
光の ::before は、ふだん opacity: 0.6 にしていて、:hover と :focus-visible のときに 1 にします。1.5秒の時点で、白い背景の上で比べました。ボタンの外側 20px の範囲で白でない画素は、ポインターを乗せていないときの 3,811 個から、ポインターを乗せると 4,863 個に増えました。ボタンの右端から 6px 外の色は、#eaf9fc から #dcf6fb に濃くなっています。
Tab キーでボタンを選んだときも :focus-visible が当てはまり、::before の opacity は 1 になりました。キーボードで操作するときも、選んでいるボタンの光が強くなります。
動きを減らす設定では回さない
OS で動きを減らす設定にしている人には、prefers-reduced-motion のメディアクエリで、アニメーションを止めます。Playwright で reducedMotion: 'reduce' にして開くと、animation-name は none になり、1秒たっても --angle は 0deg のままでした。設定がない(no-preference)ときは、1秒後に --angle が約 120deg まで進んでいました。
対応しているブラウザ
MDN の互換性データ(@mdn/browser-compat-data 8.1.8)では、@property は Chrome 85、Firefox 128、Safari 16.4 から使えます。conic-gradient() は Chrome 69、Firefox 83、Safari 12.1 から、isolation は Chrome 41、Firefox 36、Safari 8 からです。疑似要素の位置に使った inset は Chrome 87、Firefox 66、Safari 14.1 から、:focus-visible は Chrome 86、Firefox 85、Safari 15.4 からです。ここに挙げた5つのうち、いちばん新しく対応したのは、Chrome では inset(87)、Firefox と Safari では @property(128 と 16.4)です。





