CSSで、マウスを乗せるとカードが浮き上がる動きを付ける方法

​​CSS​HTML,Playwright

マウスを乗せるとカードが浮き上がる動きは、:hover のときに transform でカードを数px上げ、box-shadow の影を下に広げると作れます。上げるのは margin ではなく transform にすると、周りの要素が動きません。ただし、カードそのものを上げると、下の端の近くでマウスを動かしたときに、上がっては戻る動きを繰り返します。リンクの a は動かさず、中の div だけを上げる書き方も紹介します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS だけのページを Playwright で開いてマウスとキーボードで操作し、getComputedStyle() と getBoundingClientRect() の値や、click が届いた要素を記録しました。タッチの端末と動きを減らす設定は、Playwright のエミュレーションで試しています。Firefox と Safari、スマートフォンの実機は試していません。

縦に並んだ3枚のカードに、上から順にマウスを乗せる。乗せたカードが少し上がって影が濃くなり、下の「持ち上がった距離」が 6px になった瞬間だけピンクになる。マウスを外すとカードは元に戻る
中の div を持ち上げる書き方のカード3枚に、順にマウスを乗せたときの動き。浮き上がる動きは、見やすいように実際の2倍の時間をかけて再生している(上の表示、マウスの絵、下の距離の表示は GIF 用に足したもの)

:hover で transform と box-shadow を変える

まず、リンクの a 要素1つだけで作る形です。

html
<a class="card" href="/articles/1">1つの要素のカード</a>
css
.card {
  display: block;
  padding: 20px;
  border-radius: 12px;
  color: #1f2a30;
  text-decoration: none;
  background: #fff;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.15); /* ふだんの、低い影 */
  transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}

.card:hover,
.card:focus-visible {
  transform: translateY(-6px); /* 上に 6px 持ち上げる */
  box-shadow: 0 12px 24px rgb(0 0 0 / 0.18); /* 影を下に広げ、浮いて見せる */
}

:hover は、マウスのボタンを押さずにポインターを要素の上に置いたときに当てはまる擬似クラスです。当てはまっている間は、transform の translateY(-6px) でカードを上に 6px ずらします。同時に、box-shadow の影を、縦のずらし 1px・ぼかし 3px から、縦のずらし 12px・ぼかし 24px に変えます。カードが上がり、影が下に大きく広がるので、机から浮いたように見えます。

Chromium でカードにマウスを乗せ、値を読み取った結果です。

乗せる前 乗せている間 外した後
:hover 当てはまらない 当てはまる 当てはまらない
上への移動 なし 6px なし
影(縦のずらし/ぼかし) 1px/3px 12px/24px 1px/3px
カードの上の端 24px 18px 24px

transition で、transform と box-shadow を 200ms かけて変えています。transition を一時停止し、時間を 40ms ずつ進めながら値を読み取りました。

経過時間 上への移動 影の縦のずらし 影のぼかし
0ms 0px 1px 3px
40ms 1.85px 4.39px 9.48px
80ms 3.43px 7.28px 14.99px
120ms 4.71px 9.64px 19.49px
160ms 5.63px 11.31px 22.69px
200ms 6px 12px 24px

ease-out なので、はじめに速く上がり、終わりに近づくほどゆっくりになります。影も、カードの動きと同じ ease-out の進み方で広がっていきます。

上げるのは margin ではなく transform にする

カードを上げるだけなら、:hover で margin-top: -6px を付ける書き方もできます。2つの書き方で、カードのすぐ下に置いた段落の位置を比べました。表の数字は、乗せる前 → 乗せている間の位置です。

カードの上の端 下の段落の上の端
margin-top で上げる 224px → 218px 296px → 290px
transform で上げる 344px → 338px 416px → 416px(動かない)

どちらもカードは 6px 上がりますが、margin-top では下の段落も一緒に 6px 上がりました。margin はレイアウトそのものを変えるので、マウスを乗せるたびに後ろの要素まで動きます。transform では、段落は動きませんでした。

カードの下の端でマウスを動かすと、上がっては戻るのを繰り返す

カードそのものを上げる書き方には、落とし穴があります。カードの下の端にマウスがあると、カードが上がったせいで、マウスの位置がカードの外に出てしまいます。

