CSSで、枠線が回りながら光るボタンを作る方法

​​CSS​HTML,Playwright

枠線が回りながら光るボタンは、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、スマートフォンのブラウザ、アニメーション中の描画の負荷は試していません。

「送信する」と書かれた白い丸いボタンの枠を、水色からピンクに変わる光の帯が時計回りに回っている。途中でマウスポインターがボタンの上に来ると、下の文字が「ポインター:ボタンの上(光が強くなる)」に変わり、ボタンのまわりのにじんだ光が濃くなる
枠の光の帯が3秒で1周する。ポインターを乗せると、外側の光が強くなる

コード全体

CSS は次のとおりです。

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 は、ふつうのボタンにクラスを付けるだけです。

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(重ね合わせコンテキストを作るプロパティを付けていないもの)の中では、疑似要素がその背景の後ろに隠れてしまいます。

上下2段の比較。どちらも薄い水色の帯の中に「送信する」のボタンを置いている。上の「isolation なし(auto)」は、文字だけが見えて、枠も白い塗りも光もない。下の「isolation: isolate」は、白い丸いボタンに水色からピンクの光の帯が付いた枠と、外側のにじんだ光が見える
isolation がないと、枠も光も親の背景の後ろに隠れる(上)。isolate にすると見える(下)

薄い水色(#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)です。

​Posted by 86