GODRICH

【コピペOK】CSSベントグリッドホバーアニメーション9選

ポートフォリオサイトや製品紹介ページで、大きな四角と小さな四角が写真のコラージュのように組み合わさったレイアウトを見たことはないでしょうか。あれはベントグリッドと呼ばれるレイアウトで、この記事ではマス目にマウスを乗せたときの反応を作るCSSコードを9つ、一行でコピペOKな形で集めました。

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

9つは人気順ではなく、ユーザーがグリッドを目で追う順番に並べています。マス全体がふわっと反応するだけの軽い合図(01〜02)から、中の写真やアイコンだけが動くコンテンツ単位の反応(03〜05)へ進み、数字が変わる瞬間(06)とレイアウトそのものを操作したように見える大胆な演出(07)を経て、最後は画面に入ってきた瞬間(08)とグリッド全体を照らす光の演出(09)で締めくくります。

01呼吸するような拡大+影

マウスを乗せるとタイル全体がわずかに拡大しながら影が深くなり、まるで息を吸うようにゆっくり反応します。動いているのはtransform: scale(1.04)box-shadowだけで、レイアウトの位置やサイズは一切崩れません。

scalebox-shadow300ms
.bs-focal {
  transition: transform 300ms $ease-spring, box-shadow 300ms $ease-spring;
  box-shadow: $shadow-press;
}
.bs-focal:hover {
  transform: scale(1.04);
  box-shadow: $shadow-raised;
}

02ボーダービームタイル

強調したいタイルにマウスを乗せると、枠線に沿って細い光が一周します。conic-gradientmask-composite: excludeでくり抜いてリング状にし、光っているように見えるのはこのレイヤーのtransform回転だけです。

conic-gradientmask1.6s
.bb-focal::before {
  content: "";
  position: absolute;
  inset: 0;
  padding: $sp-1;
  background: conic-gradient(from 0deg, transparent 0 70%, $color 85%, transparent 100%);
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  opacity: 0;
}
.bb-focal:hover::before {
  opacity: 1;
  animation: bb-spin 1.6s linear infinite;
}

03画像タイルズーム

サムネイルの入ったタイルにマウスを乗せると、外枠は動かないまま中の写真だけがゆっくり拡大します。overflow: hiddenで額縁を固定し、その内側の画像レイヤーにだけtransform: scaleをかけている仕組みです。

scaleoverflow: hidden400ms
.iz-tile--a { overflow: hidden; }
.iz-tile__media {
  position: absolute;
  inset: 0;
  transition: transform 600ms linear;
}
.iz-focal:hover .iz-tile__media {
  transform: scale(1.15);
}

04アイコンフロートタイル

機能紹介タイルの中の小さなアイコンは普段は静止していて、マウスを乗せて初めて上下にふわふわ浮き始めます。ホバーしていない間は完全に止まっているので、グリッド全体を見たときに視線が散りません。

translateY@keyframes1.4s
.if-focal:hover .if-icon {
  animation: if-bob 1.4s $ease-out infinite;
}
@keyframes if-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-6px); }
}

05グラデーションリビールタイル

普段は写真だけが見えているタイルに、マウスを乗せると下から暗いグラデーションの幕がじわりと現れ、タイトルの文字が少しだけ上に動きます。動いているのは幕のopacityとタイトルのtransformだけで、写真そのものには一切手を加えていません。

opacitytranslateY350ms
.gr-tile__scrim {
  opacity: 0;
  transition: opacity 350ms $ease-out;
}
.gr-tile__label {
  transform: translateY(8px);
  transition: transform 350ms $ease-out;
}
.gr-focal:hover .gr-tile__scrim { opacity: 1; }
.gr-focal:hover .gr-tile__label { transform: translateY(0); }

06統計カウントタイル

数字で実績を見せるタイルにマウスを乗せると、0から目標の数字までカウントアップします。@propertyで整数型として登録したカスタムプロパティ--numをキーフレームで動かし、実際に画面に出ている数字は::aftercounter()が描いています。このプロパティをinherits: trueで登録しないと::afterのcounter()が更新されず数字がまったく動かなくなるので、登録の仕方には注意が必要です。

@propertycounter()1.2s
@property --num {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}
.sc-stat__value::after {
  counter-reset: num var(--num);
  content: counter(num);
}
@keyframes sc-countup { to { --num: 87; } }
.sc-focal:hover .sc-stat__value {
  animation: sc-countup 1.2s $ease-out forwards;
}

07ホバーでgrid span拡張

マウスを乗せたタイルが隣のマスまで広がって大きくなったように見えますが、実際にgrid-columngrid-rowのspan値が変わっているわけではありません。span値はとびとびの離散値でトランジションが効かないため、代わりにtransform: scalez-indexで隣のマスの上に重ねることで拡大を再現しています。

scalez-indextransform-origin: top left
.he-focal {
  transition: transform 600ms $ease-spring, box-shadow 600ms $ease-spring;
  box-shadow: $shadow-press;
}
.he-focal:hover {
  transform: scale(1.35);
  box-shadow: $shadow-raised;
  z-index: 5;
}

