Webサイトのファーストビューに動きを持たせる実装はいくつもあるけれど、画面全体を小さなタイルに分割して、一枚一枚を独立に3D回転させるというアプローチは、正直なところ最初は「本当にできるのかな」と不安だった。CSSの3D Transforms、backface-visibility の微妙な挙動、数百の要素を同時に動かすパフォーマンス管理。どれか一つでも躓けば、全体が破綻する繊細な実装になる。

結果として動いたものを見たときの嬉しさは、お菓子作りで複雑なレシピが思い通りに焼き上がった瞬間に似ていた。まずは動作するデモを見てもらいたい。

京谷商会トップページで稼働中のタイルフリップ。数秒ごとに異なるパターンで風景写真が入れ替わる。

タイルグリッドの構築

ヒーローの幅と高さを取得し、40px(約1cm)刻みでグリッドに分割する。横1400pxなら35列、高さ500pxなら13行。合計455枚のタイルが生まれる。観葉植物の植え替えで鉢のサイズを計算するときと似た感覚で、コンテナに対してタイルがきれいに敷き詰まるよう Math.ceil で切り上げている。

var TS = 40;
var cols = Math.ceil(hero.offsetWidth / TS);
var rows = Math.ceil(hero.offsetHeight / TS);

各タイルは3層構造になっている。外側の .tileperspective: 800px を設定して奥行き感を与え、中間の .tile-innertransform-style: preserve-3d で3D空間を維持しながら回転する。一番内側に .tile-front.tile-back という2枚の面を配置して、MDNのbackface-visibilityドキュメントにある通り backface-visibility: hidden で裏面を隠す。

.tile { perspective: 800px; overflow: hidden; }
.tile-inner {
  transform-style: preserve-3d;
  transition: transform 0.6s ease-in-out;
}
.tile-inner.flipped { transform: rotateY(180deg); }
.tile-front, .tile-back {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}
.tile-back { transform: rotateY(180deg); }

perspective の値は視点距離を表し、小さいほど遠近感が強まる。800pxは自然な奥行き感と視認性のバランスが良い。W3C CSS Transforms Module Level 2の仕様では、perspective は正の長さ値で、要素の子要素に対する透視投影を定義すると規定されている。

この構造はトランプのカードと同じ原理で、表と裏を背中合わせに貼り付けた状態をCSSで再現している。

背景写真のスライス表示

50枚の風景写真はUnsplash CDNから配信される。ポイントは、全てのタイル面に同じ画像URLを指定しつつ、background-size をヒーロー全体のサイズに、background-position をそのタイルの位置に応じたオフセットにすること。ジグソーパズルの各ピースが全体像の一部を見せているのと同じ仕組みだ。

face.style.backgroundImage = 'url(' + sceneUrl + ')';
face.style.backgroundSize = w + 'px ' + h + 'px';
face.style.backgroundPosition = '-' + (col * 40) + 'px -' + (row * 40) + 'px';

CSS-Tricksのbackground-position解説で紹介されているスプライト技法の応用になる。

90度問題と、その丁寧な解決

実装していて一番手こずったのがこの問題だった。タイルが回転して90度に達した一瞬、表面も裏面も真横を向くため、CSSは両方を「裏面」と判定する。結果としてタイルが消え、背後のヒーロー背景色(紺色)が透けて見える。

最初は「一瞬だし気づかないかも」と思ったのだけれど、河野さんから「ちらつきは品質の妥協」とフィードバックをもらい、きちんと対処することにした。解決策は意外とシンプルで、.tile 要素自体に現在表示中の画像と同じ背景を持たせる。タイルの面が消える90度の瞬間にも、タイル要素の背景が同じ画像を表示しているので、視覚的には何も起きない。

// フリップ完了後、タイル要素の背景を更新
setTimeout(function() {
  tile.style.backgroundImage = 'url(' + nextScene + ')';
  tile.style.backgroundSize = heroSize;
  tile.style.backgroundPosition = tileOffset;
}, FLIP_DURATION);

地味な処理だけれど、こういう一つ一つの丁寧さがアニメーション全体の品質を支えている。なお、この90度問題はMDNのtransform-style: preserve-3dの解説でも触れられているレンダリング順序の制約に起因する。

25種のフリップパターンを数学で生成する

パターン関数は (row, col, totalRows, totalCols) を受け取り、そのタイルの「ステップ番号」を返す。ステップが小さいタイルから順に裏返る。

水平パターンは列番号だけで決まる return cols - 1 - col。放射状パターンは中心からの距離 Math.hypot(row - rows/2, col - cols/2) を使う。渦巻きパターンは Math.atan2 で角度を求めて距離と組み合わせる。

// 渦巻き(時計回り)
function(r, c, R, C) {
  var angle = Math.atan2(r - R/2, c - C/2) / Math.PI;
  var dist = Math.hypot(r - R/2, c - C/2);
  return Math.round(angle * 8 + dist * 2) + 20;
}

数学の授業で習った三角関数がこんなところで役立つとは思わなかった。全パターンのステップ値は0からNORM(列数-1)に正規化するので、パターンの種類によらず所要時間が揃う。web.devのアニメーションガイドでも推奨されている通り、予測可能なタイミングはユーザーの安心感につながる。

パフォーマンスへの配慮

455枚のタイルを同時に動かすと聞くと心配になるかもしれないが、CSSの transformGPUコンポジットレイヤーで処理されるため、レイアウトやペイントの再計算が発生しない。各タイルのフリップは setTimeout で段階的にトリガーされ、一度に全タイルが動き出すことはない。

2026年現在、主要ブラウザはCSS will-change プロパティを完全にサポートしている。タイル要素に will-change: transform を指定することで、ブラウザに事前に「この要素はtransformが変わる」と伝え、コンポジットレイヤーの確保を最適化できる。ただし、MDNのwill-changeドキュメントが注意しているように、全455枚に常時指定するとメモリ消費が増大する。フリップ直前にJavaScriptで付与し、完了後に除去するのが望ましい。

画像のプリロードも工夫していて、次に表示する1枚だけを直前に読み込む遅延方式を採用した。50枚を初回に一括読み込みすると帯域を圧迫するし、ファーストビューの表示速度にも影響する。必要なものを必要なタイミングで。植物の水やりと同じで、やりすぎは根腐れの原因になる。

アクセシビリティへの対応

動きのあるUIには、モーションに敏感なユーザーへの配慮が欠かせない。CSSメディアクエリ prefers-reduced-motion を使えば、OS設定で「視覚効果を減らす」を有効にしているユーザーに対してアニメーションを抑制できる。

@media (prefers-reduced-motion: reduce) {
  .tile-inner {
    transition: none;
  }
}

MDNのprefers-reduced-motion解説によると、前庭障害を持つユーザーにとって過度なアニメーションはめまいや吐き気の原因になりうる。タイルフリップのように画面全体が動く演出は特に影響が大きいため、この設定は必須と言える。JavaScript側でも window.matchMedia('(prefers-reduced-motion: reduce)') を参照して、フリップのインターバルを長めにする、または静止画の切り替えに変更するなどの対応が望ましい。

このタイルフリップと連動するスクロールUI(スティッキーセクションバー)については、前田がスクロール連動ナビゲーションの設計で解説しているので、あわせて読んでもらえると嬉しい。デザイン面の設計思想は河野さんのタイルフリップで魅せるヒーローデザインで語られている。