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