カードの下の端から 2px 上の点にマウスを置き、「マウスを右へ 1px 動かす → 持ち上がった距離を読み取る → 50ms 待つ」を21回繰り返しました。1回あたり、実際には約 55〜70ms かかっています。比べているのは、カードごと上げる書き方と、次の節で紹介する、中の div だけを上げる書き方です。

折れ線グラフ。横軸は最初にマウスを動かしてからの時間(0〜1500ms)、縦軸はカードの持ち上がった距離(0〜6px)。中の div を持ち上げる書き方(水色)は約 200ms で 6px に達してそのまま止まる。カードごと持ち上げる書き方(灰色)は、約 3〜4px まで上がっては 0.4〜1.5px まで戻るのを、のこぎりの歯のように繰り返す
下の端から 2px 上の点で、マウスを右へ 1px ずつ動かしながら読み取った、カードの持ち上がった距離

カードごと上げる書き方では、マウスを動かすたびに、:hover が当てはまる・外れるを繰り返しました。時間は、最初にマウスを動かしてからの経過時間です。

  • 2ms::hover が当てはまる(持ち上がった距離 0px)
  • 69ms::hover が外れる(2.93px まで上がっていた)
  • 136ms::hover が当てはまる(0.42px まで戻っていた)
  • 202ms::hover が外れる(3.32px)
  • 以降も同じ繰り返しで、最後の 1336ms の時点まで、1.49px と 4.27px の間を行き来していた

カードが上がると、マウスのある点はカードの外になります。マウスを動かすと、:hover が外れてカードが下がり始め、マウスのある点がまたカードの中に入ります。次にマウスを動かすと :hover が当てはまってまた上がる、という繰り返しです。カードは、マウスを動かしている間ずっと、上下に小刻みに動き続けます。中の div だけを上げる書き方では、:hover は当てはまったままで、207ms の時点で 6px まで上がり、そこで止まりました。

マウスを動かさずに置いたままにすると、揺れは起きません。1.5秒の間ずっと :hover は当てはまったままで、カードも 6px 上がったままでした。ところが、マウスの真下にある要素を document.elementFromPoint() で調べると、カードが上がり始めてすぐ(64ms、2.27px 上がった時点)から、カードではなく、カードを包む外側の div になっていました。そのままクリックすると、click はカードの a ではなく外側の div に届き、リンクとして押されませんでした。中の div だけを上げる書き方では、同じ点でクリックした click はカードの a に届いています。

a は動かさず、中の div を持ち上げる

マウスの当たる範囲を動かさないようにするには、a 自体は動かさず、見た目のカードを中の div に描いて、その div だけを上げます。

html
<a class="card2" href="/articles/1">
  <div class="card2__body">中の div を持ち上げるカード</div>
</a>
css
.card2 {
  display: block;
  border-radius: 12px;
  color: #1f2a30;
  text-decoration: none;
}

.card2__body {
  padding: 20px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.15);
  transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}

@media (hover: hover) {
  .card2:hover .card2__body {
    transform: translateY(-6px);
    box-shadow: 0 12px 24px rgb(0 0 0 / 0.18);
  }
}

.card2:focus-visible {
  outline: none;
}

.card2:focus-visible .card2__body {
  transform: translateY(-6px);
  box-shadow: 0 12px 24px rgb(0 0 0 / 0.18);
  outline: 3px solid #067a98;
  outline-offset: 2px;
}

/* 動きを減らす設定のときは、持ち上げずに影だけを変える */
@media (prefers-reduced-motion: reduce) {
  .card2:hover .card2__body,
  .card2:focus-visible .card2__body {
    transform: none;
  }
}

:hover を見るのは外側の a、動かすのは中の div です。マウスを乗せている間、a の範囲は 112px〜200px のまま動かず、中の div だけが 106px〜194px に上がりました。下の端の 6px は div から外れますが、a の範囲には残っているので、:hover は外れません。

上がった div の上の部分(a の上の端より 3px 上)も調べました。その点の真下にある要素は、カードの a の中の要素でした。マウスをその点に動かしても :hover は当てはまったままで、クリックすると click はカードの a に届きました。div は a の子なので、a の元の範囲からはみ出した部分にマウスがあっても、a の上にあることになります。

