GODRICH

一行でコピペOK ― CSS カード ホバー アニメーション9選

css カード ホバーとは、カード型のUIコンポーネントがマウスやフォーカスを受け取った瞬間だけ、浮く・光る・傾くといった短い変化を見せる仕掛けです。今回は9種類を選び、7つはCSSのtransformとtransitionだけで、残り2つはJavaScriptを10行前後添えて実装しました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は人気順ではなく、カードを選ぶ人の視線がたどる順番に合わせました。まず存在を主張する2つ(01・02)、次に傾きと内容で層を見せる3つ(03〜05)、表面の縁を変える1つ(07)、押せると伝える2つ(06・08)を経て、最後に実際に押された瞬間へ答える1つ(09)で終わります。コードは要点だけを載せていて、完成した9本のファイルはzipにまとめてあります。

01浮き上がり+影

カードにカーソルを重ねると8pxほど上へ浮き上がり、box-shadowで影が一段濃くなります。クリックする前からグリッドの中で真っ先に目に入るようになります。商品カードの一覧やブログ記事一覧のカードに向いています。

transform: translateYbox-shadow 확대transition
.card {
  transform: translateY(0);
  transition:
    transform .2s $ease-spring,
    box-shadow .2s $ease-spring;
}

.card:hover,
.card:focus-visible,
.card:active {
  transform: translateY(-8px);
  box-shadow: $shadow-raised;
}

02枠を光が回る

カードの枠の内側でconic-gradientの光が2秒に一度ぐるりと回り続け、触れる前から静かに動いているように見えます。角度は@propertyで登録した--angleを0degから360degへkeyframesで進めているので、値が飛ばずになめらかに繋がります。プレミアムな料金プランカードやAI機能紹介カードに向いています。

conic-gradient@property angleopacity
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.card__ring {
  background: conic-gradient(
    from var(--angle),
    transparent 0deg, $color 40deg, transparent 100deg
  );
  opacity: 0;
}

.card:hover .card__ring,
.card.is-demo .card__ring {
  opacity: 1;
  animation: spin 2s linear infinite;
}

@keyframes spin { to { --angle: 360deg; } }

033Dチルト視差

カーソルの位置に応じてrotateXとrotateYでカードが傾き、背景レイヤーだけが逆方向へわずかにずれて層が分かれて見えます。座標の計算はJavaScript数行だけが担い、CSS側はその値を変数として受け取るだけです。機能紹介カードやポートフォリオのプロジェクトカードに向いています。

perspectiverotateX/rotateYJS 좌표
card.addEventListener('pointermove', (e) => {
  const r = card.getBoundingClientRect();
  const px = (e.clientX - r.left) / r.width - .5;
  const py = (e.clientY - r.top) / r.height - .5;
  card.style.setProperty('--rx', `${-py * 16}deg`);
  card.style.setProperty('--ry', `${px * 20}deg`);
  card.style.setProperty('--pz', `${px * -10}px`);
});

04内容がスライドアップ

画像の下に隠れている説明パネルが、ホバーすると画像の上へtranslateYでスライドアップして現れます。カードの高さ自体は変わらないまま、情報だけが一枚追加されます。画像中心の商品カードやギャラリーのサムネイルカードに向いています。

transform: translateYoverflow: hiddenopacity
.card__panel {
  transform: translateY(70%);
  transition: transform .35s $ease-spring;
}

.card:hover .card__panel,
.card.is-demo .card__panel {
  transform: translateY(0);
}

05背景画像が拡大

カードを覆う背景がホバーで1.12倍に拡大し、overflow: hiddenが角丸の外へのはみ出しを切り取ります。滑らかなグラデーションだけでは拡大が伝わりにくいため、輪郭のはっきりした太陽と丘の形を重ねています。旅行先の紹介カードやブログのカバーカードに向いています。

transform: scaleoverflow: hiddenborder-radius 유지
.card { overflow: hidden; }

.card__bg {
  transform: scale(1);
  transition: transform .5s $ease-out;
}

.card:hover .card__bg,
.card.is-demo .card__bg {
  transform: scale(1.12);
}

06アイコンが弾む

カード右上の丸いアイコンバッジが、ホバーすると2回小さく弾んでクリックできることを伝えます。keyframesで上下にtranslateYさせるだけで、cubic-bezierの効いた動きがボールの弾む感触を作ります。ブログカードの「続きを読む」矢印やリンクカードに向いています。

@keyframes bouncecubic-beziertransform: translateY
.card__icon { transform: translateY(0); }

.card:hover .card__icon,
.card:focus-visible .card__icon,
.card:active .card__icon {
  animation: bounce .3s $ease-pop 2;
}

@keyframes bounce {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-8px); }
}

07グラデーション枠

普段は薄いグレーの枠のカードが、ホバーすると2色のグラデーション枠に変わって縁取ります。mask-compositeで内側だけを切り抜いた層のopacityを0から1に変えるだけなので、レイアウトはまったく動きません。料金比較カードやおすすめを強調したいカードに向いています。

