Canvas × CSSで作るモザイク解除アニメーションの実装

荒いドットから画像が解像するモザイク解除アニメーションをCanvas要素で実装する方法を解説しています。

CanvasのgetImageData APIを使って画像の色情報をピクセル単位で取得し、重ねた複数のレイヤー要素に反映することで、段階的に解像度が上がっていく演出を実現しています。色のサンプリングとセルへの反映を中心に解説します。

実装例

以下が実装例です。要素がビューポートに入るとモザイク解除アニメーションが始まります。

モザイク解除アニメーション

今回のデモはスクロールで発火しますが、他にもファーストビューやボタンクリックなど様々な場面で活用可能です。派手さを抑えながら、Webサイトをリッチに見せる演出として効果的かもしれません。

ざっくりとしたアニメーションの仕組みは次の通りです。

  • モザイクの元となるレイヤーを用意する(HTML)
  • 各レイヤーにモザイク用のセルを作成する(CSS, JavaScript)
  • キャンバスに画像を縮小描画する(JS)
  • ピクセル単位の色を取得する(JS)
  • 取得した色をセルに反映する(JS)
  • スクロールで要素が画面に入ったタイミングで、各レイヤーを時差を持って非表示にする(JS)

HTML

HTMLは次の通りです。

index.html
<div class="mosaic-img">
<div class="mosaic-img__layer" data-index="1"></div>
<div class="mosaic-img__layer" data-index="2"></div>
<div class="mosaic-img__layer" data-index="3"></div>
<div class="mosaic-img__layer" data-index="4"></div>
<img src="https://picsum.photos/id/28/1200/700" alt="">
</div>

ラッパー要素のmosaic-imgの中に、レイヤー要素を4つと表示する画像を配置しています。レイヤー要素がモザイクの元となる要素で、data-index属性でインデックスを付与しています。

なお、data-indexはCSSのセレクタ用途のみで、JavaScriptはquerySelectorAllで取得した順序(HTMLの記述順)でレイヤーを扱っています。

CSS

CSSは次の通りです。

style.css
.mosaic-img {
margin: 30px auto;
width: 65%;
aspect-ratio: 12 / 7;
position: relative;
// JS 実行前のチラつき防止(GSAP と揃える)
opacity: 0;
}
.mosaic-img__layer {
position: absolute;
inset: 0;
display: grid;
pointer-events: none;
}
.mosaic-img__layer[data-index="1"] {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, 1fr);
z-index: 4;
}
.mosaic-img__layer[data-index="2"] {
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(6, 1fr);
z-index: 3;
}
.mosaic-img__layer[data-index="3"] {
grid-template-columns: repeat(16, 1fr);
grid-template-rows: repeat(12, 1fr);
z-index: 2;
}
.mosaic-img__layer[data-index="4"] {
grid-template-columns: repeat(32, 1fr);
grid-template-rows: repeat(24, 1fr);
z-index: 1;
}

ラッパー要素を基準に、レイヤー要素を絶対配置で全画面表示にし、グリッドレイアウトでモザイク用のセルを作成しています。data-index属性のインデックスの数字が小さいほど前面に表示されます。各レイヤーの詳細は次の通りです。

index列数行数セル数z-index
143124
286483
316121922
432247681

前面のレイヤーほどセルの数が少なく(画質が荒い)、背面のレイヤーほどセルの数が多く(画質が鮮明)なるようにしています。下記はインデックス3のセルのグリッドレイアウトです。

インデックス3のセルのグリッドレイアウト

JavaScript

JavaScriptは次の通りです。少し長いコードのため、アコーディオンで折りたたんでいます。展開してご確認ください。

