テクニック

ホバーアニメーションのCSS実装|カードでやりすぎない基準

この記事の対象: WordPress・Shopify・静的サイトを問わず、カードUIのホバー演出を自分で書く制作者
読了時間: 約12分

カードにホバーを付けるとき、迷うのは「どこまで動かすか」です。画像を拡大して、影を落として、色を変えて、タイトルに下線を引いて──全部載せると、一覧ページ全体がざわつきます。逆に何も起きないと、クリックできることが伝わりません。

この記事では、実務でよく使う3パターン(画像の拡大・影・色の変化)をコピーして動く形で載せます。あわせて、同じ見た目でも重くなる書き方と軽い書き方の差、動きを減らす設定をしている閲覧者への対応も書きます。テーマやCMSに依存しない素のCSSで組むので、WordPressでもShopifyでも静的HTMLでもそのまま使えます。


前提と作業環境

前提と作業環境

この記事のコードが動く条件

外部ライブラリは使いません。jQueryもGSAPもAOSも不要です。読み込むファイルが増えるほど初期表示は遅くなるので、CSSで済むものはCSSで書きます。

項目 内容
必要なもの テーマまたはサイトのCSSファイルを編集できる権限
WordPress ブロックテーマ・クラシックテーマどちらでも可。子テーマの style.css、または「追加CSS」
Shopify Online Store 2.0 のテーマ。assets/ 配下のCSSファイル
静的サイト 任意のCSSファイル
JavaScript 基本パターンでは不要。最後の「タッチ端末対応」だけ数行使います

使うCSSプロパティの対応状況

この記事で使うプロパティは、いずれも主要ブラウザに長く実装されているものです。ただし対応状況はバージョンで変わるため、実装前に一度確認してください。確認先は Can I use と MDN が確実です。

プロパティ 用途 備考
transform 拡大・移動 長期間サポートされている。ベンダープレフィックスは現行ブラウザでは不要
transition 変化の時間指定 同上
box-shadow 影 同上
prefers-reduced-motion 動きを減らす設定の検知 主要ブラウザで対応済み。詳細はMDNで確認
:focus-visible キーボード操作時のフォーカス 比較的新しいため、対応状況をCan I useで確認してから使う
@supports 機能の有無で分岐 未対応環境へのフォールバックを書くときに使う
aspect-ratio 画像枠の縦横比固定 対応状況をCan I useで確認。未対応環境向けにはpadding-topによる比率確保で代替する

共通のHTML

以降のコードはすべて、次のHTMLを土台にします。まずこれをそのまま置いてください。

<ul class="card-list">
  <li class="card">
    <a class="card__link" href="/news/sample-1/">
      <div class="card__media">
        <img class="card__image" src="/img/sample-1.jpg" alt="" width="800" height="600" loading="lazy" decoding="async">
      </div>
      <div class="card__body">
        <p class="card__meta">2026.09.01 / お知らせ</p>
        <h3 class="card__title">カードのタイトルがここに入ります</h3>
        <p class="card__text">説明文が入ります。2行程度で収まる長さを想定しています。</p>
      </div>
    </a>
  </li>
  <li class="card">
    <a class="card__link" href="/news/sample-2/">
      <div class="card__media">
        <img class="card__image" src="/img/sample-2.jpg" alt="" width="800" height="600" loading="lazy" decoding="async">
      </div>
      <div class="card__body">
        <p class="card__meta">2026.08.24 / 実績</p>
        <h3 class="card__title">2枚目のカードタイトル</h3>
        <p class="card__text">説明文が入ります。2行程度で収まる長さを想定しています。</p>
      </div>
    </a>
  </li>
</ul>

コピーしたあと変更する箇所

  • card などのクラス名 — 既存テーマと衝突する場合は、プレフィックスを付けて site-card のように変えます。CSS側も同時に置換してください
  • href — 実際のリンク先
  • src / width / height — 実画像のパスと実寸。width と height は必ず入れます。無いとレイアウトシフトが起きます
  • alt — カード全体がリンクで、すぐ下にタイトルテキストがある構造なので、画像のaltは空(alt="")が適切です。画像だけがリンクの場合は、リンク先がわかる文言を入れます
  • 1枚目の画像は loading="lazy" を外す — ファーストビューに入る画像を遅延読み込みにすると、かえって表示が遅れます

共通のベースCSS(ホバーなし)

