모션·인터랙션 / Cadence
Pixel Mosaic
이미지 · 갤러리 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Pixel Mosaic Transition
A slideshow whose transition pixelates: the photo breaks into bigger and bigger blocks,
holds at the blockiest point while the blocks flip one by one (in a random order) to the
next photo, then sharpens again on the new photo. Pixelation draws the photo onto a tiny
offscreen canvas and scales it back up with image smoothing off, so every source pixel
becomes a hard block. Changes every 3.5 s; click the picture (or press → / space) for
the next one.
Dependencies: none (Canvas 2D).
Tweak (CONFIG in the script):
- maxBlock 48 px: block size at the blockiest point (CSS px)
- duration 1.8 s: the whole transition (grow → dissolve → shrink)
- dissolve 0.3: the middle 30 % of the transition, in which the blocks hold at maxBlock
and cross over cell by cell; the first and last 35 % grow / shrink the blocks
- Easing: easeInOutCubic for the growing, the dissolve and the shrinking
- autoEvery 3.5 s between automatic changes (0 = only on click)
Reduced motion: the photos swap directly, with no automatic changes.
Based on "Pixel Mosaic Transition", from The Animations Index, 39 Pixel Mosaic
Transition (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pixel Mosaic Transition</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100svh;
margin: 0;
background: #fff;
}
.stage {
flex: 1;
display: grid;
place-items: center;
padding: 24px clamp(16px, 2.5vw, 32px);
}
.mosaic {
width: min(100%, 900px, (100svh - 120px) * 1.5); /* 3:2, never taller than the screen allows */
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: 6px;
background: #efefef;
cursor: pointer;
}
.mosaic__canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
</style>
</head>
<body>
<main class="stage">
<div class="mosaic" id="mosaic">
<canvas class="mosaic__canvas" id="mosaic-canvas" role="img" aria-label="Photo, click for the next one"></canvas>
</div>
</main>
<script>
/* CONFIG ------------------------------------------------------------------------- */
const CONFIG = {
// Landscape (3:2) photos. Swap in your own: same origin, or a host that sends CORS
// headers (the dissolve reads pixels back from the canvas).
images: [
"https://picsum.photos/seed/cadence-1/1600/1067",
"https://picsum.photos/seed/cadence-2/1600/1067",
"https://picsum.photos/seed/cadence-3/1600/1067",
"https://picsum.photos/seed/cadence-4/1600/1067",
"https://picsum.photos/seed/cadence-5/1600/1067",
],
maxBlock: 48, // px, block size at the blockiest point (in CSS px)
duration: 1.8, // seconds for the whole transition
dissolve: 0.3, // middle share of the transition in which the blocks hold at maxBlock and dissolve cell by cell
autoEvery: 3.5, // seconds between automatic changes, 0 = only on click
};
/* Helpers ------------------------------------------------------------------------- */
const prefersReducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** Device pixel ratio capped at 2: sharp on retina, cheap on 3x phones. */
const getPixelRatio = () => Math.min(window.devicePixelRatio || 1, 2);
/** A stable pseudo-random 0..1 per cell: the cell's dissolve threshold. */
const cellRandom = (x, y) => {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return n - Math.floor(n);
};
const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/** Loads an image (CORS-enabled, so the canvas can read it back). */
function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Could not load image: ${src}`));
image.src = src;
});
}
/**
* requestAnimationFrame loop that pauses when the tab is hidden or `element` is off
* screen, and passes the elapsed time and the delta (seconds, capped at 0.1).
*/
function createRenderLoop(callback, { element = null } = {}) {
let frameId = null;
let pollId = null;
let running = false;
let visible = true;
let lastTime = 0;
let elapsed = 0;
const paused = () => document.hidden || !visible;
function tick(now) {
frameId = null;
if (!running) return;
if (paused()) {
pollId = setTimeout(schedule, 500);
return;
}
const delta = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0;
lastTime = now;
elapsed += delta;
callback(elapsed, delta);
frameId = requestAnimationFrame(tick);
}
function schedule() {
clearTimeout(pollId);
pollId = null;
if (running && frameId === null) {
lastTime = 0;
frameId = requestAnimationFrame(tick);
}
}
document.addEventListener("visibilitychange", schedule);
if (element && "IntersectionObserver" in window) {
new IntersectionObserver((entries) => {
visible = entries[0].isIntersecting;
schedule();
}).observe(element);
}
return {
start() { running = true; schedule(); },
stop() {
running = false;
if (frameId !== null) cancelAnimationFrame(frameId);
clearTimeout(pollId);
frameId = null;
pollId = null;
},
isRunning: () => running,
};
}
/* Setup --------------------------------------------------------------------------- */
const box = document.getElementById("mosaic");
const canvas = document.getElementById("mosaic-canvas");
const ctx = canvas.getContext("2d");
const small = document.createElement("canvas");
const smallCtx = small.getContext("2d", { willReadFrequently: true });
const smallNext = document.createElement("canvas"); // the next photo at the same block size, for the dissolve
const smallNextCtx = smallNext.getContext("2d", { willReadFrequently: true });
const reduced = prefersReducedMotion();
const pixelRatio = getPixelRatio();
let images = [];
let current = 0;
let width = 0;
let height = 0;
function resize() {
width = box.clientWidth;
height = box.clientHeight;
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
draw(1);
}
window.addEventListener("resize", resize);
/* Cover fit of an image into the canvas: the source rectangle to draw. */
function coverRect(image) {
const scale = Math.max(canvas.width / image.width, canvas.height / image.height);
const sw = canvas.width / scale;
const sh = canvas.height / scale;
return [(image.width - sw) / 2, (image.height - sh) / 2, sw, sh];
}
/* Draws the current photo with a given block size (1 = sharp). With mix > 0, each
block shows the next photo once mix passes its own threshold. */
function draw(block, mix = 0) {
const image = images[current];
if (!image) return;
ctx.imageSmoothingEnabled = true;
if (block <= 1) {
ctx.drawImage(image, ...coverRect(image), 0, 0, canvas.width, canvas.height);
return;
}
// Downscale to (canvas / block) pixels, then blow it back up without smoothing.
small.width = Math.max(1, Math.round(width / block));
small.height = Math.max(1, Math.round(height / block));
smallCtx.imageSmoothingEnabled = true;
smallCtx.drawImage(image, ...coverRect(image), 0, 0, small.width, small.height);
if (mix > 0) {
// Dissolve: the next photo at the same block size, copied in cell by cell.
const nextImage = images[(current + 1) % images.length];
smallNext.width = small.width;
smallNext.height = small.height;
smallNextCtx.imageSmoothingEnabled = true;
smallNextCtx.drawImage(nextImage, ...coverRect(nextImage), 0, 0, small.width, small.height);
const from = smallCtx.getImageData(0, 0, small.width, small.height);
const to = smallNextCtx.getImageData(0, 0, small.width, small.height).data;
const px = from.data;
for (let y = 0; y < small.height; y++) {
for (let x = 0; x < small.width; x++) {
if (cellRandom(x, y) >= mix) continue;
const i = (y * small.width + x) * 4;
px[i] = to[i];
px[i + 1] = to[i + 1];
px[i + 2] = to[i + 2];
px[i + 3] = to[i + 3];
}
}
smallCtx.putImageData(from, 0, 0);
}
ctx.imageSmoothingEnabled = false;
ctx.drawImage(small, 0, 0, canvas.width, canvas.height);
}
/* Transition · grow → dissolve → shrink ------------------------------------------ */
let transition = null; // { elapsed, swapped }
function next() {
if (transition || images.length < 2) return;
if (reduced) {
current = (current + 1) % images.length;
draw(1);
return;
}
transition = { elapsed: 0, swapped: false };
loop.start();
}
function frame(time, delta) {
if (!transition) return;
transition.elapsed += delta;
const t = Math.min(transition.elapsed / CONFIG.duration, 1);
// The blocks grow (eased) until `edge`, hold at maxBlock through the dissolve (so the
// cell grid stays put and every cell flips exactly once), then shrink (eased) on the new photo.
const edge = (1 - CONFIG.dissolve) / 2;
let block = CONFIG.maxBlock;
let mix = 0;
if (t < edge) {
block = 1 + easeInOutCubic(t / edge) * (CONFIG.maxBlock - 1);
} else if (t <= 1 - edge) {
mix = easeInOutCubic((t - edge) / CONFIG.dissolve); // share of cells already on the next photo
} else {
if (!transition.swapped) {
transition.swapped = true;
current = (current + 1) % images.length;
}
block = 1 + easeInOutCubic((1 - t) / edge) * (CONFIG.maxBlock - 1);
}
draw(block, mix);
if (t >= 1) {
transition = null;
draw(1);
loop.stop();
}
}
const loop = createRenderLoop(frame, { element: canvas });
box.addEventListener("click", next);
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight" || event.key === " ") next();
});
/* Start --------------------------------------------------------------------------- */
Promise.allSettled(CONFIG.images.map(loadImage)).then((results) => {
images = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
resize();
if (CONFIG.autoEvery > 0 && !reduced) setInterval(next, CONFIG.autoEvery * 1000);
});
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Pixel Mosaic Transition: a slideshow that pixelates, dissolves block by block, and sharpens **Goal:** build a photo slideshow whose transition breaks the picture into growing square blocks, flips the blocks one by one to the next photo at the blockiest point, then sharpens back to a crisp image. ## Visual behaviour 1. **Layout.** One 3:2 picture (max 900 px wide, never taller than the viewport allows), centred on a white page, 6 px rounded corners, light grey `#EFEFEF` behind it. It is drawn on a `<canvas>` with the photo `cover`-fitted. The cursor is a pointer. 2. **Trigger.** The photo changes automatically every 3.5 s, and on click on the picture or pressing → or Space. A trigger during a running transition is ignored. The slideshow wraps around. 3. **The transition (1.8 s total), in three phases:** - **Grow (first 35 %, 0.63 s).** The block size grows from 1 px (sharp) to 48 px (CSS px), eased with `easeInOutCubic`. The photo becomes a coarser and coarser mosaic. - **Dissolve (middle 30 %, 0.54 s).** The block size holds at 48 px, so the grid of cells stays put. Each cell has its own fixed random threshold between 0 and 1; a share `mix` of the cells, eased from 0 to 1 with `easeInOutCubic`, shows the next photo (also pixelated at 48 px), and each cell flips once its threshold is below `mix`. For a moment the mosaic is a patchwork of both photos. - **Shrink (last 35 %, 0.63 s).** The next photo becomes the current one, and the block size shrinks from 48 px back to 1 px, eased with `easeInOutCubic`, until it is sharp. 4. When the transition ends, the new photo is drawn sharp and the animation loop stops until the next trigger. ## How pixelation works - Keep a tiny offscreen canvas of `round(width / block) × round(height / block)` pixels (CSS width / height of the picture). - Draw the photo into it with smoothing **on** (cover-fitted), so each tiny pixel is the average colour of its area. - Draw that tiny canvas back onto the full-size canvas with `imageSmoothingEnabled = false`, so every tiny pixel becomes a hard square block. - **Dissolve:** draw the next photo into a second tiny canvas of the same size, read both with `getImageData`, copy the next photo's pixel into the first for every cell `(x, y)` whose threshold `< mix`, `putImageData`, then scale up as above. - Cell threshold: a stable hash, e.g. `fract(sin(x × 127.1 + y × 311.7) × 43758.5453)`. - Size the full canvas at the device pixel ratio (capped at 2) and use `image-rendering: pixelated` on it. ## Exact values | What | Value | |---|---| | Max block | 48 CSS px | | Duration | 1.8 s | | Phases | grow 0–35 %, dissolve 35–65 %, shrink 65–100 % | | Easing | `easeInOutCubic(t) = t < 0.5 ? 4t³ : 1 − (−2t + 2)³ / 2` (≈ `cubic-bezier(0.65, 0, 0.35, 1)`) for all three phases | | Block size | `1 + ease(phaseProgress) × 47` (grow), 48 (dissolve), `1 + ease(1 − phaseProgress) × 47` (shrink) | | Auto-advance | every 3.5 s | | Frame | 3:2, `min(100%, 900px, (100svh − 180px) × 1.5)`, radius 6 px | | Pixel ratio | `min(devicePixelRatio, 2)` | ## Markup ```html <div class="mosaic"> <!-- aspect-ratio: 3/2; overflow: hidden; border-radius: 6px --> <canvas class="mosaic__canvas"></canvas> <!-- width/height 100%; image-rendering: pixelated --> </div> ``` - Preload all photos before the first draw (load with `crossOrigin = "anonymous"` if they come from another host, otherwise `getImageData` fails on a tainted canvas). - Run the transition in a `requestAnimationFrame` loop with a delta capped at 0.1 s; pause it when the tab is hidden or the canvas is off screen (IntersectionObserver). - Redraw sharp on resize. ## Libraries None: Canvas 2D and `requestAnimationFrame`. (A WebGL fragment shader could do the same with `floor(uv × cells) / cells` sampling and a per-cell hash, if you want it on very large canvases.) ## Responsive and reduced motion - The picture scales with the viewport and stays 3:2; block sizes are in CSS px, so the mosaic looks the same at any size. - `prefers-reduced-motion: reduce`: no automatic changes, and a click or key swaps the photo directly with no pixelation. ## Acceptance criteria - Every 3.5 s (or on click / → / Space), the photo turns into growing blocks, then at 48 px blocks the cells flip to the next photo in a scattered random order, then the new photo sharpens. - Blocks are hard-edged squares, never blurry. - During the dissolve the block grid doesn't move; each cell flips exactly once. - The whole transition takes 1.8 s and ends on a crisp photo. - Clicking during a transition does nothing. - With reduced motion, photos change instantly and only on request.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: e933d44a114c1bd040dd80a5cee59cd92228b899d76e19ddbef33efcc6ca1fc8