GODRICH

一行でコピペOK CSS アニメーション ギャラリー 9選

写真にマウスを乗せると覆っていた板がすっと開いたり、円がじわっと広がって中身が見えたりするサイトを見たことはありませんか。この記事はそうしたcss アニメーション ギャラリーの動きを、一行でコピペできるコード9個にまとめたものです。うち1つはJavaScriptを使わずカテゴリー別のフィルタータブまで動きます。

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

9個は派手さの順ではなく、ギャラリーを訪れた人が実際にたどる順番に並べています。まず01〜03は写真が1枚だけ覆いを解かれる単体のリビールで、04は複数の写真タイルが順番に並んで現れるグリッドの登場です。05と07はクリックして写真を大きく見る瞬間、06はカーソルを乗せた1枚だけに情報を足すホバーで、08はスクロールして画面に入った大きな写真がやわらかく現れる演出です。最後の09は複数の写真をカテゴリーで絞り込むフィルターで締めくくります。

01左右ワイプ

写真を覆う板が右端を軸にscaleX(0)まで縮んでいき、左から右へ向かって写真が現れます。動かしているのはtransform-originを右端に固定したscaleXだけです。

scaleXtransform-origin500ms
.wr-cover {
  position: absolute;
  inset: 0;
  background: #fff7e6;
  transform-origin: 100% 50%;
  transform: scaleX(1);
  transition: transform 600ms ease-out;
}
.wr-focal:hover .wr-cover {
  transform: scaleX(0);
}

02カーテン分割リビール

半分ずつに分かれた2枚のパネルが写真を覆っていて、マウスを乗せると左のパネルは左へ、右のパネルは右へtranslateXでそれぞれ100%押し出され、舞台のカーテンのように開きます。

translateX50vw600ms
.cs-panel {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  transition: transform 600ms cubic-bezier(.2, .8, .2, 1);
}
.cs-panel--l { left: 0; }
.cs-panel--r { left: 50%; }
.cs-focal:hover .cs-panel--l { transform: translateX(-100%); }
.cs-focal:hover .cs-panel--r { transform: translateX(100%); }

03円形マスク拡張

写真の中心から円形のclip-pathが半径0%から75%まで広がり、カメラの絞りが開くように写真を見せます。

clip-pathcircle()550ms
.cm-photo {
  clip-path: circle(0% at 50% 50%);
  transition: clip-path 600ms ease-out;
}
.cm-focal:hover .cm-photo {
  clip-path: circle(75% at 50% 50%);
}

04メイソンリー順次フェード

高さの異なる写真タイルがレンガのように積まれたグリッドが画面に入ると、IntersectionObserverがグリッド全体の到達を検知してタイルごとにinクラスを付け、transition-delayだけを140ms刻みでずらして順番に浮かび上がらせます。

columnstransition-delayJS 7줄
var tiles = document.querySelectorAll('.mf-tile');
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); });

05dialogライトボックス

サムネイルのボタンを押すとネイティブのdialog要素がshowModal()で開いて写真が拡大表示され、閉じるボタンはページを勝手にスクロールさせないpreventScroll付きでフォーカスを受け取ります。

dialogshowModalJS 6줄
var dlg = document.querySelector('.lb-dialog');
var btn = document.querySelector('.lb-close');
document.querySelector('.lb-thumb').addEventListener('click', function () {
  dlg.showModal();
  btn.focus({ preventScroll: true });
});
btn.addEventListener('click', function () {
  dlg.close();
});

06ホバーキャプショングリッド

3枚並んだグリッドでマウスを乗せた1枚だけキャプションバーがtranslateYで下から上へ上がってきてopacityでふわっと現れ、残りの2枚はそのままです。