.card-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.card {
  /* カード自体は動かさず、中身のリンクを動かす */
  border-radius: 12px;
  background: #fff;
  /* 拡大した画像がはみ出さないよう、ここで切る */
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

.card__link {
  display: block;
  color: inherit;
  text-decoration: none;
}

.card__media {
  /* 画像の縦横比を固定する。読み込み前でも高さが確保される */
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: #f2f2f2;
}

.card__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.card__body {
  padding: 16px 18px 20px;
}

.card__meta {
  margin: 0 0 8px;
  font-size: 0.8125rem;
  color: #767676;
}

.card__title {
  margin: 0 0 8px;
  font-size: 1.0625rem;
  line-height: 1.5;
  font-weight: 700;
}

.card__text {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.7;
  color: #555;
}

変更する箇所: minmax(280px, 1fr) の280pxがカード1枚の最小幅です。3カラムに固定したいなら repeat(3, 1fr) に置き換えます。#767676 などの色は、サイトのカラーに合わせて変えてください。ただしグレーは薄くしすぎないこと。本文グレーは背景白に対してコントラスト比4.5:1以上を確保します(#767676 はその境界付近の値です)。

置き場所

環境 パス
WordPress 子テーマ wp-content/themes/{子テーマ名}/style.css の末尾
WordPress ブロックテーマ 同上、または 外観 → エディター → スタイル → 追加CSS
Shopify assets/component-card-custom.css を新規作成し、layout/theme.liquid の </head> 直前で読み込む
静的サイト 既存のCSSファイルの末尾

Shopifyの場合、読み込みの1行はこうです。

{{ 'component-card-custom.css' | asset_url | stylesheet_tag }}

パターン1:画像の拡大

パターン1:画像の拡大

一覧ページで最もよく使われる演出です。ポイントは、拡大するのは画像だけで、カードの外形は動かさないこと。カード自体が大きくなるとグリッドの他のカードとの間隔が変わって見え、一覧全体が落ち着きません。

.card__image {
  transition: transform 0.4s ease;
}

.card__link:hover .card__image,
.card__link:focus-visible .card__image {
  transform: scale(1.05);
}

これだけです。.card__media に overflow: hidden が入っているので、拡大した分は枠で切られます。

倍率と時間の目安

scale() の値を上げるほど動きは派手になりますが、画像が粗く見えるリスクも上がります。元画像が表示サイズの2倍程度で用意されていれば1.08あたりまでは耐えますが、等倍で置いている場合は1.03〜1.05に留めるのが安全です。

用途 scale duration 印象
ニュース一覧・ブログ一覧 1.03〜1.05 0.4s 落ち着く。件数が多い一覧向き
実績一覧・商品一覧 1.05〜1.08 0.5s 見せ場がある。1画面に6枚程度まで
ヒーロー下の大きなカード 1.05 0.6s ゆっくり動くと高級感が出る

数字を上げたくなったら、まず件数を数えてください。1画面に12枚並ぶ一覧で1.08は動きすぎます。

transform と top の差

同じ「動かす」でも、書き方によってブラウザの負荷はかなり変わります。ここが今回いちばん実務に効く部分です。

2つの書き方でブラウザがやる仕事の違い

transform / opacity で書く

  • 合成(Composite)だけで済む
  • 周囲の要素の位置計算が発生しない
  • 多くの環境でGPUに乗る
  • 件数が多くても崩れにくい

top / left / width / margin で書く

  • レイアウト計算からやり直す
  • 周囲の要素も再計算の対象になる
  • 毎フレーム繰り返される
  • 枚数が増えるほど詰まりやすい

ブラウザは要素を描くとき、大まかに「レイアウト(位置とサイズを決める)→ ペイント(色を塗る)→ コンポジット(重ね合わせる)」という順で処理します。top や width を変えると最初のレイアウトからやり直しになり、そのカードだけでなく周囲の要素まで計算対象に入ります。transform と opacity は最後のコンポジットだけで完結するので、同じ見た目でも処理が軽くなります。

避けたい書き方と、置き換え後を並べます。

/* 避けたい:レイアウトの再計算が毎フレーム走る */
.card:hover {
  position: relative;
  top: -6px;          /* → transform: translateY(-6px) */
  width: 105%;        /* → transform: scale(1.05) */
  margin-top: -6px;   /* → transform: translateY(-6px) */
}

/* 推奨:合成だけで済む */
.card:hover {
  transform: translateY(-6px);
}

確認方法: Chrome DevTools の Performance タブで記録しながらホバーすると、Layout(紫)が繰り返し出るかどうかで判断できます。transform に書き換えると紫の帯が消え、Composite Layers だけが残る形になります。実測せずに「軽いはず」で済ませないこと。

transition は動かす側に書く

初学者がつまずきやすいのがここです。transition は :hover の中ではなく、通常状態のセレクタに書きます。

/* ○ 正しい:ホバーを外したときも同じ速度で戻る */
.card__image { transition: transform 0.4s ease; }
.card__link:hover .card__image { transform: scale(1.05); }

/* × 戻るときだけ一瞬で戻り、動きがちぐはぐになる */
.card__image { }
.card__link:hover .card__image { transform: scale(1.05); transition: transform 0.4s ease; }

意図的に「入りはゆっくり、戻りは速く」したい場合だけ、両方に別々の transition を書きます。

/* 戻りだけ速くする例 */
.card__image { transition: transform 0.2s ease-out; }        /* 戻るときの速度 */
.card__link:hover .card__image {
  transform: scale(1.05);
  transition: transform 0.5s ease;                            /* 入るときの速度 */
}

通常状態に書いた値が「戻り」、:hover に書いた値が「行き」になります。この対応関係さえ押さえておけば、意図した非対称になります。

transition: all を書かない

/* 避ける */
.card { transition: all 0.3s; }

/* 書く */
.card { transition: transform 0.3s ease, box-shadow 0.3s ease; }

all は変化しうる全プロパティを監視対象にします。意図していないプロパティまでアニメーションしてしまい、後からスタイルを足したときに原因不明のちらつきが出ます。よくあるのは、後日 background-color や border-color を追加した瞬間に、それまで無かった遅れが出るケースです。プロパティ名は面倒でも列挙してください。

イージングの選び方

ease のままでもほぼ困りませんが、印象を調整したいときは次を目安にします。

値 動き方 向く場面
ease 入りが速く、終わりで緩む 既定。迷ったらこれ
ease-out 最初が速く、着地が静か 出現・拡大。反応が早く感じる
ease-in 最初が遅い ホバー演出には不向き。反応が鈍く見える
cubic-bezier(0.2, 0.8, 0.2, 1) 素早く出て、ゆっくり止まる 少し上質に見せたいとき

ホバーで ease-in を使うと、マウスを乗せた瞬間に何も起きない時間ができて「効いていない」と感じられます。入りは速いカーブを選びます。


パターン2:影で浮かせる

影は「押せる」ことを伝える力が強い一方、やりすぎるとカードが紙のように浮いて見えて、一覧の情報密度が下がります。

.card {
  transition: transform 0.3s ease, box-shadow 0.3s ease;
}

.card:hover,
.card:focus-within {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.18);
}

