CSS だけで Astro のボタンをホバー時に浮かせて光らせる — グラスモーフィズムから取り入れたもの

静的な Astro ブログで共通に使える CSS のホバー効果を作りました。浮き上がり、アクセントカラーの光の帯、枠線とグローに加え、Astro のスコープ付きスタイルと Pagefind の詳細度の落とし穴も扱います。

黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ
目次

はじめに

私はグラスモーフィズムのコンポーネントが好きです。ほかのサイトでよく見かけるカードは、ポインターを乗せると少し浮き上がり、光の帯が表面を横切り、縁が光ります。同じ感触をこのブログのボタンとピル型のタグにも付けたいと思いました。

最初に気になったのは、ブラウザーで JavaScript を動かす必要があるかどうかでした。このブログのページは、すべて事前にビルドした静的 HTML だからです。必要ありませんでした。効果はすべてブラウザーが描画時に適用する CSS なので、ページは以前とまったく同じように静的なままです。

実際に作ってみると、私が気に入っていたのはガラスの質感ではなく、浮き上がり、光の帯、アクセントカラーの枠線、グローといった効果だったこともわかりました。ガラスのぼかしは背景に何かがあって初めて見えるもので、このブログは無地の背景なので、最終的に取り入れませんでした。浮き上がり、光の帯、アクセントカラーの枠線、グローの 4 つは、1 つの共通クラスとして、サイト内のボタン、ピル型のタグ、拡大表示できる図版で動いています。

グラスモーフィズムとは

グラスモーフィズムは、パネルをすりガラスのように見せる UI スタイルです。主に 3 つの CSS プロパティで成り立っています。

  1. rgba(255, 255, 255, 0.1) のような半透明の塗り。パネルの背後にあるものが透けて見えます。
  2. backdrop-filter: blur(10px)。要素そのものではなく、要素の背後にあるページの部分をぼかします。
  3. 薄く明るい枠線。ぼかした背景の上でも、パネルの縁を見分けられるようにします。

グラスモーフィズムのパネルの見た目は、背後に何があるかで決まります。このスタイルは、鮮やかなグラデーションや写真の上で見せるのが普通です。ぼかしには、にじませる形や色が必要だからです。背後にグラデーションや写真がなければ、すりガラスのぼかしは見えません。このブログにはガラスのぼかしを見せられる背景がないので、取り入れませんでした。

暗いグラデーションのページに置いたグラスモーフィズムのカード。背景がぼけて透けて見え、ポインターを乗せるとカードが浮き上がって縁が明るくなります

気に入った効果はガラスがなくても再現できる

あのカードのどこが好きなのかを書き出してみると、すりガラスに関わるものは 1 つもありませんでした。カードは数ピクセル浮き上がり、ハイライトが表面を横切り、枠線が光ってその下にやわらかなグローが出ます。浮き上がりは transform、ハイライトは動くグラデーション、枠線とグローは border-color と box-shadow で作れるので、どれも backdrop-filter を必要としません。

