ホバーで立体影を付けるアニメーション|CSS+JSで実装


ホバーで立体影を付けるとは、:hover のときに text-shadow または box-shadow の値を変化させ、要素が紙から浮き上がったように見える状態を作ることです。文字そのものを浮かせたい場合は text-shadow、ボタンやカードのような矩形を浮かせたい場合は box-shadow を使います。

この記事では、テキストリンクを対象にした「文字が浮き上がる立体影」を、コピーしてそのまま動く最小構成で解説します。結論から書くと、この演出に JavaScriptは要りません。CSSのプロパティ2つ(transitiontext-shadow)だけで完結します。

後半では、擬似要素とJavaScriptを使う書き方が実際のページで壊れる3つの条件を、Chromiumでの実測値つきで比較します。さらに「書いた影が本当に意図どおりに描かれているか」をDevToolsのComputed値で判定する手順まで扱います。


ホバーで立体影を付ける2つの手段

CSSで影を落とすプロパティは2つあり、影が付く対象が違います。テキストリンクに対して box-shadow を使うと、文字ではなくリンクの矩形(インラインボックス)に影が付いてしまうため、狙った見た目になりません。

プロパティ影が付く対象向いている場面
text-shadow文字のグリフの形テキストリンク・見出し・ロゴ文字
box-shadow要素のボーダーボックス(矩形)ボタン・カード・画像

この記事で扱うのは前者、text-shadow による文字の立体影です。ボタンやカードを浮かせる box-shadow 系や、傾き・グロー・スライドなど他系統のホバー演出をまとめて見比べたい場合は、ホバーエフェクト100種のギャラリーにコード付きで並べています。この記事は「立体影ひとつを掘り下げる」側の担当です。


最小構成のコード(HTML+CSSのみ)

まずHTMLです。リンクにクラスを1つ付けるだけで、追加のマークアップは要りません。

<a href="#" class="hover-effect">hover1</a>
<a href="#" class="hover-effect">hover2</a>
<a href="#" class="hover-effect">hover3</a>

CSSはこれで全部です。transition に「:hover で変えるプロパティを全部書く」のが唯一の注意点になります。

.hover-effect {
  font-size: 24px;
  text-decoration: none;
  color: #333;
  /* :hover で変える2つを両方ここに列挙する */
  transition: color .3s ease, text-shadow .3s ease;
}

.hover-effect:hover {
  color: #f00;
  /* X方向 -2px / Y方向 -0.3em / ぼかし 0 / 色 #999 */
  text-shadow: -2px -.3em 0 #999;
}

text-shadow は「X方向のずれ・Y方向のずれ・ぼかし半径・色」の順に値を取ります。ぼかし半径は省略でき、省略すると 0 と同じ扱いです。

役割動かすとどうなるか
-2pxX方向のずれ正の値で右、負の値で左に影が出る
-.3emY方向のずれ負の値で上に浮き、正の値で下に沈む
0ぼかし半径0で輪郭がくっきり、大きくすると柔らかい影になる
#999影の色文字色より薄い色にすると自然に見える

Y方向に px ではなく em を使っているのには理由があります。em は要素の font-size を基準に計算されるため、文字サイズが変わっても影の比率が崩れません。font-size: 24px の要素に -0.3em を指定した場合、実際に描画に使われる値は -7.2px です。見出しと本文で同じクラスを使い回すときに効いてきます。emrem の使い分けはCSSプロパティの基礎にまとめています。


JavaScriptを使う書き方と、それが不要な理由

この演出は、擬似要素にテキストの複製を作って背後に敷き、そちらに影を付けるという書き方でも実現できます。複製するテキストはHTMLに直接書けないため、JavaScriptでCSSカスタムプロパティに流し込みます。下は元になったCodePenのデモと、そのコードです。

差分チェックツールで効率UPお手本コードと自分のコードを比較して、違いを一目で確認できます。練習前にブックマークしておくと便利です。
Diff Checkerを開く →

See the Pen Untitled by masakazuimai (@masakazuimai) on CodePen.

/* 擬似要素+JS版(比較用。この記事では推奨しない) */
.old-effect {
  position: relative;
  font-size: 24px;
  text-decoration: none;
  color: #333;
  transition: color .3s ease;
}

.old-effect::before {
  content: var(--text);   /* JSで入れた文字列を複製する */
  position: absolute;
  left: 0;
  top: 0;
  z-index: -1;            /* 本体の裏に敷く */
  color: transparent;
  transition: text-shadow .3s ease;
}