変更する箇所

  • -4px — 浮かせる量。6pxを超えると「カードが飛ぶ」印象になります。2〜6pxが実用範囲
  • rgba(0, 0, 0, 0.18) — 影の濃さ。背景が濃い色のサイトでは、黒の影は見えないので rgba(255,255,255,0.12) など明るい影に変えるか、影ではなくボーダーの色変化に切り替えます

影を自然に見せる2つのコツ

1. 縦のオフセットを大きく、横は0にする

box-shadow: 0 12px 24px -8px の3つの数字は順に「横方向のずれ・縦方向のずれ・ぼかし」で、4つ目のマイナス値は影の広がりを縮めています。光は上から当たるので、横のずれは0のままにして縦だけ落とすと自然です。ぼかしはずれの2倍程度が目安です。

2. 通常時にも薄い影を置いておく

影が0の状態から急に濃い影が現れると、切り替わりが唐突に見えます。ベースCSSで 0 1px 3px rgba(0,0,0,0.08) を入れてあるのはそのためです。ホバー時はその延長として濃くなるだけなので、変化が滑らかに感じられます。

影ではなくボーダーで浮かせる選択肢

背景がグレーのサイトや、カードの境界をはっきり見せたい一覧では、影より枠線のほうが収まります。

.card {
  border: 1px solid #e3e3e3;
  transition: border-color 0.25s ease, transform 0.25s ease;
}

.card:hover,
.card:focus-within {
  border-color: #1a5fbf;
  transform: translateY(-2px);
}

枠線に切り替えるときは、通常時から同じ太さのボーダーを置いて色だけ変えます。ホバー時に border を足す書き方だと、その1px分だけカードの高さが変わって周囲が揺れます。

:focus-within を併記する理由

:hover だけだと、キーボードでTabキーを押して回る人には何も起きません。:focus-within は「子要素にフォーカスが当たっているとき」に効くので、カード内のリンクにフォーカスが入った瞬間に同じ演出が働きます。