mask-compositelinear-gradientopacity
.card__border {
  background: linear-gradient(135deg, $color, $subject-cream);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  opacity: 0;
  transition: opacity .35s $ease-out;
}

.card:hover .card__border,
.card.is-demo .card__border {
  opacity: 1;
}

08カーソルスポットライト

カーソル座標をJavaScript数行で読み取り--mx--myというCSS変数に渡すと、radial-gradientの光がカーソルに追従してカード表面を照らします。--spという数値も@propertyで登録してあるので、光の強さがふっと現れて自然に消えます。ダークテーマのカードやインタラクティブな機能カードに向いています。

pointermoveCSS 변수radial-gradient
card.addEventListener('pointermove', (e) => {
  const r = card.getBoundingClientRect();
  const mx = (e.clientX - r.left) / r.width * 100;
  const my = (e.clientY - r.top) / r.height * 100;
  card.style.setProperty('--mx', `${mx}%`);
  card.style.setProperty('--my', `${my}%`);
  card.style.setProperty('--sp', 1);
});

09押した反応

クリック可能なカードを実際に押すとscaleが0.97まで縮み、影も一段浅くなって指先で触れた感触を伝えます。ホバーだけでは反応せず、押している間だけ動きます。選択できるカード型のラジオボタンやタッチ操作のモバイルカードに向いています。

:activetransform: scalebox-shadow 축소
.card {
  transform: scale(1);
  transition:
    transform .1s $ease-out,
    box-shadow .1s $ease-out;
}

.card:active {
  transform: scale(.97);
  box-shadow: $shadow-press;
}

どこで崩れるか — 落とし穴

9つの中で02と08はどちらも@propertyでカスタムプロパティの型を登録しています。最初はこれを省いて--angle--mx--myをただのCSS変数として書いたところ、02の光は回転させたつもりが一コマも動かず一瞬で消え、08のスポットライトも位置がなめらかに追従せず飛び飛びに切り替わるだけでした。MDNの@propertyの解説にあるとおり、ブラウザは<angle><percentage>として型を教えられない限りカスタムプロパティを補間できず、アニメーションの途中の値を作れません。

2つ目は07のグラデーション枠を組んでいるときに踏んだ落とし穴です。標準のmask-composite: excludeだけを書いてSafariで確かめると枠がいつまでも透明にならず、正方形のグラデーションがカード全体を覆ったままでした。caniuseのmask-composite対応表にあるとおりSafariは長らく接頭辞付きの-webkit-mask-compositeしか読めず、しかもキーワードはexcludeではなくxorだったため、両方を並べて書いてからSafariでも枠が正しく切り抜かれるようになりました。修正済みの9本のファイルを開く合言葉は45c9tna8で、下のカードから取り出せます。

3つ目は05の背景拡大を作っているときに見つけた発見です。角度だけが違うなめらかなlinear-gradientだけを背景に敷いてscale(1.12)を掛けたところ、レンダリングして測定すると24フレームすべてで隣り合うピクセルの色の差が閾値の12を超えず、累積面積は0.00%のまま「動いていないカード」と判定されました。MDNのlinear-gradient()の解説にあるとおり色がなめらかに混ざる帯は隣り合うピクセルの差が小さくなりがちで、境界のはっきりした太陽と丘の形を重ねてから同じ倍率で測り直すと、強度41.3・累積面積0.68%まで上がり正常に通過しました。

アクセシビリティ

9つともprefers-reduced-motion: reduceでは動きだけを止め、最後の見た目は残します。01は浮かばず影だけが濃いまま、02は光が回らず薄く固定、03は傾かず:focus-visibleで既定の角度だけを見せ、04はパネルが最初から開いたまま、05は拡大せず静止、06はバッジが跳ねず、07は枠が常にうっすら見えたまま、08は光が灯った状態で止まり、09は影だけが浅くなります。:hoverだけでなく:focus-visible:activeにも同じ最終状態を与えているので、キーボード操作やタッチ操作でもマウス操作と同じ結果が届きます。カード以外のCSSアニメーションはカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。

FAQ

08のスポットライトはタッチ画面でもカーソルに追従しますか?

いいえ、pointermoveはタッチでは座標を継続して渡さないため、08は--spが既定値の0のまま止まります。代わりに:focus-visible:activeでカード中央に固定のスポットライトを灯し、指で押しても反応だけは残るようにしています。

9つを1つの画面に全部並べても大丈夫ですか?

並べても構いませんが、02と08のように回り続けたり追従し続けたりする演出は一画面に1つか2つにとどめるのが無難です。残りの7つはホバー・フォーカス・プレスといったユーザーの操作に紐づいているので、何枚並べても画面がうるさくなりすぎることはありません。

Reactの03と08はカーソル座標をどう扱っていますか?

どちらのコンポーネントもuseRefでカード要素をつかみ、useCallbackで包んだpointermoveハンドラーの中でstyle.setPropertyを使ってCSS変数へ直接書き込みます。stateを経由しないので、座標が変わるたびにコンポーネント全体が再レンダリングされることはありません。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。