【コピペOK】CSSベントグリッドホバーアニメーション9選
ポートフォリオサイトや製品紹介ページで、大きな四角と小さな四角が写真のコラージュのように組み合わさったレイアウトを見たことはないでしょうか。あれはベントグリッドと呼ばれるレイアウトで、この記事ではマス目にマウスを乗せたときの反応を作るCSSコードを9つ、一行でコピペOKな形で集めました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 呼吸するような拡大+影
- 02 ボーダービームタイル
- 03 画像タイルズーム
- 04 アイコンフロートタイル
- 05 グラデーションリビールタイル
- 06 統計カウントタイル
- 07 ホバーでgrid span拡張
- 08 スクロールで順番に表示
- 09 隣接タイルのグロー
9つは人気順ではなく、ユーザーがグリッドを目で追う順番に並べています。マス全体がふわっと反応するだけの軽い合図(01〜02)から、中の写真やアイコンだけが動くコンテンツ単位の反応(03〜05)へ進み、数字が変わる瞬間(06)とレイアウトそのものを操作したように見える大胆な演出(07)を経て、最後は画面に入ってきた瞬間(08)とグリッド全体を照らす光の演出(09)で締めくくります。
01呼吸するような拡大+影
マウスを乗せるとタイル全体がわずかに拡大しながら影が深くなり、まるで息を吸うようにゆっくり反応します。動いているのはtransform: scale(1.04)とbox-shadowだけで、レイアウトの位置やサイズは一切崩れません。
.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-gradientをmask-composite: excludeでくり抜いてリング状にし、光っているように見えるのはこのレイヤーのtransform回転だけです。
.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をかけている仕組みです。
.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アイコンフロートタイル
機能紹介タイルの中の小さなアイコンは普段は静止していて、マウスを乗せて初めて上下にふわふわ浮き始めます。ホバーしていない間は完全に止まっているので、グリッド全体を見たときに視線が散りません。
.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だけで、写真そのものには一切手を加えていません。
.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をキーフレームで動かし、実際に画面に出ている数字は::afterのcounter()が描いています。このプロパティをinherits: trueで登録しないと::afterのcounter()が更新されず数字がまったく動かなくなるので、登録の仕方には注意が必要です。
@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-columnやgrid-rowのspan値が変わっているわけではありません。span値はとびとびの離散値でトランジションが効かないため、代わりにtransform: scaleとz-indexで隣のマスの上に重ねることで拡大を再現しています。
.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の値だけを変えることで順番に見せています。
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を切り替えています。
.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: scaleとz-indexで隣のマスの上に重ねる方法を使います。09番の:has()セレクターと06番の@propertyによるカウンターも比較的新しい機能で、Chrome・Edge・最新のSafari・Firefoxでは問題なく動きますが、古いブラウザでは動きのない最終状態のまま表示されます。08番には、スクロール位置だけでアニメーションを駆動するanimation-timeline: view()というネイティブCSS機能もありますが、ブラウザごとにレンダリング結果が割れるためこのデモでは採用せず、どこでも同じように動くIntersectionObserverを実コードとして使っています。zipを解凍するときに求められるパスワードは6s9wc2fbで、そのまま入力すれば開きます。
アクセシビリティ — 9つがreduced-motionで残すもの
システムで動きを減らす設定をオンにすると、9つ全部がprefers-reduced-motion: reduceでanimation: none !importantになり動きが止まります。01・02・03・05・07・09のような装飾的な反応はそのまま静止していれば十分ですが、06のカウントアップと08の順番に現れる動きは情報そのものが動きで伝わるため、アニメーションを止める代わりに--numやopacity・transformを最初から最終値に固定し、動かなくても数字とコンテンツが最初から全部見えるようにしてあります。
1マスだけを大きく見せたいレイアウトを探しているならベントグリッドレイアウトの作り方もあわせて読めますし、カードそのものが反応するパターンはCSSカードホバーアニメーション9選にまとめています。
FAQ — よくある質問
ベントグリッドを作るのにgridは必須ですか?
はい、display: gridとgrid-template-columnsでマスの大きさを変えて配置するのがベントグリッドの土台です。この記事の9つのデモはホバーの反応だけを見せるために3〜4マスの小さなグリッドを使っていますが、実際のページではマスの数とspan値を増やすだけで応用できます。
マウスのないタッチ端末ではホバーが効かないのでは?
そのとおりです。タッチ端末では:hoverがまったく反応しないか、一度タップしたまま外れない場合が多いため、01・03・05のような見た目だけの演出はタッチで省略されても情報が欠けないように設計してあります。06のように数字が重要な項目は、画面に入ってきたタイミング(ビューポート進入)をきっかけに切り替える方法をおすすめします。
zipの中のvanillaとreactでコードは違いますか?
動きは同じです。vanilla版はSCSS変数をファイルの先頭にまとめていて、React版は同じ値をコンポーネントのpropsとして受け取り、CSSのカスタムプロパティに渡す構造になっています。見た目の結果はどちらも変わりません。