ただし、フォーカスリング(ブラウザ標準の枠線)は消さないでください。

/* × これをやると、キーボード操作で今どこにいるかわからなくなる */
.card__link:focus { outline: none; }

/* ○ 見た目を変えたいなら、代わりの枠を用意する */
.card__link:focus-visible {
  outline: 2px solid #1a73e8;
  outline-offset: 3px;
  border-radius: 12px;
}

:focus-visible はマウスクリック時には発火せず、キーボード操作など「フォーカスリングが必要な状況」だけで効きます。マウス操作で枠が出るのが嫌でoutlineを消していた場合は、これで解決します。

なお .card に overflow: hidden が入っていると、outline-offset で外に出した枠が切られて見えなくなることがあります。フォーカス枠が出ないときは、outline-offset を 0 にするか、枠を .card 側に付けて確認してください。


パターン3:色の変化

パターン3:色の変化

背景色・文字色・下線を変えるパターンです。3つのうち最も控えめで、テキスト主体のカードに向きます。

.card__title {
  transition: color 0.25s ease;
}

.card__link:hover .card__title,
.card__link:focus-visible .card__title {
  color: #1a5fbf;
}

/* 下線を左から伸ばす */
.card__title {
  display: inline;
  background-image: linear-gradient(currentColor, currentColor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0% 1px;
  transition: background-size 0.3s ease, color 0.25s ease;
}

.card__link:hover .card__title,
.card__link:focus-visible .card__title {
  background-size: 100% 1px;
}

変更する箇所: #1a5fbf をサイトのアクセントカラーに。1px は下線の太さ、0.3s は伸びる速度です。

下線に text-decoration ではなく背景グラデーションを使っているのは、text-decoration は伸縮のアニメーションができないためです。背景のサイズなら0%から100%へ滑らかに変えられます。currentColor を指定しているので、文字色が変われば下線の色も追従します。

display: inline を指定しているのは、見出しがブロック要素のままだと、テキストの長さに関係なく行の右端まで下線が伸びてしまうためです。文字の幅に沿わせたい場合は inline にします。ただし margin-bottom が効かなくなるので、下の余白は親要素側で確保してください。

なお background-size の変化はレイアウトの再計算までは起こしませんが、transform ほど軽くはありません。1画面に何十枚も並ぶ一覧では、下線を諦めて色の変化だけにするほうが安定します。

色変化で気をつけること

  • コントラスト比を落とさない — ホバー時に文字が薄い色になる設計は避けます。変化後も背景に対して4.5:1以上
  • 色だけで状態を伝えない — 色覚特性によっては変化が伝わりません。下線や影など、色以外の変化を1つ添えます
  • 背景色を変えるなら文字色も検算する — カード背景を濃色に反転させる演出では、文字色も同時に切り替える必要があります
/* 背景を反転させる場合の書き方 */
.card {
  transition: background-color 0.3s ease, color 0.3s ease;
}

.card:hover,
.card:focus-within {
  background-color: #16243a;
  color: #fff;
}

.card:hover .card__meta,
.card:focus-within .card__meta,
.card:hover .card__text,
.card:focus-within .card__text {
  color: #d6dce6;   /* グレー指定を上書きしないと読めなくなる */
}

.card__meta と .card__text には個別に色指定が入っているので、親の color を変えただけでは追従しません。これは反転演出でいちばん多い見落としです。

反転演出を使うなら、色を変数にまとめておくと検算が楽になります。

.card {
  --card-bg: #fff;
  --card-fg: #222;
  --card-sub: #767676;
  background-color: var(--card-bg);
  color: var(--card-fg);
}

.card__meta,
.card__text { color: var(--card-sub); }

.card:hover,
.card:focus-within {
  --card-bg: #16243a;
  --card-fg: #fff;
  --card-sub: #d6dce6;
}

書き換えるのが3つの変数だけになるので、上書き漏れが起きません。ただしカスタムプロパティ自体はトランジションできないため、変化は瞬時に切り替わります。滑らかに変えたい場合は前述の直接指定の書き方を使ってください。


やりすぎないための基準

やりすぎないための基準

3つのパターンを全部重ねると、たいてい過剰になります。判断の目安を置きます。

ホバー演出を決める3ステップ
件数を数える1画面に何枚並ぶかで上限が決まる
演出は2つまで拡大+影、色+下線などの組み合わせ
離して見る実データを入れて1440px幅で全体を確認

先に件数を決めてから演出の強さを決める、という順番が大事です。デザインカンプでカードが3枚しか置かれていなくても、本番では30枚並ぶことがあります。

件数ごとの目安

1画面に並ぶ枚数 推奨する演出 避けたいこと
1〜3枚 拡大1.05 + 影 + 色変化 特になし。多少強くてよい
4〜9枚 拡大1.03〜1.05 + 影、または色変化のみ 3つ同時に効かせる
10枚以上 色変化と下線のみ、または影のみ 画像の拡大。視線が定まらなくなる

そのほかの判断軸

  • 動きの持続時間は0.2〜0.5秒 — 0.2秒未満は「点滅」に見え、0.6秒を超えるとマウスを速く動かしたときに動きが追いつきません
  • カードの外形を動かすのは1回だけ — 拡大と浮き上がりを同時にやると、輪郭が二重に動いて落ち着きません
  • ホバー中に文字が動かない — カード全体を scale すると文字も一緒に拡大され、レンダリングし直しでにじみます。動かすのは画像だけに留めるのが安全です
  • 回転・傾きは慎重に — rotate や skew は目立ちますが、一覧では合いません。単体で置かれるバナーやヒーローなら検討の余地があります

コンテンツの性質でも変わる

同じ枚数でも、カードの中身によって適する強さは違います。

カードの中身 向く演出 理由
写真が主役(実績・商品・レシピ) 画像の拡大+影 画像そのものが見せ場なので、画像を動かす意味がある
テキストが主役(ニュース・コラム) 色の変化+下線 画像はアイキャッチにすぎず、拡大しても情報が増えない
サムネイルが無い(テキストのみ) 背景色または枠線 動かす対象が文字しかない。背景で範囲を示す
図版・ロゴなど余白の多い画像 影のみ 拡大すると余白が切れて図が欠ける

ロゴや図版を拡大すると、object-fit: cover の切り取りで肝心の部分が枠外へ出ます。この種の画像は object-fit: contain にして、拡大ではなく影で反応させるほうが安全です。

同じ「作り込みすぎない」判断は、記事一覧のカスタマイズ全般で共通します。Shopifyでの一覧側の組み方はこちらにまとめています。


動きを減らす設定への対応

動きを減らす設定への対応

OSには「視差効果を減らす」「アニメーションを減らす」という設定があり、これをオンにしている閲覧者がいます。前庭障害や片頭痛の要因になるためで、無視すると体調に直接影響します。CSSからは prefers-reduced-motion メディアクエリで検知できます。

CSSの最後に、次のブロックを追加してください。

@media (prefers-reduced-motion: reduce) {
  .card,
  .card__image,
  .card__title,
  .card__link {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }

  .card:hover,
  .card:focus-within {
    transform: none;
  }

  .card__link:hover .card__image,
  .card__link:focus-visible .card__image {
    transform: none;
  }
}

変更する箇所: セレクタのクラス名だけ、自分のものに合わせます。

ここでのポイントは、演出を全部消すのではなく、動きだけを止めて色や影の変化は残すことです。動きを止めた上で何の反応もなくなると、「押せる」ことが伝わらなくなります。上のコードでは transform だけを打ち消し、box-shadow と color の変化はそのまま効かせています。時間を0.01msにしているのは、transitionend イベントに依存しているスクリプトが動かなくなるのを避けるためです。

動作確認の方法

環境 手順
macOS システム設定 → アクセシビリティ → 表示 → 「視差効果を減らす」をオン
Windows 設定 → アクセシビリティ → 視覚効果 → 「アニメーション効果」をオフ
iOS 設定 → アクセシビリティ → 動作 → 「視差効果を減らす」をオン
Chrome DevTools コマンドメニュー(Ctrl/Cmd + Shift + P)で Emulate CSS prefers-reduced-motion を検索

DevToolsのエミュレートが最も手軽です。実装したらその場で切り替えて、動きが止まるかを確認してください。OS設定を実際に変えて確認する場合は、ブラウザを再読み込みしなくても即座に反映されます。

逆の書き方もある

最初からアニメーションを付けず、「動いてよい人にだけ付ける」書き方もあります。

@media (prefers-reduced-motion: no-preference) {
  .card__image {
    transition: transform 0.4s ease;
  }
  .card__link:hover .card__image {
    transform: scale(1.05);
  }
}

こちらのほうが安全ですが、既存サイトに後から入れる場合は上の reduce で打ち消す形のほうが差分が小さく済みます。新規制作なら no-preference 側で書くのがおすすめです。

判断の分かれ目は、既存CSSの量です。テーマが元々あちこちに transition を書いている状態で no-preference 方式に切り替えると、既存の記述を全部囲み直すことになります。その場合は reduce で上から打ち消すほうが現実的です。自分が最初から書くCSSなら no-preference を選びます。


タッチ端末での挙動

スマートフォンには「ホバー」がありません。タップすると :hover が発火したまま残り、別の場所をタップするまで演出が効きっぱなしになる、という現象が起きます。

CSSだけで解決できます。

/* ホバーできるポインタを持つ環境だけに適用する */
@media (hover: hover) and (pointer: fine) {
  .card {
    transition: transform 0.3s ease, box-shadow 0.3s ease;
  }
  .card:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.18);
  }
  .card__link:hover .card__image {
    transform: scale(1.05);
  }
}

