First CH Designs
BLK-62Blocks

Async Mosaic Tile Rotation Band

A visual band where color-field tiles on a 12x6 irregular grid keep swapping color and shape on their own cycle (amber tints, grays, a hairline outline), with the heading and CTAs gathered onto a single white plate at the center. No JS, no canvas, no dependencies: negative animation-delay plus per-tile durations create the asynchronous rotation. Only opacity and transform animate, shapes switch via static clip-path, and reduced-motion settles into one still mosaic. On narrow screens the mosaic sits above the text as its own strip instead of behind it.

Added:
2026-09-16
Dependencies:
None
tags
#visual #band #mosaic #grid #clip-path #loop #keyframes #no-image #no-js

Preview

制作の考え方

見た瞬間の印象を、
設計でつくる

色の面積・余白・動きの強さまで決めてから組み立てます。派手さではなく、伝わる速さを基準に一つずつ選びます。

import type { CSSProperties } from "react";

/**
 * モザイクタイル非同期ローテーション帯(BLK-62)
 *
 * 差別化メモ: 既存のタイル演出はどれも「素材を見せるための出現」(写真を分割して1枚へ着地させる
 * ヒーロー・スクロールでマスクが開く実績グリッド・ポインタに反応するcanvas粒子)で、
 * 一度きりか入力駆動。本ブロックは見せる素材を持たず、タイル自身が時間軸で色面と図形を
 * 入れ替え続ける「動く地」で、その上に見出しとCTAを1枚だけ置く帯にした。JSもcanvasも依存も持たない。
 * 節の定型も外した: 英字キッカーなし / 角丸ゼロ(全て角のある面)/ 矢印の付いたテキストリンクなし。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **負の animation-delay で位相をずらすと、JSも交差監視APIも要らない**。
 *   1タイルにフェイスをN枚重ね、同じ @keyframes を animation-delay: -(dur * (f/N + phase)) で
 *   配ると、各フェイスが自分の持ち時間だけ現れる循環になる。負の遅延はアニメーションを
 *   「途中から」始めるので、初期フレームからバラバラの状態で描かれる(マウント後に動き出す
 *   類の演出と違い、SSRの初期HTMLがそのまま完成状態になる)。
 * - **周期をタイルごとに変えないと必ず揃う**。位相だけずらしても duration が同じなら
 *   全タイルが同じ間隔で切り替わり「一斉に動く格子」に見える。duration を数秒ずつ散らすと
 *   最小公倍数が実用上到達しないので、二度と同じ並びに戻らない=非同期に見える。
 * - **図形の切り替えは clip-path を静的に持たせる**。clip-path 自体をアニメーションさせると
 *   paint が走るが、フェイスごとに固定の clip-path を持たせて opacity/transform で入れ替えれば
 *   合成だけで「別の図形に変わった」ように見える(動かすのは2プロパティのまま)。
 * - **不定形グリッドは「敷かないセル」で作る**。12x6 の grid にサイズ違いのタイルを
 *   明示配置し、あえて11セルを空ける。空きは白のまま残るので、余白がそのまま図版になる。
 * - **reduced-motion の静止状態は1枚目のフェイスに寄せる**。フェイスの既定は opacity: 0 で
 *   animation が唯一の表示要因なので、@media 側で animation: none にすると全部消える。
 *   :first-child にだけ opacity: 1 を置いておくと、停止時は「1枚の静止したモザイク」になる。
 */

/** 12列 x 6行のうち、どのセルをどの大きさで敷くか([列, 行, 列幅, 行幅]・1始まり) */
const TILES: [number, number, number, number][] = [
  [1, 1, 2, 2],
  [3, 1, 3, 1],
  [7, 1, 2, 2],
  [9, 1, 2, 1],
  [11, 1, 2, 2],
  [3, 2, 2, 2],
  [9, 2, 1, 2],
  [6, 2, 1, 2],
  [1, 3, 2, 1],
  [5, 3, 1, 2],
  [7, 3, 2, 2],
  [11, 3, 1, 2],
  [1, 4, 1, 2],
  [2, 4, 2, 1],
  [9, 4, 2, 1],
  [12, 4, 1, 1],
  [2, 5, 3, 2],
  [6, 5, 2, 2],
  [9, 5, 2, 2],
  [11, 5, 1, 1],
  [12, 5, 1, 2],
  [8, 6, 1, 1],
  [5, 5, 1, 1],
];

/**
 * 色面。アンバーの濃淡+グレー。index 4 だけは塗らずhairlineの枠にする
 * (枠だけのフェイスを増やすと、同時刻に「空いて見えるタイル」が増えて地が痩せる)。
 */
const FACE_COLORS = [
  "bg-amber-600",
  "bg-amber-100",
  "bg-gray-200",
  "bg-amber-300",
  "border border-amber-600",
  "bg-gray-800",
  "bg-amber-500",
  "bg-gray-300",
  "bg-amber-200",
  "bg-amber-400",
];
const OUTLINE_FACES = new Set([4]);