08スクロールで順番に表示

グリッドが画面に入ってくると、タイルが一つずつ時間差で浮かび上がって現れます。実際のコードはIntersectionObserverがタイルの交差を検知して.inクラスを付け、タイルごとにtransition-delayの値だけを変えることで順番に見せています。

IntersectionObservertransition-delayJS 8줄
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (e) {
    if (e.isIntersecting) {
      e.target.classList.add('in');
      io.unobserve(e.target);
    }
  });
}, { threshold: 0.3 });
tiles.forEach(function (t) { io.observe(t); });
.ss-tile {
  transition: opacity 400ms $ease-out, transform 400ms $ease-out;
}
.ss-tile.in { opacity: 1; transform: translateY(0); }
.ss-tile:nth-child(2).in { transition-delay: 160ms; }
.ss-tile:nth-child(3).in { transition-delay: 320ms; }
.ss-tile:nth-child(4).in { transition-delay: 480ms; }

09隣接タイルのグロー

グリッドの上でマウスを動かすと、今ポイントしているマスだけでなく隣のマスまでやわらかい光が一緒ににじみます。カーソル座標を--mx--myというカスタムプロパティに保存して全タイルで共有し、:has()で「グリッドのどこかがホバー中か」だけを判定して光のopacityを切り替えています。

:has()radial-gradientJS 8줄
.sl-tile::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(140px circle at var(--mx) var(--my), rgba($color, .6), transparent 70%);
  opacity: 0;
  transition: opacity 200ms linear;
}
.sl-grid:has(.sl-tile:hover) .sl-tile::before { opacity: 1; }
var grid = document.querySelector('.sl-grid');
grid.addEventListener('pointermove', function (e) {
  var r = grid.getBoundingClientRect();
  var x = ((e.clientX - r.left) / r.width * 100).toFixed(1) + '%';
  var y = ((e.clientY - r.top) / r.height * 100).toFixed(1) + '%';
  grid.style.setProperty('--mx', x);
  grid.style.setProperty('--my', y);
});

ベントグリッドレイアウトはどこで壊れるか

9つの中でいちばんつまずきやすいのは07番です。マウスを乗せたマスをgrid-column: span 2のように広げたいと考える人は多いのですが、gridのspan値はとびとびの離散値なのでブラウザが中間のコマを作れず、トランジション自体が発生しません。切り替わった瞬間にワープするだけです。そのため実際のマス割りはそのままにして、transform: scalez-indexで隣のマスの上に重ねる方法を使います。09番の:has()セレクターと06番の@propertyによるカウンターも比較的新しい機能で、Chrome・Edge・最新のSafari・Firefoxでは問題なく動きますが、古いブラウザでは動きのない最終状態のまま表示されます。08番には、スクロール位置だけでアニメーションを駆動するanimation-timeline: view()というネイティブCSS機能もありますが、ブラウザごとにレンダリング結果が割れるためこのデモでは採用せず、どこでも同じように動くIntersectionObserverを実コードとして使っています。zipを解凍するときに求められるパスワードは6s9wc2fbで、そのまま入力すれば開きます。

アクセシビリティ — 9つがreduced-motionで残すもの

システムで動きを減らす設定をオンにすると、9つ全部がprefers-reduced-motion: reduceanimation: none !importantになり動きが止まります。01・02・03・05・07・09のような装飾的な反応はそのまま静止していれば十分ですが、06のカウントアップと08の順番に現れる動きは情報そのものが動きで伝わるため、アニメーションを止める代わりに--numopacitytransformを最初から最終値に固定し、動かなくても数字とコンテンツが最初から全部見えるようにしてあります。

1マスだけを大きく見せたいレイアウトを探しているならベントグリッドレイアウトの作り方もあわせて読めますし、カードそのものが反応するパターンはCSSカードホバーアニメーション9選にまとめています。

FAQ — よくある質問

ベントグリッドを作るのにgridは必須ですか?

はい、display: gridgrid-template-columnsでマスの大きさを変えて配置するのがベントグリッドの土台です。この記事の9つのデモはホバーの反応だけを見せるために3〜4マスの小さなグリッドを使っていますが、実際のページではマスの数とspan値を増やすだけで応用できます。

マウスのないタッチ端末ではホバーが効かないのでは?

そのとおりです。タッチ端末では:hoverがまったく反応しないか、一度タップしたまま外れない場合が多いため、01・03・05のような見た目だけの演出はタッチで省略されても情報が欠けないように設計してあります。06のように数字が重要な項目は、画面に入ってきたタイミング(ビューポート進入)をきっかけに切り替える方法をおすすめします。

zipの中のvanillaとreactでコードは違いますか?

動きは同じです。vanilla版はSCSS変数をファイルの先頭にまとめていて、React版は同じ値をコンポーネントのpropsとして受け取り、CSSのカスタムプロパティに渡す構造になっています。見た目の結果はどちらも変わりません。

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

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