@media (hover: hover) と prefers-reduced-motion の部分は、後の2つの節で説明します。

キーボードで選んだときも浮かせる

:hover はポインターを乗せたときにしか当てはまらないので、キーボードで操作する人には、どのカードを選んでいるかが伝わりません。そこで :focus-visible にも同じ指定をしています。:focus-visible は、フォーカスを目に見える形で示すべきだとブラウザが判断したときに当てはまる擬似クラスです。

ページを開いて Tab キーを押し、値を読み取りました。

:focus-visible 上への移動
Tab キーで1枚目(1つの要素のカード)を選んだとき 当てはまる 6px
Tab キーで2枚目(中の div を持ち上げるカード)を選んだとき 当てはまる 6px
2枚目をマウスでクリックし、マウスを外に出したとき 当てはまらない なし

Tab キーで選んだときは :focus-visible が当てはまり、カードが上がりました。マウスでクリックしたときも、フォーカスはそのカードに移っていました。それでも :focus-visible は当てはまらず、マウスを外に出すとカードは元の位置に戻りました。マウスで押した人には、乗せている間だけ浮く動きになります。

中の div を上げる書き方では、フォーカスの枠線も、浮いたカードそのものに付けたいところです。そこで a の outline を消し、代わりに中の div に outline を付けています。Tab キーで2枚目を選んだときは、中の div に 3px の水色の実線の枠が付いていました。

タッチの端末では、@media (hover: hover) で浮かせない

タッチの画面では、マウスのように「乗せるだけ」の操作ができません。MDN の hover メディア特性の説明には、ホバーの操作ができない、または簡単にはできない端末で hover: none になるとあります。多くのモバイル機器は、ロングタップで擬似的にホバーを実現するとも書かれています。そこで、浮かせる指定を @media (hover: hover) の中に書き、ホバーが簡単にできる端末だけに当てています。

Playwright でタッチの端末(hasTouch と isMobile)をエミュレーションし、確かめました。

  • (hover: hover):当てはまらない
  • (hover: none):当てはまる
  • (pointer: coarse):当てはまる

この状態で、中の div を持ち上げるカードをタップすると、click はカードに届きました。タップの後は、別の場所をタップするまで :hover が当てはまっていました。しかし @media (hover: hover) が当てはまらないので、カードは上がらず、影も変わりませんでした。

比べるため、@media (hover: hover) で囲んでいない、1つの要素のカードもタップしました。こちらは、タップの後に :hover が当てはまっておらず、カードも上がっていませんでした。タップする順番を入れ替えても、結果は同じでした。

2枚の違いを調べるため、:hover で変える内容だけが違うリンクを用意し、1つずつタップしました。この検証環境では、:hover で transform を変えるリンクだけ、タップの直後に mouseout イベントが起き、:hover が外れました。影だけ、または文字の色だけを変えるリンクでは :hover が当てはまったままで、影だけを変えるリンクは影も広がったままでした。1つの要素のカードで :hover が外れたのも、:hover で transform を変えていたからです。このカードの :hover から transform だけを打ち消してタップすると、:hover は当てはまったままで、影も広がっていました。影や文字の色の変化はタップの後も残るので、@media (hover: hover) で囲んでおくと、タップした後に影が広がったままになりません。

動きを減らす設定では、影だけを変える

OS で動きを減らす設定をオンにしている人には、prefers-reduced-motion の reduce が当てはまります。中の div を持ち上げる書き方では、このとき transform を none に戻し、影の変化だけを残しています。

Playwright の emulateMedia({ reducedMotion: 'reduce' }) で、この設定をオンにしてマウスを乗せた結果です。

:hover 上への移動 影(縦のずらし/ぼかし)
中の div を持ち上げるカード 当てはまる なし 12px/24px
1つの要素のカード(設定に合わせていない CSS) 当てはまる 6px 12px/24px

設定に合わせた CSS では、カードは動かず、影だけが広がりました。マウスを乗せたことは影の変化で伝わり、カードが上下に動くことはありません。設定に合わせていない1つの要素のカードは、設定をオンにしていても 6px 上がりました。

​Posted by 86