このブログのページ背景は、サイトの基本となる面の色 --bg-1 の単色です(ライトモードでは #ffffff、ダークモードでは #0b1120)。単色をぼかしても同じ色にしかなりません。このブログでガラスのパネルを作っても、ただ色の付いた箱として描画されます。

ぼかしの材料を用意するためだけに、記事の背後へグラデーションを置くことも避けたいと考えました。長文は単色の面のほうが読みやすいからです。

4 つの効果をまとめた共通クラス:.hover-glint

浮き上がり、光の帯、アクセントカラーの枠線、グローの 4 つのホバー効果を、1 つのグローバルクラス .hover-glint にまとめ、4 つのカスタムプロパティで制御しています。そのカスタムプロパティの色はすべて --accent から作っています。--accent は、このブログがクリックできるテキストに使っているリンクの青です(ライトモードでは #1d4ed8、ダークモードでは #60a5fa)。

--accent は light-dark() で定義しています。light-dark() は、ライトのカラースキームでは 1 つ目の値を、ダークのカラースキームでは 2 つ目の値を選びます。そのため、グローと帯はカスタムプロパティを追加しなくてもテーマに追従します。

src/styles/global.css
:root {
--hover-lift: -2px;
--hover-duration: 200ms;
--hover-glow: 0 6px 16px color-mix(in srgb, var(--accent) 30%, transparent);
--hover-sheen: linear-gradient(
100deg,
transparent 40%,
color-mix(in srgb, var(--accent) 15%, transparent) 50%,
transparent 60%
);
}

color-mix(in srgb, var(--accent) 30%, transparent) は、不透明度 30% のアクセントカラーです。新しい色ではなくリンクの青を選んだのは、この青がすでにクリックできるものを示しているからです。同じ青でホバー時のグローを出せば、同じ意味が伝わります。

:focus-visible 以外のルールは、2 つのメディアクエリの中に置いています。

src/styles/global.css
.hover-glint:focus-visible {
border-color: var(--accent);
box-shadow: var(--hover-glow);
}
@media (hover: hover) {
.hover-glint:hover {
border-color: var(--accent);
box-shadow: var(--hover-glow);
}
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.hover-glint {
transition:
transform var(--hover-duration) ease,
box-shadow var(--hover-duration) ease,
border-color var(--hover-duration) ease,
background-position 500ms ease;
}
.hover-glint:hover {
transform: translateY(var(--hover-lift));
background-position: 0 0;
}
.hover-glint:active {
transform: none;
}
}
  • (hover: hover) は hover というメディア特性を調べます。使っている端末がポインターを要素の上に置けるかどうか、という特性です。

    • hover は置ける、という意味です。マウスや、ノートパソコンのキーボードの下にあるタッチパッドがこれにあたります。
    • none は置けない、という意味です。スマートフォンやタブレットのタッチスクリーンがこれにあたります。

    そのため (hover: hover) はパソコンでだけ有効になり、スマートフォンやタブレットはこのルールを無視します。こう分けているのは、タッチスクリーンではタップした後も別の場所をタップするまでホバーの効果が残ることがあり、読者が指を離した後もボタンが浮いたままになるからです。

  • prefers-reduced-motion: no-preference は prefers-reduced-motion というメディア特性を調べます。読者が OS で「視差効果を減らす」をオンにしているかどうか、という特性です。

    • reduce はオン、という意味です。macOS や iOS のアクセシビリティ設定で「視差効果を減らす」をオンにした場合などです。
    • no-preference はオフ、という意味です。

    そのため、「視差効果を減らす」をオンにしていない読者には浮き上がりと光の帯が表示されます。この設定をオンにする読者は、画面上の動きでめまいや気分の悪さを感じることが多いので、アクセントカラーの枠線とグローだけが表示され、ボタンが浮き上がったり光の帯が横切ったりすることはありません。

  • :focus-visible は、キーボードで操作する読者に、動きを伴わない枠線とグローを表示します。上の 2 つのメディアクエリはどちらも (hover: hover) を条件にしていて、キーボードをつないだタブレットはこの条件に合わないため、中のルールは適用されません。そのため :focus-visible はメディアクエリの外に置いています。

この効果を適用する要素は 1 つずつ選びました。

  • ピル型のタグ
  • もっと見る
  • いいね・共有・リンクコピーの各ボタン
  • フッターのソーシャルアイコン
  • ヘッダーの検索ボタン
  • オフラインページの「再読み込み」ボタン
  • 拡大表示を開く図版

モバイルの目次カードは対象から外しました。このカードが想定しているスマートフォンでは、ホバーが発生しないからです。検索結果の行には、枠線とグローだけを付けています。項目が密に並ぶリストで行がポインターの下で動くと、邪魔にしかならないからです。

なぜ白い帯を使わないのか

一般的なガラスのカードでは、ハイライトは不透明度の低い白い帯です。暗いパネルや彩度の高いパネルの上なら、この白い帯はよく見えます。このブログのライトモードではカードが #ffffff の上にあるので、白い帯は見えないまま横切ります。

そこで帯は、color-mix() で作った不透明度 15% のアクセントカラーにしました。この帯は白の上でも見え、ダークモードでもちゃんと光って見えるうえ、隣のグローとも色がそろいます。

ライトモードでホバーした Load more ボタン。青い枠線、その下のやわらかな青いグロー、左端から入ってくる薄い青の帯が見える

ライトモードでホバーした Load more。アクセントカラーの帯は白の上でも見えます

ダークモードでホバーした Astro のタグ。黒に近い背景の上に、明るい青の枠線とその下の青いグローが見える

ダークモードでホバーした Astro のタグ。アクセントカラーは #60a5fa です。

帯の移動を背景レイヤーで作る

帯を横切らせる一般的な方法は、::before 疑似要素(CSS が要素の中に追加で作る箱)にグラデーションを持たせて transform で横に動かし、ボタン自体に overflow: hidden を指定して、ボタンからはみ出したグラデーションを切り取るものです。このブログではこの方法が使えません。記事横の共有レールにあるいいねボタンと共有ボタンは、ボタンの外にはみ出すツールチップで名前を表示していて、overflow: hidden はそのツールチップも一緒に切り取ってしまうからです。

切り取られた ::before の帯と、同じ切り取りがツールチップに与える影響2 つのパネル。左は、::before 疑似要素による斜めのグラデーションの帯を持つピル型のボタン。overflow: hidden がボタンの外をすべて切り取るので、帯のボタン内の部分は実線、外の部分は薄い破線で描かれている。右は、共有レールの四角いボタンと、その右側にはみ出した「いいね」のツールチップ。同じ overflow: hidden で切り取られるため、ツールチップは赤い薄い破線で描かれ、「切り取られる」と示されている。一般的な帯の作り方このブログの共有レールボタン::before のグラデーションoverflow: hidden がボタンの外をすべて切り取る♥いいね切り取られるいいねボタンの「いいね」ツールチップはボタンの中の要素で、ボタンの外に描かれているだけなので、overflow: hidden で一緒に切り取られる
overflow: hidden で ::before の帯を切り取っても、ボタン自体は問題なく見えます。しかし、ポインターがボタンに入るとボタンの外側にツールチップが表示され、そのツールチップもボタンの中の要素なので一緒に切り取られます

この問題を避けるため、帯はボタン自体の背景画像に描きました。画像の幅はボタンの 3 倍で、帯は中央の 3 分の 1 にあります。background-position は、背景画像のどの位置から表示するかを決めます。通常は 100% 0 で、ボタンには背景画像の右の 3 分の 1 が表示されます。ポインターが入ると 0 0 へ少しずつ移り、帯が左から右へ横切ります。ポインターが離れると 100% 0 へ少しずつ戻り、帯は右から左へ戻ります。背景は border-radius にも自動で沿うので、ピル型でも追加の対応はいりません。

src/styles/global.css
.hover-glint {
background-image: var(--hover-sheen);
background-size: 300% 100%;
background-repeat: no-repeat;
background-position: 100% 0;
}
ボタンは動かず、背景画像がその下を動く2 段の図。各段で、動かないピル型のボタンが、ボタンの 3 倍の幅を持つ背景画像の上にある。背景画像は 3 等分され、中央の 3 分の 1 に薄く現れて消えるアクセントカラーの帯がある。上段は background-position 100% 0 の通常時で、背景画像は左に寄り、ボタンには右の 3 分の 1 が表示され、帯はボタンの左にある。下段はポインターが入って background-position 0 0 になったときで、背景画像は右へ動き、ボタンには左の 3 分の 1 が表示され、帯はボタンの右にある。2 段の間の矢印は背景が 500ms で右へ動くことを示し、その動きで帯がボタンを左から右へ横切る。通常時: background-position: 100% 0帯ボタン(動かない)ポインターが入ったとき: background-position: 0 0帯ボタン(動かない)背景が 500ms で右へ動く背景画像(ボタンの 3 倍の幅)
ボタン自体は動かず、動くのはその下の背景画像です。100% 0 ではボタンに背景画像の右の 3 分の 1 が表示され、帯はボタンの左にあります。背景画像が右へ動いて 0 0 になると、ボタンには左の 3 分の 1 が表示され、帯はボタンを横切って右側に出ています

帯を背景画像に描くと、この効果を適用するコンポーネントはすべて 1 つのルールを守る必要があります。background-color だけを指定し、background は使いません。コンポーネント自身のルールはグローバルクラスを上書きするので、ピル型のタグに background: var(--bg-2) を書くと、気づかないうちに帯が消えてしまいます。今回この効果を適用したコンポーネントでは、すべて background を background-color に書き換えました。

枠線の色を決めるルール:Astro のスコープ付きスタイル

Astro は、コンポーネントの <style> に書いたルールをそのコンポーネントだけに適用します。これをスコープ付きスタイルと呼びます。Astro はセレクタのすべての部分に data-astro-cid-* 属性を付けるので、たとえば .social a は .social[data-astro-cid-…] a[data-astro-cid-…] になります。属性が 1 つ増えるごとに詳細度が上がるので、コンポーネントのルールは、グローバルな .hover-glint:hover や .hover-glint:focus-visible に勝つか同点になることが多くなります。

ビルド後の CSS を見ると、border-color について各コンポーネントの基本ルールとグローバルクラスのどちらが勝つかがわかります。

結果例詳細度この分類に入るコンポーネント
グローバルクラスが勝つa[data-astro-cid-…](0,1,1)ピル型のタグ、「再読み込み」ボタン
同点で、スタイルシートの読み込み順で決まる.load-more-button[data-astro-cid-…](0,2,0)もっと見る、図版の枠
コンポーネントが勝つ.social[data-astro-cid-…] a[data-astro-cid-…](0,3,1)検索ボタン、いいね・共有ボタン、フッターのアイコン

いいね・共有ボタンのように詳細度で勝つか同点になるコンポーネントでは、ホバー時もキーボードフォーカス時も、グローバルクラスのアクセントカラーの枠線がコンポーネント側の設定で上書きされます。そこで、いいね・共有ボタンなどのコンポーネントでは、それぞれのルールの中でアクセントカラーの枠線を指定し、:focus-visible を :hover と並べて書きました。

src/components/SiteFooter.astro
.social a:hover,
.social a:focus-visible {
border-color: var(--accent);
color: var(--text-1);
background-color: var(--bg-1);
}

:hover だけを書くと、いいね・共有ボタンのように詳細度で勝つか同点になるコンポーネントでは、キーボードフォーカス時にグローは出ても枠線は元の色のままです。:focus-visible を加えると、フォーカス時に枠線もアクセントカラーになります。

4 重の :is(*, #\#) で Pagefind のスタイルを上書きする

このブログが使っている静的サイト向けの検索ライブラリ Pagefind は、Astro のコンポーネントよりもスタイルの上書きが難しくなっています。Pagefind のスタイルシートは、すべてのルールを :is(*, #\#) で 3 重に包んでいます。

Pagefind は :is() で 3 重に包むことで、自分のルールに ID 3 つ分の詳細度を持たせ、定義したスタイルが勝つようにしています。そこで同じ技で返し、4 重に包んで、検索結果の行のルールが ID 4 つ分の詳細度で Pagefind 本来のスタイルを上書きするようにしました。

src/styles/global.css
@media (hover: hover) {
:is(*, #\#):is(*, #\#):is(*, #\#):is(*, #\#) .pf-searchbox-result:hover {
box-shadow:
inset 0 0 0 1px var(--accent),
var(--hover-glow);
}
}

まとめ

このブログのピル型のタグ、主なボタン、拡大表示できる図版は、ポインターを乗せると 2px 浮き上がり、アクセントカラーの枠線とグローが付き、リンクの青の薄い帯が表面を横切るようになりました。この 4 つの効果は、すべて 1 つのグローバルクラスに書かれています。そのためサイトは完全に静的なままで、追加で読み込むものもありません。

Astro のサイトでこの効果を保つためのルールは 2 つです。

  1. この効果を適用するコンポーネントは background-color を指定し、background は使わない。background を使うと、一括指定プロパティが帯を消します。
  2. スコープ付きのルールの詳細度がクラス以上になるコンポーネントでは、:hover と :focus-visible の両方でアクセントカラーの枠線を自分で指定する。

参考リンク

この記事をシェア