translateYopacity300ms
.hc-cap {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 8px;
  transform: translateY(100%);
  opacity: 0;
  transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.hc-tile:hover .hc-cap {
  transform: translateY(0);
  opacity: 1;
}

07サムネイル→ライトボックスモーフ

サムネイルをクリックした瞬間にgetBoundingClientRect()でその位置を測り、オーバーレイのパネルをまずそこへtransformで重ねてから値を消すことで、中央の大きなライトボックスの位置までFLIPと呼ばれる手法で滑らかに拡大します。

getBoundingClientRectFLIPJS 9줄
var t = thumb.getBoundingClientRect();
var p = panel.getBoundingClientRect();
panel.style.transform =
  'translate(' + (t.left - p.left) + 'px,' + (t.top - p.top) + 'px) ' +
  'scale(' + (t.width / p.width) + ',' + (t.height / p.height) + ')';
panel.classList.add('is-open');
requestAnimationFrame(function () {
  panel.style.transform = 'translate(0,0) scale(1,1)';
});

08スクロールマスクリビール

写真が画面に入るとIntersectionObserverが付けたクラスに合わせてmask-imagemask-positionが動き、輪郭が硬く切り替わるのではなくグラデーションのやわらかい縁で写真が現れます。

mask-imagemask-positionJS 7줄
.mk-photo {
  mask-image: linear-gradient(100deg, transparent 0 38%, #000 58% 100%);
  mask-size: 260% 100%;
  mask-position: 0 0;
  transition: mask-position 700ms ease-out;
}
.mk-frame.in .mk-photo {
  mask-position: 100% 0;
}

09:hasベースのフィルタータブ

隠したラジオボタン3個と:has()セレクターだけで、タブを選ぶと他のカテゴリーのタイルがopacityで薄くなりscaleでわずかに縮みます。インジケーターは64px固定幅の正確な倍数でtranslateXするので、タブの数が増えても位置がずれません。

:has()translateXJS 8줄
.ft-frame:has(#ft-2:checked) .ft-tile:not([data-cat="photo"]) {
  opacity: .15;
  transform: scale(.94);
  pointer-events: none;
}
.ft-frame:has(#ft-3:checked) .ft-tile:not([data-cat="video"]) {
  opacity: .15;
  transform: scale(.94);
  pointer-events: none;
}

CSS アニメーション ギャラリーはどこで壊れるか

一番つまずいたのは08番でした。mask-size: 260%のようにマスク画像を要素より大きくすると、mask-positionのパーセンテージが直感と逆向きに効きます。最初はmask-position: 100% 0のほうが写真を隠す値で0 0のほうが写真を見せる値だろうと思い込んでいましたが、実際にレンダリングしたフレームを1枚ずつ確認すると逆でした。100% 0のときはグラデーションの黒い部分(見える部分)が要素全体を覆い、0 0のときは透明な部分が要素全体を覆って写真が完全に隠れていたのです。base側と.in側の値を入れ替えてようやく、スクロールで写真が現れる向きになりました。05番と07番にも別の注意点があります。dialogが開く瞬間に@starting-styleで登場アニメーションを付けたくなりますが、SafariとFirefoxでの対応がまだ揃っていないためこのデモでは使わず、dialogはブラウザ標準のとおり瞬時に開き、::backdropもアニメーションさせない静的な半透明幕1枚に留めています。07番も名前とは裏腹にdocument.startViewTransition()は使っておらず、クリック位置を測ってtransformだけで拡大するFLIP手法にしているのは、ブラウザによって対応や見え方が割れるためです。mask-sizeを100%より大きくする効果を使うときは、まず1枚だけ実際にレンダリングしてmask-positionの両端を目で確認してから値を決めると、同じ思い込みを避けられます。9項目のvanillaコードとzipの解凍パスワードは63vswp74で、この段落をゆっくり読めば見つかります。

アクセシビリティ — reduced-motionで9個が何を残すか

OSで「視差効果を減らす」など動作を減らす設定をオンにすると、9個ともprefers-reduced-motion: reduceでアニメーションとトランジションが止まります。01・02・03・06のように写真やキャプションを見せる演出は、ホバーした瞬間の最終形(覆いが外れた写真、上がりきったキャプション)へ動きなしでそのまま切り替わるため情報は欠けません。04(メイソンリー)と08(スクロールマスク)も同様で、タイルの登場順や境界のやわらかさそのものが情報ではないので、最初から完全に現れた状態へ直接切り替えれば十分です。05のdialogはもともとブラウザ標準の即時表示なので変化はほとんどなく、07はクリック後のtransform遷移だけが止まり、拡大した写真という最終結果は変わりません。09だけは事情が違い、どのタブが選ばれているか自体が情報なので、トランジションを止めてもopacitytransformの値は:has()によって即座に切り替わり、フィルター機能自体は変わらず働きます。写真そのものが動くホバー効果を探しているならCSS画像ホバー効果の記事も参考になり、一覧はCSSカテゴリーからまとめて見られます。clip-pathの詳しい構文はMDNのclip-pathドキュメントに、:has()セレクターの対応状況はMDNの:has()ドキュメントにまとまっています。

FAQ — よくある質問

なぜ写真をそのまま出さずにリビール効果を使うのですか?

必須ではありませんが、写真がゆっくり、あるいは面白い形で現れると訪問者の視線がその瞬間に留まりやすくなります。01〜03のように写真1枚に添える軽い演出から、07のようにクリックまでつながる演出まで、画面の目的に合わせて選べます。

05番と07番はどちらもライトボックスですが、何が違いますか?

05番はネイティブのdialog要素とshowModal()だけで完結し、フォーカスの管理やEscキーで閉じる動作をブラウザが面倒を見てくれます。07番はサムネイルの位置から拡大していく演出を重視した作りで、自前のdivパネルをtransformで動かすぶんコードは長くなりますが、写真がどこから出てきたかが視覚的にわかります。

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

見た目の動きは同じです。vanilla側はSCSS変数を使い、実際に使う経路とキャプチャ用のis-demo経路を1つのファイルに両方持っていますが、React版はis-demoを持たず実際に使う経路だけをコンポーネントのpropsとして受け取る形にしています。

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

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