/* タッチ端末には押した瞬間のフィードバックだけ用意する */
@media (hover: none) {
  .card__link:active {
    opacity: 0.7;
  }
}

(hover: hover) はホバー可能な入力機器がある環境、(pointer: fine) はマウスやトラックパッドのように細かく指せる機器がある環境を指します。この2つを併記すると、タッチのみの端末を確実に除外できます。

タブレットにキーボードとトラックパッドを付けている場合など、両方の入力を持つ環境ではブラウザが主たる入力機器を基準に判定します。判定が変わるのは入力機器を接続・切断したときなので、実機で両方の状態を確認しておくと安心です。

:focus-within を使った演出は @media (hover: hover) の外に置いてください。中に入れると、タッチ端末でキーボード(外付けやスクリーンリーダー操作)を使う人に届かなくなります。

JavaScriptが必要になる場面

CSSで足りない場面は限られますが、「カード全体をクリック可能にしつつ、中に別のリンク(タグやカテゴリ)を置きたい」ときはJSが要ります。<a> の入れ子はHTMLとして不正なので、外側をdivにしてJSでクリックを拾います。

<li class="card" data-card-href="/news/sample-1/">
  <div class="card__media">
    <img class="card__image" src="/img/sample-1.jpg" alt="" width="800" height="600" loading="lazy">
  </div>
  <div class="card__body">
    <h3 class="card__title"><a class="card__main-link" href="/news/sample-1/">カードのタイトル</a></h3>
    <p class="card__tags"><a href="/tag/news/">お知らせ</a></p>
  </div>