/** 図形。すべて直線だけで切る(円・角丸は使わない) */
const FACE_SHAPES = [
  "none",
  "polygon(0 0, 100% 0, 0 100%)",
  "polygon(0 0, 100% 0, 100% 100%)",
  "polygon(100% 0, 100% 100%, 0 100%)",
  "polygon(0 0, 100% 100%, 0 100%)",
  "polygon(0 0, 100% 0, 100% 52%, 0 52%)",
  "polygon(0 48%, 100% 48%, 100% 100%, 0 100%)",
  "polygon(20% 20%, 80% 20%, 80% 80%, 20% 80%)",
  "polygon(0 0, 100% 0, 100% 60%, 60% 100%, 0 100%)",
];

/** 1タイルが循環するフェイス数 */
const FACES = 3;

/**
 * タイル i のフェイス f の見た目と時間。乱数を使わず index から決めるので、
 * サーバとクライアントで必ず同じ値になる(hydration がずれない)。
 */
function face(i: number, f: number) {
  const colorIndex = (i * 3 + f * 7) % FACE_COLORS.length;
  const outline = OUTLINE_FACES.has(colorIndex);
  // 周期はタイルごとに 4.8s〜10.3s へ散らす(揃わせないための本体)
  const duration = 4.8 + (i % 6) * 1.1;
  // 位相もタイルごとにずらし、負の遅延で「途中から」始める
  const phase = (i * 0.37) % 1;
  return {
    colorClass: FACE_COLORS[colorIndex],
    clipPath: outline ? "none" : FACE_SHAPES[(i * 7 + f * 4) % FACE_SHAPES.length],
    duration,
    delay: -(duration * (f / FACES + phase)),
  };
}

export default function MosaicRotateBand() {
  return (
    <div className="relative w-full max-w-5xl overflow-hidden border border-gray-200 bg-white">
      {/*
        動く地。情報を持たないので支援技術からは隠す。
        狭幅では「帯の上半分を占める1本のモザイク」として流れに置き(md 未満)、
        md 以上でだけ絶対配置にして全面へ回す。狭幅で全面に敷くと、中央の一枚が
        帯の幅をほぼ埋めてしまい地が数pxしか見えなくなるため。
      */}
      <div
        aria-hidden="true"
        className="pointer-events-none grid h-44 grid-cols-12 grid-rows-6 gap-1.5 p-1.5 sm:h-52 md:absolute md:inset-0 md:h-auto md:gap-2 md:p-2"
      >
        {TILES.map(([col, row, w, h], i) => (
          <span
            key={i}
            className="relative block"
            style={{
              gridColumn: `${col} / span ${w}`,
              gridRow: `${row} / span ${h}`,
            }}
          >
            {Array.from({ length: FACES }, (_, f) => {
              const { colorClass, clipPath, duration, delay } = face(i, f);
              return (
                <span
                  key={f}
                  className={`mrb-face absolute inset-0 block ${colorClass}`}
                  style={
                    {
                      clipPath,
                      animationDuration: `${duration.toFixed(2)}s`,
                      animationDelay: `${delay.toFixed(2)}s`,
                    } as CSSProperties
                  }
                />
              );
            })}
          </span>
        ))}
      </div>

      {/*
        中央の一枚。md 以上では白い余白(box-shadow のスプレッド)を1周ぶん持たせ、
        タイルの端が枠線に接して線が太って見えるのを防ぐ。狭幅では地の上に載らないので
        枠も白地も持たせず、hairlineは帯の外枠1本だけにする。
      */}
      <div className="relative flex px-6 pt-8 pb-9 sm:px-8 md:min-h-[460px] md:items-center md:justify-center md:py-20">
        <div className="w-full md:max-w-md md:border md:border-gray-200 md:bg-white md:px-10 md:py-12 md:shadow-[0_0_0_10px_#fff]">
          <p className="flex items-center gap-3 text-xs font-bold text-amber-600">
            <span aria-hidden="true" className="h-px w-8 bg-amber-600" />
            制作の考え方
          </p>

          <h2 className="mt-4 text-[clamp(1.35rem,3.4vw,2rem)] leading-[1.55] font-bold tracking-tight text-gray-900">
            見た瞬間の印象を、
            <br />
            設計でつくる
          </h2>

          <p className="mt-5 text-[15px] leading-[1.95] text-gray-700">
            色の面積・余白・動きの強さまで決めてから組み立てます。派手さではなく、伝わる速さを基準に一つずつ選びます。
          </p>

          <div className="mt-8 flex flex-wrap items-center gap-x-7 gap-y-4 border-t border-gray-200 pt-7">
            <a
              href="#"
              className="bg-amber-600 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
            >
              制作の進め方を見る
            </a>
            <a
              href="#"
              className="text-sm font-semibold text-gray-900 underline decoration-gray-300 decoration-1 underline-offset-4 transition-colors hover:decoration-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
            >
              料金の考え方
            </a>
          </div>
        </div>
      </div>

      {/* このブロック内で完結する keyframes と reduced-motion ガード */}
      <style>{`
        .mrb-face {
          opacity: 0;
          animation-name: mrb-face-cycle;
          animation-timing-function: ease-in-out;
          animation-iteration-count: infinite;
        }
        .mrb-face:first-child { opacity: 1; }
        @keyframes mrb-face-cycle {
          0%          { opacity: 0; transform: scale(0.88); }
          6%, 30%     { opacity: 1; transform: scale(1); }
          39%, 100%   { opacity: 0; transform: scale(1.04); }
        }
        @media (prefers-reduced-motion: reduce) {
          .mrb-face { animation: none; }
        }
      `}</style>
    </div>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/mosaic-rotate-band.json