.old-effect:hover { color: #f00; }
.old-effect:hover::before { text-shadow: -2px -.3em 0 #999; }
document.querySelectorAll('.old-effect').forEach(function (el) {
  el.style.setProperty('--text', '"' + el.textContent + '"');
});

この書き方が壊れる3つの条件

上のコードをHTMLに貼り、Chromiumで実際にホバーして計測した結果が次の表です。CodePenの中では動いて見えても、実サイトに移した途端に影が出なくなる条件が3つあります。

条件実測結果原因
リンク文字に " を含む(例: say "hi"影が出ない。擬似要素の contentnone になる--text の値が "say "hi"" となり、CSSの文字列として成立しない
HTMLを改行・インデントして書いた影が出ない。--text が空のまま contentnone になるtextContent に前後の改行と空白が入り、そのままでは文字列値にならない
祖先要素に background-color がある影が背景に隠れて見えなくなるz-index: -1 の擬似要素が、祖先の背景より先に描かれる

3つ目が特に厄介です。.old-effect には position: relative が付いていますが、z-indexauto のままなので、この要素は重ね合わせコンテキストを作りません。その結果、負の z-index を持つ擬似要素は祖先側の背景より下に潜り込みます。カード・記事本文・セクションなど、背景色が指定された箱の中に置いた瞬間に影が消える、という形で表面化します。

加えて、擬似要素の content はアクセシブル名の計算に含まれます。実測では、この書き方のリンクのアクセシブル名が hover1 hover1 と二重になりました。スクリーンリーダーはリンク名を2回読み上げることになります。

要素自身に影を付ければ複製は要らない

そもそも text-shadow は、その要素自身の文字の下に影を描くプロパティです(MDN: text-shadow)。つまり、透明な複製テキストを背後に敷く工程そのものが不要でした。冒頭のCSSのみ版と擬似要素版を同条件で計測すると、ホバー時の text-shadow は両方とも rgb(153, 153, 153) -2px -7.2px 0px で一致します。見た目は同じで、壊れる条件だけが消えます。

比較軸CSSのみ版擬似要素+JS版
ホバー時の text-shadowrgb(153, 153, 153) -2px -7.2px 0px同左(描画されれば同じ値)
引用符を含む文字影が出る影が出ない
改行インデントしたHTML影が出る影が出ない
祖先に background:#fff影が出る影が出ない
アクセシブル名hover1hover1 hover1
必要なJavaScriptなしあり(無効時は影も消える)

擬似要素そのものが悪いわけではなく、「文字の複製を裏に敷く」という手段が text-shadow に対しては過剰だった、というだけです。下線のスライドや枠線の伸長のように、文字とは別の図形を動かしたい場合は擬似要素が正解になります。擬似要素の指定方法はCSSセレクタ完全ガイドで確認できます。


実装した立体影が意図どおりか検証する

影は「マウスを乗せている間だけ」表示されるため、目視だけでは合否を判定しにくい部類の演出です。ブラウザのDevToolsには :hover を強制的にONにしたままComputed値を読む機能があるので、それを使って3点を確認します。

検証1: 影がホバー時だけ描かれているか

Elementsパネルで対象のリンクを選択し、Stylesパネル右上の「:hov」から :hover にチェックを入れます。この状態でComputedタブを開き、text-shadow の値を読みます。チェックのON/OFFを切り替えて、次の2行が両方とも成立していれば合格です。

観測箇所合格不合格
Computed text-shadow(:hov OFF)none値が入っている=常時表示になっている
Computed text-shadow(:hov ON)rgb(153, 153, 153) -2px -7.2px 0px のように色・X・Y・ぼかしの4値が入るnone のまま=セレクタが当たっていない

Y方向が -7.2px と表示されるのは、CSSに書いた -0.3emfont-size: 24px を基準に計算された結果です。Computedタブは常に計算後の絶対値を返すので、ここが -7.2px になっていれば em の基準が意図どおり効いていると分かります。見出し用に font-size: 40px を当てた要素で同じクラスを使えば、同じ指定のまま -12px に伸びます。文字サイズごとに影の値を書き分けなくてよい、という実務上の利点はここで確認できます。

検証2: 動かすプロパティがtransitionに含まれているか

同じくComputedタブで transition-property を開き、:hover で変えたプロパティが全部並んでいるかを見ます。ここに載っていないプロパティは0秒で切り替わる、つまり瞬間的にジャンプします。「なぜか一部だけカクつく」という症状の原因は、ほぼこれです。

観測箇所合格不合格
Computed transition-propertycolor, text-shadow のように、:hover で変えたプロパティが漏れなく並ぶcolor だけなど、変えたはずのプロパティが欠けている
Computed transition-durationプロパティの数だけ値が並ぶ(例: 0.3s, 0.3s値が1つしかない=2つ目以降に時間が割り当たっていない

実測で挙動を確かめました。transition: color .3s ease, text-shadow .3s ease のまま :hovertransform: scale(1.1) を足すと、ホバー開始から50ms後の時点で transform はすでに matrix(1.1, 0, 0, 1.1, 0, 0)、つまり変化が完了していました。同じ50ms時点の text-shadowrgba(153, 153, 153, 0.22) -0.439903px -1.58365px 0px で、まだ補間の途中です。片方だけが先に飛ぶため、拡大だけがカクついて見えます。transform も動かしたいなら transition の並びに追記するのが唯一の対処です。キーフレームを使った動きの制御はCSSキーフレームアニメーション入門にまとめています。

検証3: 実際のページに置いても影が消えないか

擬似要素と z-index: -1 で影を敷く書き方を採る場合だけ必要な確認です。Elementsパネルでリンクの祖先要素を順にたどり、それぞれのComputedタブで background-color を見ます。

観測箇所合格不合格
祖先要素すべてのComputed background-colorすべて rgba(0, 0, 0, 0)=透明どこかに不透明な色がある=負の z-index の影がその背景に隠れる

実測でも同じ結果になりました。親要素に background: #fff を付けただけで、擬似要素版はホバーしてもピクセルが1つも変化しませんでした。一方、要素自身に text-shadow をかけるCSSのみ版は、親の背景の有無にかかわらず影が描かれました。

実装親に背景なし親に background:#fff
CSSのみ版(要素自身に text-shadow影が出る影が出る
擬似要素+z-index:-1影が出る影が出ない

不合格だった場合の対処は1つで、擬似要素をやめて要素自身に text-shadow をかけることです。この記事の最小構成に戻せば、祖先の背景を調べる必要そのものがなくなります。


見た目を調整する4つのカスタマイズ

いずれも最小構成の .hover-effect:hover.hover-effect を差し替えるだけで、HTMLとJavaScriptには触りません。

影の色を変える

.hover-effect:hover {
  color: #f00;
  /* 半透明にすると下地の色に馴染む */
  text-shadow: -2px -.3em 0 rgba(0, 120, 255, .6);
}

影の色を不透明な原色にすると、背景色を変えたときに浮いて見えます。rgba() でアルファを0.5〜0.7程度に落としておくと、ダークテーマに切り替えても破綻しにくくなります。

影をぼかす

.hover-effect:hover {
  color: #f00;
  /* 3番目の値がぼかし半径 */
  text-shadow: -2px -.3em 6px #999;
}

ぼかし半径を上げるほど影の輪郭が拡散し、立体感より発光に近い印象になります。文字サイズが小さい本文リンクでは、半径を文字サイズの4分の1程度までに抑えると読みやすさを損ないません。発光そのものを狙う場合はネオンボタンジェネレーターで値を作ると早いです。

速度とイージングを変える

.hover-effect {
  font-size: 24px;
  text-decoration: none;
  color: #333;
  /* プロパティごとに時間とイージングを変えられる */
  transition:
    color .15s ease-out,
    text-shadow .45s cubic-bezier(.2, .8, .2, 1);
}

色は速く、影はゆっくり動かすと、影だけが遅れて追いついてくる見え方になります。ホバーは離脱も頻繁に起きるため、全体で0.5秒を超えると「反応が鈍い」と受け取られやすい点には注意します。

影を重ねて奥行きを出す

.hover-effect:hover {
  color: #f00;
  /* カンマ区切りで複数指定。先に書いたものほど手前に描かれる */
  text-shadow:
    -1px -.15em 0 #bbb,
    -2px -.3em  0 #999,
    -3px -.45em 0 #777;
}

ずれ幅を等間隔に増やし、同時に色を段階的に濃くすると、押し出し文字のような厚みが出ます。影の数だけ描画コストが増えるため、長文の本文リンクに一括で当てるのではなく、見出しやCTAなど数の限られた箇所に絞るのが無難です。傾きと影を組み合わせた例はニューモーフィズム風の傾きホバーでも扱っています。


font-weightを一緒に太くするときの注意点

ホバー時に影と一緒に文字を太くしたくなりますが、font-weight は他のプロパティと同じようには動きません。MDNの font-weight の形式的定義には Animation type として「by computed value type」と記載されており、アニメーション自体は可能です。ただし同ページには「非可変フォントで、指定された太さがそのフォントに存在しない場合はフォールバックのアルゴリズムが使われる」とも書かれています。

これがどう見えるかを実測しました。font-weight: 400 から 700 へ0.3秒で transition させ、50msごとにComputed値と要素の幅を記録した結果が次の表です。

経過時間Computed font-weight要素の実測幅
0ms40083.07px
50ms466.2583.07px
100ms572.7591.32px
150ms640.7591.32px
200ms681.591.32px
300ms70091.32px

Computed値は400から700まで連続的に変化しているのに、実際に描画された幅は83.07pxと91.32pxの2値しか取っていません。可変フォントでなければ中間の太さを持つ字形が存在しないため、0.3秒かけても見た目は途中で1回カクッと切り替わるだけになります。しかも切り替わった瞬間に文字幅が8pxほど伸び、後ろに続くテキストの位置が動きます。

したがって、立体影のホバーで太さを変えたいなら選択肢は2つです。可変フォントを読み込んで font-variation-settingswght 軸で動かすか、あるいは font-weight には触れず text-shadow の重なり枚数で厚みを演出するかです。レイアウトが動かないぶん、後者のほうが本文中のリンクには向いています。


よくある質問(FAQ)

Q. ホバーで立体影を付けるのにJavaScriptは必要ですか?

不要です。text-shadow は要素自身の文字の下に影を描くプロパティなので、透明な複製テキストを背後に敷く必要がありません。この記事の最小構成は :hover と transition だけで完結し、Chromiumでの実測でも擬似要素+JavaScript版と同じ rgb(153, 153, 153) -2px -7.2px 0px という値になりました。

Q. text-shadowとbox-shadowはどう使い分けますか?

文字そのものを浮かせたいときは text-shadow、ボタンやカードのような矩形を浮かせたいときは box-shadow を使います。text-shadow は文字のグリフの形に沿って影が出るため背景が透明でも成立し、box-shadow は要素のボーダーボックスの形に影が出ます。テキストリンクに box-shadow を当てると、文字ではなくリンクの矩形が浮きます。

Q. 影のY方向にpxではなくemを使う理由は何ですか?

em は要素の font-size を基準に計算されるため、文字サイズが変わっても影の比率が崩れないからです。font-size: 24px の要素に -0.3em を指定すると、DevToolsのComputed値は -7.2px になります。同じクラスを font-size: 40px の見出しで使い回せば、指定を変えないまま -12px に伸びます。

Q. ホバー時に一部のプロパティだけカクつくのはなぜですか?

transition-property にそのプロパティが含まれていないからです。含まれないプロパティは0秒で切り替わるため、他が補間されている間に単独で瞬間移動します。DevToolsのComputedタブで transition-property を開き、:hover で変えたプロパティが漏れなく並んでいるかを確認してください。

Q. 実際のページに貼ったら影が見えなくなりました。原因は何ですか?

擬似要素に z-index: -1 を指定して影を敷いている場合、祖先要素に不透明な background-color があると影がその背景の下に隠れます。実測でも、親に background: #fff を付けただけでホバー時のピクセルが一切変化しなくなりました。要素自身に text-shadow をかける書き方に変えれば、背景の有無に関係なく影が描かれます。

Q. スマートフォンではホバーが使えませんが、どう扱えばよいですか?

タップ中だけ反応させたい場合は :active を併記し、常に見せたい場合は :hover を使わず基本状態に text-shadow を書きます。いずれにしても、ホバーしないと読めない情報を影の中に入れないことが前提です。影は装飾に留め、リンクであることは色と下線で伝えるようにします。

Q. font-weightも一緒に太くしてよいですか?

可変フォント以外では中間の太さを持つ字形が存在せず、0.3秒かけても途中で1回だけ切り替わる見え方になります。実測ではComputed値が400から700へ連続的に変化する一方、要素の幅は83.07pxと91.32pxの2値しか取りませんでした。文字幅が変わると後続のテキストも動くため、立体影の演出では text-shadow だけを変えるほうが安定します。


まとめ

  • ホバーで文字を浮かせる立体影は、transitiontext-shadow の2プロパティだけで完成する。JavaScriptは不要。
  • 擬似要素にテキストを複製して z-index: -1 で敷く書き方は、引用符を含む文字・改行インデントしたHTML・祖先の背景色という3条件で影が出なくなる。アクセシブル名も二重になる。
  • 合否はDevToolsのComputed値で判定する。:hov:hover を固定し、text-shadow に4値が入るか、transition-property に動かすプロパティが全部載っているかを見る。
  • Y方向を em で書くと、文字サイズの違う場所で同じクラスを使い回しても影の比率が保たれる。
  • font-weight の同時変更は、可変フォントでなければ1回のカクつきとレイアウトのずれになる。厚みは影の重ね枚数で出すほうが安全。

影以外のホバー演出をまとめて比較したい場合はホバーエフェクト100種のギャラリー、CSSの各種エフェクトを値をいじりながら作りたい場合はCSSエフェクトジェネレーター一覧が使えます。