script.jsの全体像
script.js
// ---- 定数 ----
const SELECTORS = {
wrapper: ".mosaic-img",
mosaicContainer: ".mosaic-img__layer",
mosaicImg: ".mosaic-img img",
cell: ".mosaic-img__cell",
};
const LEVEL_CONFIGS = [
{ cols: 4, rows: 3 },
{ cols: 8, rows: 6 },
{ cols: 16, rows: 12 },
{ cols: 32, rows: 24 },
];
const ANIMATION = {
fadeInDuration: 0.5,
fadeOutDuration: 0.25,
staggerDelay: 0.2,
};
// ---- DOM 取得 ----
const mosaicContainers = [
...document.querySelectorAll(SELECTORS.mosaicContainer),
];
const mosaicImg = document.querySelector(SELECTORS.mosaicImg);
gsap.registerPlugin(ScrollTrigger);
// ---- モザイクセルの生成 ----
function buildCells(container, { cols, rows }) {
const fragment = document.createDocumentFragment();
const total = cols * rows;
for (let i = 0; i < total; i++) {
const cell = document.createElement("div");
cell.className = SELECTORS.cell.slice(1);
fragment.appendChild(cell);
}
container.appendChild(fragment);
}
// ---- 画像からピクセル色をサンプリングしてセルに適用 ----
function applyColorSampling(img) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
mosaicContainers.forEach((container, levelIndex) => {
const { cols, rows } = LEVEL_CONFIGS[levelIndex];
// canvas を各レベルの解像度にリサイズして再利用
canvas.width = cols;
canvas.height = rows;
ctx.drawImage(img, 0, 0, cols, rows);
// ドット単位のピクセルデータの配列を取得
const imageData = ctx.getImageData(0, 0, cols, rows);
const data = imageData.data;
const cells = container.querySelectorAll(SELECTORS.cell);
cells.forEach((cell, index) => {
const pixelIndex = index * 4;
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const a = data[pixelIndex + 3] / 255;
cell.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${a})`;
});
});
}
// 画像の CORS 再読み込みが終わるまで待つ(load / error のどちらかで解決)
function waitForMosaicImgReload(img) {
return new Promise((resolve) => {
img.addEventListener("load", resolve, { once: true });
img.addEventListener(
"error",
() => {
console.error("モザイク用画像の読み込みに失敗しました");
resolve();
},
{ once: true },
);
img.src = img.src;
});
}
// ---- モザイクエフェクトの初期化 ----
async function initMosaicEffect() {
if (!mosaicContainers.length || !mosaicImg) return;
mosaicContainers.forEach((container, index) => {
buildCells(container, LEVEL_CONFIGS[index]);
});
mosaicImg.crossOrigin = "anonymous";
await waitForMosaicImgReload(mosaicImg);
if (mosaicImg.naturalWidth > 0) {
applyColorSampling(mosaicImg);
}
}
// ---- GSAP アニメーション ----
function initAnimation() {
const wrapper = document.querySelector(SELECTORS.wrapper);
if (!wrapper || !mosaicContainers.length) return;
const tl = gsap.timeline({
scrollTrigger: {
trigger: wrapper,
start: "25% bottom",
once: true,
},
});
// 初期設定(ラッパーを非表示に)
tl.set(wrapper, { opacity: 0 }, 0);
tl.to(wrapper, {
opacity: 1,
duration: ANIMATION.fadeInDuration,
ease: "power2.out",
});
mosaicContainers.forEach((layer) => {
tl.to(
layer,
{ opacity: 0, duration: ANIMATION.fadeOutDuration },
`<+=${ANIMATION.staggerDelay}`,
);
});
}
// ---- エントリーポイント ----
async function init() {
await initMosaicEffect();
initAnimation();
}
init();

モザイクセルの生成

CSSで作成したグリッドレイアウトに対して、モザイクセルのdiv要素を生成します。引数で渡ってくるcontainerがレイヤー要素で、colsrowsがグリッドレイアウトの列数と行数です。

script.js
function buildCells(container, { cols, rows }) {
const fragment = document.createDocumentFragment();
const total = cols * rows;
for (let i = 0; i < total; i++) {
const cell = document.createElement("div");
cell.className = SELECTORS.cell.slice(1);
fragment.appendChild(cell);
}
container.appendChild(fragment);
}

cols * rowsで計算した合計セル数の分だけ、モザイクセルを生成してレイヤー要素に追加しています。

ポイントとしては、ループの中で毎回appendChildを実行するのではなく、一度fragmentにまとめてから最後にappendChildを実行することで、パフォーマンスを向上させています。

キャンバスに画像を縮小描画する

次に画像を各レイヤーの解像度に合わせてキャンバスへ縮小描画します。

script.js
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

まず、canvas要素を作成して、getContextでコンテキストを取得します。次にmosaicContainersをループして、各レイヤーの解像度にリサイズして再利用します。imgが画像要素です。

script.js
mosaicContainers.forEach((container, levelIndex) => {
const { cols, rows } = LEVEL_CONFIGS[levelIndex];
// canvas を各レベルの解像度にリサイズして再利用
canvas.width = cols;
canvas.height = rows;
ctx.drawImage(img, 0, 0, cols, rows);
});

drawImageとはCanvasのAPIの一つで、画像を描画するためのメソッドです。画像要素を特定の位置とサイズで描画します。これで次のように画像が各レイヤーの解像度にリサイズされます。

画像が各レイヤーの解像度にリサイズされた例
各レイヤーのイメージ(20倍)

drawImageで幅と高さのキャンバスに画像を縮小描画すると、各ピクセルに相当する色情報だけが保存されます。 たとえば最前面のレイヤー(index 1)の場合、横4×縦3の12ピクセル分の色になります。ブラウザが内部でリサンプリング(線形補間など)を行い、各ピクセルに最適な色を自動的に割り当ててくれます。

ピクセル単位の色を取得する

縮小描画したcanvasから、getImageDataでピクセル単位の色情報を取得します。

script.js
const imageData = ctx.getImageData(0, 0, cols, rows);
const data = imageData.data;

getImageDataは指定範囲のピクセルデータを返すCanvasのAPIで、戻り値のdataUint8ClampedArrayという配列です。各ピクセルの色情報がRGBAの順番で1次元配列として並んでいます。

// data の中身のイメージ(横4×縦3 = 12ピクセルの場合)
[
R, G, B, A, // 1ピクセル目
R, G, B, A, // 2ピクセル目
R, G, B, A, // 3ピクセル目
/* ... 最前面のレイヤー(index 1)の場合は12ピクセル目まで続く ... */
]

つまり、配列の長さはcols * rows * 4になります。1ピクセルあたり4つの値(R, G, B, A)が連続して格納されているのがポイントです。

取得した色をセルに反映する

取得した色情報を、先ほど生成したモザイクセルに反映します。

script.js
const cells = container.querySelectorAll(SELECTORS.cell);
cells.forEach((cell, index) => {
const pixelIndex = index * 4;
const r = data[pixelIndex];
const g = data[pixelIndex + 1];
const b = data[pixelIndex + 2];
const a = data[pixelIndex + 3] / 255;
cell.style.backgroundColor = `rgba(${r}, ${g}, ${b}, ${a})`;
});

レイヤー内のセルを取得し、各セルに対応するピクセルの色をbackgroundColorに設定しています。

ポイントはpixelIndex = index * 4の部分です。前述のとおりdataは1ピクセルあたり4つの値が連続して並ぶ1次元配列なので、index番目のセルに対応する色は配列のindex * 4番目から始まる4つの値(R, G, B, A)になります。

これでセルの並び順とピクセルの並び順が一致し、画像を縮小したときの色がそのままモザイクとして再現されます。各レイヤーの解像度(セル数)に応じて荒さが変わるため、前面ほど粗く、背面ほど鮮明な見た目になります。

スクロールで各レイヤーを時差を持って非表示にする

最後に、要素が画面に入ったタイミングで各レイヤーを時差を持って非表示にします。今回はアニメーションにGSAPと、スクロール連動を担うScrollTriggerプラグインを使用しています。

script.js
gsap.registerPlugin(ScrollTrigger);
function initAnimation() {
const wrapper = document.querySelector(SELECTORS.wrapper);
if (!wrapper || !mosaicContainers.length) return;
const tl = gsap.timeline({
scrollTrigger: {
trigger: wrapper,
start: "25% bottom",
once: true,
},
});
tl.set(wrapper, { opacity: 0 }, 0);
tl.to(wrapper, {
opacity: 1,
duration: ANIMATION.fadeInDuration,
ease: "power2.out",
});
mosaicContainers.forEach((layer) => {
tl.to(
layer,
{ opacity: 0, duration: ANIMATION.fadeOutDuration },
`<+=${ANIMATION.staggerDelay}`,
);
});
}

gsap.timelinescrollTriggerオプションでスクロール連動を設定し、指定した位置にラッパー要素が到達したタイミングでタイムラインを再生する仕組みです。

タイムラインの中身は次のような構成です。

  1. ラッパー要素をopacity: 0にして初期化
  2. ラッパー要素をopacity: 1にフェードインさせて画像と全レイヤーを表示
  3. 前面のレイヤーから順番にopacity: 0へフェードアウト

3つ目のループ部分が、各レイヤーを時差を持って非表示にする処理です。gsap.toの第3引数に"<+=0.2"のような相対位置を渡すことで、ひとつ前のアニメーション開始から0.2秒後に次のアニメーションが始まるよう調整しています。

記法意味
<直前のアニメーションの開始位置
<+=0.2直前のアニメーション開始から0.2秒後

前面のレイヤー(粗いモザイク)から順にフェードアウトすることで、背面の鮮明なレイヤーが徐々に見えてくる演出になります。最終的にすべてのレイヤーが消えると、元の画像が表示される仕組みです。

まとめ

CanvasのgetImageDataを使った、画像のピクセル色をサンプリングしてモザイクセルに反映する実装方法を解説しました。ポイントは次のとおりです。

  • drawImageで画像をレイヤーごとの解像度に縮小描画する
  • getImageDataでピクセル単位の色情報を1次元配列として取得する
  • 配列の並び順(RGBA × ピクセル数)に合わせてセルへ色を反映する
  • GSAPのタイムラインをScrollTriggerと連動させ、前面のレイヤーから時差を持ってフェードアウトさせる

アニメーションの表現は、レイヤーの数やフェードアウトのタイミングを工夫することで、さまざまに調整できます。ぜひ色々と試してみてください。

この記事を書いた人

写真:ブール 代表 西川雅人

Masato Nishikawa

ウェブ制作会社・デザイナー様向けに、コーディングやWordPress実装を承っているフリーランスです。繁忙期のサポートや、継続的な外注先をお探しでしたら、お気軽にお声がけください。

コーディングや
WordPress実装の
ご相談を承っています。

新規制作から運用フェーズの修正まで柔軟に対応します。
お見積りや実装可否の確認など、お気軽にご連絡ください。