</li>
document.querySelectorAll('.card[data-card-href]').forEach(function (card) {
  card.addEventListener('click', function (event) {
    // カード内の実リンクをクリックしたときは、そちらを優先する
    if (event.target.closest('a')) return;
    // テキスト選択中の誤爆を防ぐ
    if (window.getSelection().toString().length > 0) return;
    window.location.href = card.dataset.cardHref;
  });
});
@media (hover: hover) and (pointer: fine) {
  .card[data-card-href] { cursor: pointer; }
}

変更する箇所: .card のクラス名と data-card-href の属性名。属性名を変える場合は、JS側の card.dataset.cardHref も対応するキャメルケースに直します(data-foo-bar → dataset.fooBar)。

置き場所

環境 パス
WordPress 子テーマの js/card.js に置き、functions.php で wp_enqueue_script() から読み込む
Shopify assets/card-click.js を作り、layout/theme.liquid で {{ 'card-click.js' \| asset_url \| script_tag }}
静的サイト </body> 直前に <script src="/js/card.js" defer></script>

キーボード操作でも到達できるよう、タイトルは必ず本物の <a> にしておきます。カード全体のクリックはあくまで「マウスの便利機能」で、それ単体でリンクの代わりにはなりません。

この方式では、カード内のタイトルにフォーカスが入ったときに演出を効かせたいので、:focus-within はカード側に書きます。.card:focus-within なら、外側がdivでも子の <a> のフォーカスを拾えます。

WordPressで一覧のマークアップ自体を編集者が触れる形にしたい場合は、ブロックパターン化しておくと崩れにくくなります。


完成形のCSS(コピー用)

ここまでの内容を1つにまとめたものです。そのまま貼れば動きます。

/* ===== カードのベース ===== */
.card-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.card {
  border-radius: 12px;
  background: #fff;
  overflow: hidden;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

.card__link {
  display: block;
  color: inherit;
  text-decoration: none;
}

.card__link:focus-visible {
  outline: 2px solid #1a73e8;
  outline-offset: 3px;
  border-radius: 12px;
}

.card__media {
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: #f2f2f2;
}

.card__image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.card__body { padding: 16px 18px 20px; }
.card__meta { margin: 0 0 8px; font-size: 0.8125rem; color: #767676; }
.card__title { margin: 0 0 8px; font-size: 1.0625rem; line-height: 1.5; font-weight: 700; }
.card__text { margin: 0; font-size: 0.875rem; line-height: 1.7; color: #555; }

/* ===== ホバー演出(マウス環境かつ動きを許容する設定のときだけ) ===== */
@media (hover: hover) and (pointer: fine) {
  .card {
    transition: transform 0.3s ease, box-shadow 0.3s ease;
  }
  .card__image {
    transition: transform 0.4s ease;
  }
  .card__title {
    transition: color 0.25s ease;
  }

  .card:hover,
  .card:focus-within {
    transform: translateY(-4px);
    box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.18);
  }

  .card:hover .card__image,
  .card:focus-within .card__image {
    transform: scale(1.05);
  }

  .card:hover .card__title,
  .card:focus-within .card__title {
    color: #1a5fbf;
  }
}

/* ===== タッチ端末 ===== */
@media (hover: none) {
  .card__link:active { opacity: 0.7; }
}

/* ===== 動きを減らす設定への対応 ===== */
@media (prefers-reduced-motion: reduce) {
  .card,
  .card__image,
  .card__title {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
  .card:hover,
  .card:focus-within,
  .card:hover .card__image,
  .card:focus-within .card__image {
    transform: none;
  }
  /* 影と色の変化は残す。無反応にはしない */
}

まとめて置換すべき箇所

箇所 既定値 変える判断
クラス名 card 系 card 既存テーマに同名クラスがあれば site-card などに
minmax(280px, 1fr) 280px カード1枚の最小幅。3カラム固定なら repeat(3, 1fr)
#1a5fbf 青 サイトのアクセントカラー
#1a73e8(outline) 青 フォーカス枠の色。背景とのコントラストを確保
scale(1.05) 1.05 1画面10枚以上ならこの行ごと削除
translateY(-4px) -4px 2〜6pxの範囲で調整
aspect-ratio: 4 / 3 4:3 縦長のサムネイルなら 3 / 4、横長なら 16 / 9

貼ったあとに見る3点

  1. キーボードでTabキーを押す — フォーカス枠が見えるか、カードの演出が効くか
  2. DevToolsで prefers-reduced-motion をエミュレート — 移動と拡大が止まり、色と影は残るか
  3. DevToolsのデバイスモードでタッチをエミュレート — タップ後にホバー状態が残らないか

この3つが通れば、実装としては完了です。あとは件数と強さの判断だけが残ります。


うまくいかないとき

画像がカードからはみ出す

.card__media に overflow: hidden が入っているか確認します。テーマ側のCSSが後から overflow: visible を当てているケースもあるので、DevToolsで該当要素を選んで「Computed」タブの overflow の値を見てください。打ち消されていれば、セレクタの詳細度を上げるか、後ろに読み込まれるファイルに書き直します。

ホバーがカクつく

まずPerformanceタブで記録し、Layout が繰り返し発生していないか見ます。出ていれば、top / left / width / height / margin を変化させている箇所が残っています。transform に置き換えてください。

それでも重い場合は、大きすぎる画像を疑います。表示幅600pxのカードに4000px幅の画像を置くと、拡大のたびに再サンプリングが走ります。表示サイズの2倍程度に縮小しておくのが目安です。

will-change: transform は最後の手段です。常時指定するとメモリを消費し続けるので、ホバー時だけ付けるか、そもそも使わないほうが安全です。

角丸から画像がはみ出す(Safari)

overflow: hidden と border-radius と transform を組み合わせると、一部の環境で角が正しく切れないことがあります。.card__media 側にも border-radius を指定するか、.card に isolation: isolate; を追加すると解消することがあります。環境依存なので、実機で確認してから採用してください。

タブキーでフォーカスが飛ばない

.card__link が <a> タグになっているか確認します。<div onclick> ではキーボードで到達できません。カード全体をJSでクリック可能にする方式を使っている場合は、タイトル部分が <a href> になっているかを見てください。

ホバーが効いたり効かなかったりする

カードの間にわずかな隙間があり、マウスがそこを通過すると :hover が外れます。gap で作った隙間は当たり判定を持たないので、これは正常な挙動です。気になる場合は .card に padding を持たせて、当たり判定そのものを広げます。

もう一つ多いのが、.card__link に display: block が付いていないケースです。<a> は初期値がインラインなので、中身の高さ分しか当たり判定が広がりません。

ホバーした瞬間に一覧全体がガタつく

カードの外形を scale で拡大している場合に起きます。transform 自体はレイアウトを動かしませんが、拡大したカードが隣のカードに重なるため、視覚的にはグリッドが揺れて見えます。カードの外形は translateY で数px浮かせるだけに留め、拡大は画像に限定してください。

もうひとつは、ホバー時に border や padding を足しているケースです。この2つは実際にレイアウトを動かします。通常時から同じ値を確保して、色だけを変える形に直します。

ホバー中にカーソルが矢印のまま

.card__link が <a href> になっていれば自動的に指のカーソルになります。href 属性が無い <a> はリンクとして扱われないので、カーソルも変わりません。JSでクリックを拾う方式なら、cursor: pointer を明示します。

Shopifyで変更が反映されない

テーマのCSSはブラウザとCDNの両方でキャッシュされます。開発テーマで確認するときは、URLに ?preview_theme_id= を付けてプレビューしてください。それでも古いままなら、スーパーリロード(Ctrl/Cmd + Shift + R)を挟みます。

WordPressで追加CSSが効かない

「追加CSS」はテーマのCSSより後に読み込まれるので詳細度の問題は起きにくいのですが、ブロックテーマではブロック側のインラインスタイルが勝つことがあります。DevToolsで打ち負けている行を特定し、セレクタを1段深くする(.wp-block-post-template .card のように)ことで解決します。!important は最後の手段です。

一覧全体がうるさい

技術的な問題ではなく、量の問題です。演出を1つ減らしてください。多くの場合、画像の拡大を外すと落ち着きます。ページの見た目に迷ったら、実データを入れた状態で1440px幅のウィンドウで表示し、少し離れて眺めるのが確実です。


よくある質問

ホバーアニメーションはCSSとJavaScriptのどちらで書くべきですか

拡大・影・色の変化はすべてCSSで書けます。JavaScriptが必要になるのは、カード全体をクリック可能にする場合や、スクロール位置に応じて演出を変える場合だけです。CSSで済むものをJSで書くと、読み込むファイルが増えて初期表示が遅くなります。

transform と top で本当に速度は変わりますか

変わります。top の変更はブラウザのレイアウト計算をやり直させ、その要素だけでなく周囲の要素も再計算の対象になります。transform は合成の段階だけで完結します。Chrome DevTools の Performance タブで記録すると、Layout の発生回数の違いとして確認できます。

prefers-reduced-motion に対応すると演出がなくなりませんか

なくす必要はありません。止めるのは移動と拡大だけで、色の変化や影は残せます。動きだけを打ち消して、押せることが伝わる反応は残すのが正しい対応です。この記事のコードもその形にしています。

スマートフォンでホバーが残ってしまいます

@media (hover: hover) and (pointer: fine) でホバー演出全体を囲むと解決します。タッチ端末には代わりに :active で押した瞬間のフィードバックを用意してください。透明度を少し下げるだけで十分伝わります。

画像の拡大倍率はどれくらいが適切ですか

1画面に並ぶ枚数で決めます。1〜3枚なら1.05〜1.08、4〜9枚なら1.03〜1.05、10枚以上なら拡大自体を外して色の変化だけにするのが目安です。元画像が表示サイズと等倍の場合は、粗さが出るため1.05までに留めてください。


まとめ

ホバー演出は、コード自体は数行で終わります。難しいのは「どこまでやるか」の判断です。

  • 軽い書き方を選ぶ — transform と opacity だけで動かす。top や width は使わない。transition: all も書かない
  • 演出は2つまで — 拡大・影・色をすべて重ねると一覧がざわつく。件数が多いほど控えめに
  • 動きを減らす設定に対応する — prefers-reduced-motion で移動と拡大だけ止め、色と影の反応は残す
  • タッチ端末を除外する — @media (hover: hover) and (pointer: fine) で囲む
  • キーボードでも同じ体験にする — :focus-within と :focus-visible を併記し、フォーカスリングは消さない

この記事の「完成形のCSS」はこの5つをすべて満たした形になっています。クラス名と色を差し替えれば、今日の作業はそれで終わります。

一覧まわりの実装は、カードの見た目だけでなく読み込みの設計とも関わります。ライブラリを使わずに動きを作る発想は、スライダーなど他のUIでも同じです。