画面 切り替え アニメーション css 9パターン
画面 切り替え アニメーション css をキャンバス粒子だけで作った9パターンです。まず渦の吸引半径の値を測り、その値で粒子が何フレームで中心に集まるかを決める。磁場収束遷移は引力の強さの値から収束速度を計算し、軌道螺旋遷移は角速度の値で次の画面が現れるタイミングを決める。
01 particle-field-transition-vortex-suck-in
02 particle-field-transition-magnetic-converge-swap
03 particle-field-transition-gravity-collapse-fall
04 particle-field-transition-flock-swarm-handoff
05 particle-field-transition-spring-scatter-settle
06 particle-field-transition-orbit-spiral-wipe
07 particle-field-transition-noise-drift-dissolve
08 particle-field-transition-repulsion-burst-open
09 particle-field-transition-lerp-trail-reform
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 渦の吸引力の値が画面を吸い込む遷移
- 02 引力の強さの値が組み立てる磁場収束遷移
- 03 重力加速度の値が崩す崩壊遷移
- 04 群れ規則の値が隊形を変える群れ引き継ぎ遷移
- 05 バネ剛性の値が散らして落ち着かせる遷移
- 06 角速度の値が螺旋で消す軌道ワイプ遷移
- 07 ノイズフィールドの値が漂わせる溶解遷移
- 08 反発力の値が弾けて開くバーストオープン遷移
- 09 補間tの値が軌跡を残して再形成する遷移
01は中心へ向かう単純な吸引力から始まり、02と04は二段階の集合に置き換え、03はそのまま真下へ崩れ落ち、05と09は振動と軌跡のコピーを足し、06と07は直線運動を角度とノイズに置き換え、08は外向きの爆発で締めくくります。ひとつ進むたびに物理の要素がひとつ増える並びです。
css の transition プロパティを使わない9枚のキャンバス画面
9枚のタイルはそれぞれ独立した<canvas>要素で、独自のrequestAnimationFrameループを回しています。画面 切り替え アニメーション css というと1枚の絵が滑らかに動く印象がありますが、実際は毎フレームRENDER(p)関数が呼ばれ、1,960個の粒子の位置をひとつずつ計算して矩形を描き直しているだけで、ブラウザがCSSの値を補間しているわけではありません。タイルをクリックしても、フォーカスしてEnterやSpaceを押しても、ウィンドウをリサイズしても、同じRENDER(p)がその時点の進行度で再描画するだけなので、この9枚のどこにもtransitionendイベントは存在しません。触れる前は2秒周期のプレビューループが自動で流れていて、最初のクリックやキー操作でそこから抜け出します。
1,960個の粒子が壊れる境界 — パーティクル トランジションが動く前の落とし穴
9パターンすべてが56列×35行、合計1,960個の同じグリッドを使い、drawParticleRectが毎フレーム角丸の矩形を1,960回描きます。つまりどんな引力や振動の計算をする前から、1フレームあたり1,960回の塗りが確定しているということです。09だけは軌跡のためにTRAIL_SAMPLES = 4個の残像コピーを重ねるので、実質7,840回まで跳ね上がります。もうひとつの落とし穴は描画の土台です。01・03・06・07・08は先にctx.drawImage(sceneBCanvas, 0, 0)で次の画面全体を描いてから、退場する粒子をその上に重ねて消していくので、粒子が消えた場所には最初から次の画面が見えています。このdrawImage呼び出しを02・04・05・09に足すと、遷移が始まった瞬間に次の画面が透けて見えてしまいます。あの4つは次の画面を1枚の絵として持たず、1,960個の色の値をひとつずつ切り替えているだけだからです。ふたつの作り方を混ぜると、早く見えすぎるか、いつまでも見えないかのどちらかになります。
9パターンを面積・強度・動いたフレームで比べる
以下の数値はこの9枚のキャンバスを実際に1フレームずつ計測した結果です。累積面積は480×300の画面のうちどれだけの範囲が変化したか、平均強度は変化した場所のピクセル差の平均、動いたフレームは23回のフレーム間差分のうち実際に変化していた回数です。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 渦の吸引 | 58.3% | 61.1 | 22/23 |
| 02 | 磁場収束 | 73.5% | 74.1 | 23/23 |
| 03 | 重力崩壊 | 60.1% | 35.3 | 19/23 |
| 04 | 群れ引き継ぎ | 70.6% | 51.3 | 23/23 |
| 05 | バネ振動 | 64.4% | 43.0 | 22/23 |
| 06 | 軌道螺旋ワイプ | 69.0% | 55.1 | 21/23 |
| 07 | ノイズ漂流 | 56.7% | 27.7 | 23/23 |
| 08 | 反発バースト | 56.9% | 31.8 | 23/23 |
| 09 | 補間軌跡再形成 | 64.1% | 40.0 | 23/23 |
getImageDataの裏にある9通りの計算式
PRECOMPUTE()は最初に1度だけ実行され、退場画面と次の画面をそれぞれオフスクリーンのキャンバスに描いてgetImageDataで読み取り、1,960個のグリッドそれぞれについて2つの色と、キャンバス中心からの距離・角度を保存します。ここから先、各RENDER(p)は0から1へ進む進行度pという数値ひとつだけを受け取り、それぞれの見出しに書いた式に沿って位置・色・不透明度を計算し直します。
01渦の吸引力の値が画面を吸い込む遷移
吸引力の値1.6は(1 − p)^1.6という式の指数で、それぞれの粒子とキャンバス中心との距離をゼロへ縮めます。ひねりの値3.4はp²を角度に掛けるので、半径が縮むほど回転が加速していきます。退場する画面のピクセルを粒子として散らし、この計算で渦の中心に吸い込むと、下に描かれた次の画面が現れます。メニュー切り替えやモーダルを閉じる演出に向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39601;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#93a4ca';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const PULL_STRENGTH = 1.6; // 변형 01 전값(후 3.2) — 반지름 감소 지수. 클수록 (1-p)^n 이 가팔라 더 적은 p 안에 중심(r=0)에 닿는다
const TWIST = 3.4; // 반지름이 줄수록(=(1-p)^n) 같은 p 증분이 더 큰 각도 변화를 만들도록 p^2 로 가속 — 각운동량 보존형 소용돌이 근사
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const shrink = Math.pow(1 - p, PULL_STRENGTH);
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const rp = q.r0 * shrink;
const ang = q.ang0 + TWIST * p * p;
const cx2 = CENTER_X + rp * Math.cos(ang);
const cy2 = CENTER_Y + rp * Math.sin(ang);
const alpha = clampN(1 - p * 1.08, 0, 1);
const scale = 0.35 + 0.65 * shrink;
const w = q.cellW * scale + gap, h = q.cellH * scale + gap;
drawParticleRect(ctx, cx2 - w / 2, cy2 - h / 2, w, h, q.ca, alpha);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './VortexSuckIn.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39601;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#93a4ca';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const PULL_STRENGTH = 1.6; // 변형 01 전값(후 3.2) — 반지름 감소 지수. 클수록 (1-p)^n 이 가팔라 더 적은 p 안에 중심(r=0)에 닿는다
const TWIST = 3.4; // 반지름이 줄수록(=(1-p)^n) 같은 p 증분이 더 큰 각도 변화를 만들도록 p^2 로 가속 — 각운동량 보존형 소용돌이 근사
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const shrink = Math.pow(1 - p, PULL_STRENGTH);
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const rp = q.r0 * shrink;
const ang = q.ang0 + TWIST * p * p;
const cx2 = CENTER_X + rp * Math.cos(ang);
const cy2 = CENTER_Y + rp * Math.sin(ang);
const alpha = clampN(1 - p * 1.08, 0, 1);
const scale = 0.35 + 0.65 * shrink;
const w = q.cellW * scale + gap, h = q.cellH * scale + gap;
drawParticleRect(ctx, cx2 - w / 2, cy2 - h / 2, w, h, q.ca, alpha);
}
}
export default function VortexSuckIn(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}01 · 渦の吸引力の値が画面を吸い込む遷移 — 計測 累積面積 58.349% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const shrink = Math.pow(1 - p, PULL_STRENGTH);
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const rp = q.r0 * shrink;
const ang = q.ang0 + TWIST * p * p;
const cx2 = CENTER_X + rp * Math.cos(ang);
const cy2 = CENTER_Y + rp * Math.sin(ang);
const alpha = clampN(1 - p * 1.08, 0, 1);
const scale = 0.35 + 0.65 * shrink;
const w = q.cellW * scale + gap, h = q.cellH * scale + gap;
drawParticleRect(ctx, cx2 - w / 2, cy2 - h / 2, w, h, q.ca, alpha);
}
}
02引力の強さの値が組み立てる磁場収束遷移
前半では各粒子が固定のランダムな方向へ26〜72px漂います。中間点を過ぎると引力の強さの値2.2が元のグリッド位置へ引き戻し、同時に色は徐々にではなく一段階で退場画面の値から次の画面の値へ切り替わります。粒子の裏に次の画面全体が絵として描かれることは一度もなく、1,960個の色の値だけが存在します。ランディングヒーローの登場やダッシュボードウィジェットの読み込みに向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39602;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#90c8b5';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const MAGNETIC_STRENGTH = 2.2; // 수렴(복귀) 구간의 스냅 지수 — 클수록 절반 이후 제자리로 더 빠르게 당겨진다(수렴 속도)
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
let env;
if (p < 0.5){
const u = p * 2;
env = u * (2 - u);
} else {
const v = (p - 0.5) * 2;
env = 1 - (1 - Math.pow(1 - v, MAGNETIC_STRENGTH));
}
const dirAng = q.seed2 * Math.PI * 2;
const mag = 26 + q.seed * 46;
const x = q.cx + Math.cos(dirAng) * mag * env;
const y = q.cy + Math.sin(dirAng) * mag * env;
const col = p < 0.5 ? q.ca : q.cb;
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './MagneticConvergeSwap.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39602;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#90c8b5';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const MAGNETIC_STRENGTH = 2.2; // 수렴(복귀) 구간의 스냅 지수 — 클수록 절반 이후 제자리로 더 빠르게 당겨진다(수렴 속도)
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
let env;
if (p < 0.5){
const u = p * 2;
env = u * (2 - u);
} else {
const v = (p - 0.5) * 2;
env = 1 - (1 - Math.pow(1 - v, MAGNETIC_STRENGTH));
}
const dirAng = q.seed2 * Math.PI * 2;
const mag = 26 + q.seed * 46;
const x = q.cx + Math.cos(dirAng) * mag * env;
const y = q.cy + Math.sin(dirAng) * mag * env;
const col = p < 0.5 ? q.ca : q.cb;
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
export default function MagneticConvergeSwap(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}02 · 引力の強さの値が組み立てる磁場収束遷移 — 計測 累積面積 73.549% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
let env;
if (p < 0.5){
const u = p * 2;
env = u * (2 - u);
} else {
const v = (p - 0.5) * 2;
env = 1 - (1 - Math.pow(1 - v, MAGNETIC_STRENGTH));
}
const dirAng = q.seed2 * Math.PI * 2;
const mag = 26 + q.seed * 46;
const x = q.cx + Math.cos(dirAng) * mag * env;
const y = q.cy + Math.sin(dirAng) * mag * env;
const col = p < 0.5 ? q.ca : q.cb;
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
03重力加速度の値が崩す崩壊遷移
重力加速度の値2.0はfall = 2.0 · lp² · (H · 0.95)という、s = ½at²をそのまま使った式で落下距離を決めます。ただしlp自体が行ごとに最大で進行の半分だけ遅れて始まるので、上の行はすぐ落ち始めるのに下の行はしばらく待ちます。この遅延のせいで9パターンの中で唯一、動いたフレームが23分の19と最も少なくなります。上から順に消えていくので、設定パネルを閉じる動作や警告画面の終了に向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39603;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#c3d6e2';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const GRAV_ACCEL = 2.0; // 낙하거리 = GRAV_ACCEL * lp^2 * 화면높이 — s = 0.5·a·t^2 닫힌형
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const rowDelay = (q.gy / ROWS) * 0.5;
const lp = clampN((p - rowDelay) / (1 - rowDelay + 1e-6), 0, 1);
const fall = GRAV_ACCEL * lp * lp * (H * 0.95);
const wobble = Math.sin(lp * Math.PI * 3 + q.seed * 6.28) * 3 * lp;
const x = q.cx + wobble;
const y = q.cy + fall;
const alpha = clampN(1 - lp * 1.1, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './GravityCollapseFall.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39603;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#c3d6e2';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const GRAV_ACCEL = 2.0; // 낙하거리 = GRAV_ACCEL * lp^2 * 화면높이 — s = 0.5·a·t^2 닫힌형
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const rowDelay = (q.gy / ROWS) * 0.5;
const lp = clampN((p - rowDelay) / (1 - rowDelay + 1e-6), 0, 1);
const fall = GRAV_ACCEL * lp * lp * (H * 0.95);
const wobble = Math.sin(lp * Math.PI * 3 + q.seed * 6.28) * 3 * lp;
const x = q.cx + wobble;
const y = q.cy + fall;
const alpha = clampN(1 - lp * 1.1, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
export default function GravityCollapseFall(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}03 · 重力加速度の値が崩す崩壊遷移 — 計測 累積面積 60.113% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const rowDelay = (q.gy / ROWS) * 0.5;
const lp = clampN((p - rowDelay) / (1 - rowDelay + 1e-6), 0, 1);
const fall = GRAV_ACCEL * lp * lp * (H * 0.95);
const wobble = Math.sin(lp * Math.PI * 3 + q.seed * 6.28) * 3 * lp;
const x = q.cx + wobble;
const y = q.cy + fall;
const alpha = clampN(1 - lp * 1.1, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
04群れ規則の値が隊形を変える群れ引き継ぎ遷移
粒子はそれぞれ6つのクラスターのひとつに属し、分離の重みの値1.0が自分のクラスター半径からの距離を決めます。この距離は進行の中間点でピークになり、再び戻っていきます。色のほうは02と同じくp = 0.5で一段階だけ切り替わります。6つのクラスターがそれぞれ違う半径で動くので、群れがいったん隊形を崩して組み直すように見え、タブ切り替えやオンボーディングのステップ移動に向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39604;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#d2c7a4';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const SEPARATION_WEIGHT = 1.0; // 변형 04 전값(후 0.4) — 군집 분리 오프셋 배율. 낮을수록 대형이 촘촘해진다
const CLUSTER_COUNT = 6; // 무리를 나누는 고정 군집 수
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const cluster = Math.floor(q.seed * CLUSTER_COUNT) % CLUSTER_COUNT;
const clusterAng = (cluster / CLUSTER_COUNT) * Math.PI * 2 + q.seed2 * 0.4;
const env = 4 * p * (1 - p);
const mag = SEPARATION_WEIGHT * (28 + cluster * 7 + q.seed2 * 14);
const x = q.cx + Math.cos(clusterAng) * mag * env;
const y = q.cy + Math.sin(clusterAng) * mag * env;
const col = p < 0.5 ? q.ca : q.cb;
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './FlockSwarmHandoff.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39604;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#d2c7a4';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const SEPARATION_WEIGHT = 1.0; // 변형 04 전값(후 0.4) — 군집 분리 오프셋 배율. 낮을수록 대형이 촘촘해진다
const CLUSTER_COUNT = 6; // 무리를 나누는 고정 군집 수
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const cluster = Math.floor(q.seed * CLUSTER_COUNT) % CLUSTER_COUNT;
const clusterAng = (cluster / CLUSTER_COUNT) * Math.PI * 2 + q.seed2 * 0.4;
const env = 4 * p * (1 - p);
const mag = SEPARATION_WEIGHT * (28 + cluster * 7 + q.seed2 * 14);
const x = q.cx + Math.cos(clusterAng) * mag * env;
const y = q.cy + Math.sin(clusterAng) * mag * env;
const col = p < 0.5 ? q.ca : q.cb;
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
export default function FlockSwarmHandoff(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}04 · 群れ規則の値が隊形を変える群れ引き継ぎ遷移 — 計測 累積面積 70.649% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const cluster = Math.floor(q.seed * CLUSTER_COUNT) % CLUSTER_COUNT;
const clusterAng = (cluster / CLUSTER_COUNT) * Math.PI * 2 + q.seed2 * 0.4;
const env = 4 * p * (1 - p);
const mag = SEPARATION_WEIGHT * (28 + cluster * 7 + q.seed2 * 14);
const x = q.cx + Math.cos(clusterAng) * mag * env;
const y = q.cy + Math.sin(clusterAng) * mag * env;
const col = p < 0.5 ? q.ca : q.cb;
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
05バネ剛性の値が散らして落ち着かせる遷移
バネ剛性の値13と減衰値0.32は、教科書に載っている減衰振動の式ωd = ωn√(1 − ζ²)にそのまま入ります。各粒子の変位はexp(−ζωn·p) · sin(ωd·p)という減衰するサイン波そのもので、色のほうも02や04のように中間点で切り替わるのではなく、減衰項がゼロへ近づくにつれて連続的に次の色へ寄っていきます。少しのオーバーシュートが機械的ではなく物理的に見えるので、カードスワイプ遷移や通知バッジの入れ替えに向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39605;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#ff9ebb';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const SPRING_K = 13; // 각진동수(rad) — 클수록 정착 전 진동이 촘촘하다
const DAMPING = 0.32; // 감쇠비(zeta) — 클수록 빨리 잦아든다
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
const wd = SPRING_K * Math.sqrt(Math.max(0.05, 1 - DAMPING * DAMPING));
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const wdi = wd * (0.85 + q.seed2 * 0.3);
const decay = Math.exp(-DAMPING * SPRING_K * p);
const env = decay * Math.sin(wdi * p);
const dirAng = q.seed * Math.PI * 2;
const mag = 16 + q.seed2 * 26;
const x = q.cx + Math.cos(dirAng) * mag * env;
const y = q.cy + Math.sin(dirAng) * mag * env;
const mixT = clampN(1 - decay, 0, 1);
const col = [
q.ca[0] + (q.cb[0] - q.ca[0]) * mixT,
q.ca[1] + (q.cb[1] - q.ca[1]) * mixT,
q.ca[2] + (q.cb[2] - q.ca[2]) * mixT
];
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './SpringScatterSettle.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39605;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#ff9ebb';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const SPRING_K = 13; // 각진동수(rad) — 클수록 정착 전 진동이 촘촘하다
const DAMPING = 0.32; // 감쇠비(zeta) — 클수록 빨리 잦아든다
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
const wd = SPRING_K * Math.sqrt(Math.max(0.05, 1 - DAMPING * DAMPING));
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const wdi = wd * (0.85 + q.seed2 * 0.3);
const decay = Math.exp(-DAMPING * SPRING_K * p);
const env = decay * Math.sin(wdi * p);
const dirAng = q.seed * Math.PI * 2;
const mag = 16 + q.seed2 * 26;
const x = q.cx + Math.cos(dirAng) * mag * env;
const y = q.cy + Math.sin(dirAng) * mag * env;
const mixT = clampN(1 - decay, 0, 1);
const col = [
q.ca[0] + (q.cb[0] - q.ca[0]) * mixT,
q.ca[1] + (q.cb[1] - q.ca[1]) * mixT,
q.ca[2] + (q.cb[2] - q.ca[2]) * mixT
];
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
export default function SpringScatterSettle(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}05 · バネ剛性の値が散らして落ち着かせる遷移 — 計測 累積面積 64.357% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
const wd = SPRING_K * Math.sqrt(Math.max(0.05, 1 - DAMPING * DAMPING));
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const wdi = wd * (0.85 + q.seed2 * 0.3);
const decay = Math.exp(-DAMPING * SPRING_K * p);
const env = decay * Math.sin(wdi * p);
const dirAng = q.seed * Math.PI * 2;
const mag = 16 + q.seed2 * 26;
const x = q.cx + Math.cos(dirAng) * mag * env;
const y = q.cy + Math.sin(dirAng) * mag * env;
const mixT = clampN(1 - decay, 0, 1);
const col = [
q.ca[0] + (q.cb[0] - q.ca[0]) * mixT,
q.ca[1] + (q.cb[1] - q.ca[1]) * mixT,
q.ca[2] + (q.cb[2] - q.ca[2]) * mixT
];
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, 1);
}
}
06角速度の値が螺旋で消す軌道ワイプ遷移
角速度の値4.0は各粒子を自分固有の半径のままangle0 + 4.0·pという角度で回します。固定の公開幅の値3.0をこの角速度で割ったspanPが、それぞれの粒子がフェードを始めるまでの持ち時間を決めます。この速度だと持ち時間は進行の0.75で、中心に近い粒子ほど早く閾値を超えるので、まっすぐではなく内側から外側へ向かう螺旋として消えていきます。フルスクリーンメニューを開く動作や画像ギャラリーの遷移に向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39606;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#c89a8f';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const ANGULAR_SPEED = 4.0; // 변형 06 전값(후 8.0) — 회전 각속도. 클수록 노출 문턱(threshold)에 더 빨리 도달해 와이프가 짧게 끝난다
const REVEAL_SPAN = 3.0; // 반지름 0~최대에 걸쳐 배분하는 회전각 예산(고정)
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const spanP = clampN(REVEAL_SPAN / ANGULAR_SPEED, 0.2, 0.8);
const fadeWidth = 0.15;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const ang = q.ang0 + ANGULAR_SPEED * p;
const x = CENTER_X + q.r0 * Math.cos(ang);
const y = CENTER_Y + q.r0 * Math.sin(ang);
const threshold = (q.r0 / (MAX_R || 1)) * spanP;
const lp = clampN((p - threshold) / fadeWidth, 0, 1);
const alpha = clampN(1 - lp * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './OrbitSpiralWipe.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39606;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#c89a8f';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const ANGULAR_SPEED = 4.0; // 변형 06 전값(후 8.0) — 회전 각속도. 클수록 노출 문턱(threshold)에 더 빨리 도달해 와이프가 짧게 끝난다
const REVEAL_SPAN = 3.0; // 반지름 0~최대에 걸쳐 배분하는 회전각 예산(고정)
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const spanP = clampN(REVEAL_SPAN / ANGULAR_SPEED, 0.2, 0.8);
const fadeWidth = 0.15;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const ang = q.ang0 + ANGULAR_SPEED * p;
const x = CENTER_X + q.r0 * Math.cos(ang);
const y = CENTER_Y + q.r0 * Math.sin(ang);
const threshold = (q.r0 / (MAX_R || 1)) * spanP;
const lp = clampN((p - threshold) / fadeWidth, 0, 1);
const alpha = clampN(1 - lp * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
export default function OrbitSpiralWipe(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}06 · 角速度の値が螺旋で消す軌道ワイプ遷移 — 計測 累積面積 69.013% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const spanP = clampN(REVEAL_SPAN / ANGULAR_SPEED, 0.2, 0.8);
const fadeWidth = 0.15;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const ang = q.ang0 + ANGULAR_SPEED * p;
const x = CENTER_X + q.r0 * Math.cos(ang);
const y = CENTER_Y + q.r0 * Math.sin(ang);
const threshold = (q.r0 / (MAX_R || 1)) * spanP;
const lp = clampN((p - threshold) / fadeWidth, 0, 1);
const alpha = clampN(1 - lp * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
07ノイズフィールドの値が漂わせる溶解遷移
ドリフト速度の値46がそれぞれの粒子の移動距離を決めますが、進む方向は各粒子自身の画面座標で読んだ低周波のサイン波とコサイン波を足し合わせて作っています。隣り合う粒子はほぼ同じ方向へ、離れた粒子はまったく違う方向へ漂うので、本物の2Dノイズフィールドの簡易な代用になっています。距離はp²で伸びるので溶け方は進むほど加速し、9パターンの中で面積も強度もいちばん小さいので、ヒーロー背景の切り替えやダークモード切替のように、効果が強すぎるとコンテンツと喧嘩してしまう場面に向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39607;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#b9a0ff';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const DRIFT_SPEED = 46; // px 스케일 — 노이즈 방향으로의 표류 속도 계수
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const noiseAngle = Math.sin(q.cx * 0.045) + Math.cos(q.cy * 0.05) + q.seed * 1.2 - 0.6;
const speed = DRIFT_SPEED * (0.6 + q.seed2 * 0.8);
const dist = speed * p * p;
const x = q.cx + Math.cos(noiseAngle) * dist;
const y = q.cy + Math.sin(noiseAngle) * dist;
const alpha = clampN(1 - p * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './NoiseDriftDissolve.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39607;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#b9a0ff';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const DRIFT_SPEED = 46; // px 스케일 — 노이즈 방향으로의 표류 속도 계수
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const noiseAngle = Math.sin(q.cx * 0.045) + Math.cos(q.cy * 0.05) + q.seed * 1.2 - 0.6;
const speed = DRIFT_SPEED * (0.6 + q.seed2 * 0.8);
const dist = speed * p * p;
const x = q.cx + Math.cos(noiseAngle) * dist;
const y = q.cy + Math.sin(noiseAngle) * dist;
const alpha = clampN(1 - p * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
export default function NoiseDriftDissolve(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}07 · ノイズフィールドの値が漂わせる溶解遷移 — 計測 累積面積 56.724% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const noiseAngle = Math.sin(q.cx * 0.045) + Math.cos(q.cy * 0.05) + q.seed * 1.2 - 0.6;
const speed = DRIFT_SPEED * (0.6 + q.seed2 * 0.8);
const dist = speed * p * p;
const x = q.cx + Math.cos(noiseAngle) * dist;
const y = q.cy + Math.sin(noiseAngle) * dist;
const alpha = clampN(1 - p * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
08反発力の値が弾けて開くバーストオープン遷移
反発力の値5,200を、それぞれの粒子の中心からの初期距離に20を足した値で割るので、中心ちょうどにいた粒子は260という強い力を、外側の粒子はずっと弱い力を受け取ります。中心に近いほど強く弾かれるということです。さらに3乗のイーズアウトが力のほとんどを最初の数フレームで使い切ります。粒子は元の角度を保ったまま半径だけ外へ伸びるので、散らばるというより爆発するように見え、スプラッシュ終了や成功モーダルの登場に向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39608;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#93a4ca';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const BURST_FORCE = 5200; // 거리 반비례 충격량 계수 — BURST_FORCE/(r0+20) 로 중심 근처일수록 크게 튄다
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const ease = 1 - Math.pow(1 - p, 3);
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const impulse = BURST_FORCE / (q.r0 + 20);
const dist = impulse * ease;
const newR = q.r0 + dist;
const x = CENTER_X + newR * Math.cos(q.ang0);
const y = CENTER_Y + newR * Math.sin(q.ang0);
const alpha = clampN(1 - p * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './RepulsionBurstOpen.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39608;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#93a4ca';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const BURST_FORCE = 5200; // 거리 반비례 충격량 계수 — BURST_FORCE/(r0+20) 로 중심 근처일수록 크게 튄다
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const ease = 1 - Math.pow(1 - p, 3);
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const impulse = BURST_FORCE / (q.r0 + 20);
const dist = impulse * ease;
const newR = q.r0 + dist;
const x = CENTER_X + newR * Math.cos(q.ang0);
const y = CENTER_Y + newR * Math.sin(q.ang0);
const alpha = clampN(1 - p * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
export default function RepulsionBurstOpen(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}08 · 反発力の値が弾けて開くバーストオープン遷移 — 計測 累積面積 56.887% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.clearRect(0, 0, W, H);
ctx.drawImage(sceneBCanvas, 0, 0);
const gap = 1;
const ease = 1 - Math.pow(1 - p, 3);
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const impulse = BURST_FORCE / (q.r0 + 20);
const dist = impulse * ease;
const newR = q.r0 + dist;
const x = CENTER_X + newR * Math.cos(q.ang0);
const y = CENTER_Y + newR * Math.sin(q.ang0);
const alpha = clampN(1 - p * 1.05, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, q.ca, alpha);
}
}
09補間tの値が軌跡を残して再形成する遷移
各粒子は固定の横ぶれ幅を持ち、最大38px、粒子ごとに大きさが違います。毎フレーム、進行の0.055ずつずらした4つのコピー(TRAIL_SAMPLES = 4)を自分の分身として描き、先頭以外の3つは不透明度41%・27%・13%へ徐々に薄くなりながら、色も退場画面の値から次の画面の値へ連続的に変化します。この4倍の描画コストは前半の落とし穴の節で触れた通り9パターン中もっとも重く、残していく軌跡はルート変更やカルーセルスライドに向いています。

<main class="stage">
<div class="rv is-demo" tabindex="0" role="button" aria-label="リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え">
<canvas class="rv__cv" width="480" height="300"></canvas>
<p class="rv__hint">クリックまたはスペースキーで切り替え</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.rv {
position: relative;
// 스테이지 패딩(clamp(8px,4vw,24px), 200~600px 구간 4vw 선형)을 뺀 가용 높이(100vh-8vw)의
// 1.6배가 가용 폭(92vw)보다 항상 작다(320·480 폭 실측) — 높이가 실제 상한이라 폭을 그 값으로 직접 잡는다
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
cursor: pointer;
box-shadow: $shadow-raised;
outline-offset: 4px;
&:focus-visible {
outline: 3px solid $subject-ink;
}
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: $sp-3;
bottom: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
// 정지 프레임만 렌더 — 자동 루프는 JS 의 matchMedia 분기가 생략한다 (BD5)
.rv {
cursor: pointer;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
outline-offset: 4px;
}
.rv:focus-visible {
outline: 3px solid #17141a;
}
.rv__cv {
display: block;
width: 100%;
height: 100%;
}
.rv__hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.rv {
cursor: pointer;
}
}(function(){
'use strict';
function makeLcg(seed){
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39609;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#90c8b5';
function clampN(v, a, b){ return v < a ? a : (v > b ? b : v); }
function easeInOut(t){ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g, x, y, w, h, r){
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g){
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g, x, y, w, h, rgb, alpha){
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const TRAIL_BULGE = 38; // 시작=끝 좌표가 같은 입자가 그리는 베지어 굽이 반경 계수
const TRAIL_SAMPLES = 4; // 잔상 샘플 수
let particles = [];
let sceneBCanvas = null;
let MAX_R = 0;
function sampleCanvas(cv){
return cv.getContext('2d').getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(){
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(a.getContext('2d'));
paintGrid(b.getContext('2d'));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const bulgeAng = q.seed * Math.PI * 2;
const bulgeMag = TRAIL_BULGE * (0.5 + q.seed2);
for (let s = 0; s < TRAIL_SAMPLES; s++){
const t = clampN(p - s * 0.055, 0, 1);
const bulge = 2 * t * (1 - t);
const x = q.cx + Math.cos(bulgeAng) * bulgeMag * bulge;
const y = q.cy + Math.sin(bulgeAng) * bulgeMag * bulge;
const col = [
q.ca[0] + (q.cb[0] - q.ca[0]) * t,
q.ca[1] + (q.cb[1] - q.ca[1]) * t,
q.ca[2] + (q.cb[2] - q.ca[2]) * t
];
const alpha = s === 0 ? 1 : clampN(0.55 - s * 0.14, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, alpha);
}
}
}
const rootEl = document.querySelector('.rv');
const cvEl = document.querySelector('.rv__cv');
const ctx = cvEl.getContext('2d');
const ARIA_LIST = 'リスト画面 — クリックまたはスペースキーでグリッド画面に切り替え';
const ARIA_GRID = 'グリッド画面 — クリックまたはスペースキーでリスト画面に切り替え';
function updateAria(prog){
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(){ rootEl.classList.remove('is-demo'); }
function play(target){
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now){
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t){
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
function onActivate(e){
if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return;
if (e.type === 'keydown') e.preventDefault();
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now){
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
requestAnimationFrame(frame);
}
}
function onResize(){ RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from 'react';
import styles from './LerpTrailReform.module.scss';
interface Particle {
gx: number; gy: number; cx: number; cy: number;
cellW: number; cellH: number;
ca: number[]; cb: number[];
r0: number; ang0: number;
seed: number; seed2: number;
}
function makeLcg(seed: number): () => number {
let s = seed >>> 0;
return function(){
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const W = 480, H = 300;
const CENTER_X = W / 2, CENTER_Y = H / 2;
const COLS = 56, ROWS = 35;
const SEED = 39609;
const rnd = makeLcg(SEED);
const PANEL_BG = '#fff7e6';
const INK = '#17141a';
const ACCENT = '#90c8b5';
function clampN(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function roundRectPath(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function paintList(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const rows = 6, padX = 22, padY = 18;
const rowH = (H - padY * 2) / rows;
for (let i = 0; i < rows; i++){
const y = padY + i * rowH + rowH * 0.12;
const h = rowH * 0.76;
g.fillStyle = ACCENT;
roundRectPath(g, padX, y, W - padX * 2, h, 10);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(padX + h / 2, y + h / 2, h * 0.32, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
const barX = padX + h + 14;
const barW = W - padX * 2 - h - 14;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, barX, y + h * 0.22, barW * 0.78, h * 0.2, 4);
g.fill();
roundRectPath(g, barX, y + h * 0.56, barW * 0.5, h * 0.16, 4);
g.fill();
g.globalAlpha = 1;
}
}
function paintGrid(g: CanvasRenderingContext2D): void {
g.fillStyle = PANEL_BG;
g.fillRect(0, 0, W, H);
const cols = 3, rows = 2, pad = 20, gap = 14;
const tileW = (W - pad * 2 - gap * (cols - 1)) / cols;
const tileH = (H - pad * 2 - gap * (rows - 1)) / rows;
for (let r = 0; r < rows; r++){
for (let c = 0; c < cols; c++){
const x = pad + c * (tileW + gap), y = pad + r * (tileH + gap);
g.fillStyle = ACCENT;
roundRectPath(g, x, y, tileW, tileH, 12);
g.fill();
g.fillStyle = INK;
g.globalAlpha = 0.85;
g.beginPath();
g.arc(x + tileW - 14, y + 14, 7, 0, Math.PI * 2);
g.fill();
g.globalAlpha = 1;
g.fillStyle = PANEL_BG;
g.globalAlpha = 0.9;
roundRectPath(g, x + 10, y + tileH - 20, tileW - 20, 10, 4);
g.fill();
g.globalAlpha = 1;
}
}
}
function drawParticleRect(g: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, rgb: number[], alpha: number): void {
if (alpha <= 0.01) return;
g.globalAlpha = alpha;
g.fillStyle = 'rgb(' + (rgb[0] | 0) + ',' + (rgb[1] | 0) + ',' + (rgb[2] | 0) + ')';
g.fillRect(x, y, w, h);
g.globalAlpha = 1;
}
const TRAIL_BULGE = 38; // 시작=끝 좌표가 같은 입자가 그리는 베지어 굽이 반경 계수
const TRAIL_SAMPLES = 4; // 잔상 샘플 수
let particles: Particle[] = [];
let sceneBCanvas!: HTMLCanvasElement;
let MAX_R = 0;
let ctx!: CanvasRenderingContext2D;
function ctx2d(cv: HTMLCanvasElement): CanvasRenderingContext2D {
const c = cv.getContext('2d');
if (!c) throw new Error('no 2d context');
return c;
}
function sampleCanvas(cv: HTMLCanvasElement): Uint8ClampedArray {
return ctx2d(cv).getImageData(0, 0, W, H).data;
}
function PRECOMPUTE(): void {
const a = document.createElement('canvas'); a.width = W; a.height = H;
const b = document.createElement('canvas'); b.width = W; b.height = H;
paintList(ctx2d(a));
paintGrid(ctx2d(b));
const da = sampleCanvas(a);
const db = sampleCanvas(b);
sceneBCanvas = b;
const cellW = W / COLS, cellH = H / ROWS;
const pts: Particle[] = [];
MAX_R = 0;
for (let j = 0; j < ROWS; j++){
for (let i = 0; i < COLS; i++){
const cx = (i + 0.5) * cellW, cy = (j + 0.5) * cellH;
const px = clampN(Math.round(cx), 0, W - 1);
const py = clampN(Math.round(cy), 0, H - 1);
const idx = (py * W + px) * 4;
const ca = [da[idx], da[idx + 1], da[idx + 2]];
const cb = [db[idx], db[idx + 1], db[idx + 2]];
const dx = cx - CENTER_X, dy = cy - CENTER_Y;
const r0 = Math.sqrt(dx * dx + dy * dy);
const ang0 = Math.atan2(dy, dx);
if (r0 > MAX_R) MAX_R = r0;
pts.push({ gx: i, gy: j, cx, cy, cellW, cellH, ca, cb, r0, ang0, seed: rnd(), seed2: rnd() });
}
}
particles = pts;
}
function RENDER(p: number): void {
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const bulgeAng = q.seed * Math.PI * 2;
const bulgeMag = TRAIL_BULGE * (0.5 + q.seed2);
for (let s = 0; s < TRAIL_SAMPLES; s++){
const t = clampN(p - s * 0.055, 0, 1);
const bulge = 2 * t * (1 - t);
const x = q.cx + Math.cos(bulgeAng) * bulgeMag * bulge;
const y = q.cy + Math.sin(bulgeAng) * bulgeMag * bulge;
const col = [
q.ca[0] + (q.cb[0] - q.ca[0]) * t,
q.ca[1] + (q.cb[1] - q.ca[1]) * t,
q.ca[2] + (q.cb[2] - q.ca[2]) * t
];
const alpha = s === 0 ? 1 : clampN(0.55 - s * 0.14, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, alpha);
}
}
}
export default function LerpTrailReform(){
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
const rootEl: HTMLDivElement = root;
const cvEl: HTMLCanvasElement = cv;
ctx = ctx2d(cvEl);
const ARIA_LIST = '리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환';
const ARIA_GRID = '그리드 화면 — 클릭하거나 스페이스바로 리스트 화면으로 전환';
function updateAria(prog: number): void {
rootEl.setAttribute('aria-label', prog < 0.5 ? ARIA_LIST : ARIA_GRID);
}
let progress = 0;
let playing = false, playStart = 0, playFrom = 0, playTo = 0;
const PLAY_MS = 600;
function human(): void { rootEl.classList.remove('is-demo'); }
function play(target: number): void {
playing = true; playFrom = progress; playTo = target; playStart = performance.now();
}
function stepPlay(now: number): void {
const t = clampN((now - playStart) / PLAY_MS, 0, 1);
progress = playFrom + (playTo - playFrom) * easeInOut(t);
RENDER(progress);
updateAria(progress);
if (t >= 1){ playing = false; progress = playTo; }
}
const DRIVE_PERIOD_MS = 2000;
const driveStart = performance.now();
function triangle(t: number): number {
const u = (t % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
return u < 0.5 ? u * 2 : 2 - u * 2;
}
const reduceMQ = window.matchMedia('(prefers-reduced-motion: reduce)');
let rafId = 0;
function onActivate(e: Event): void {
if (e.type === 'keydown'){
const ke = e as KeyboardEvent;
if (ke.key !== 'Enter' && ke.key !== ' ') return;
ke.preventDefault();
}
human();
const target = progress < 0.5 ? 1 : 0;
if (reduceMQ.matches){
progress = target;
RENDER(progress);
updateAria(progress);
} else {
play(target);
}
}
function frame(now: number): void {
if (!reduceMQ.matches){
if (playing){
stepPlay(now);
} else if (rootEl.classList.contains('is-demo')){
progress = triangle(now - driveStart);
RENDER(progress);
updateAria(progress);
}
rafId = requestAnimationFrame(frame);
}
}
function onResize(): void { RENDER(progress); }
rootEl.addEventListener('click', onActivate);
rootEl.addEventListener('keydown', onActivate);
window.addEventListener('resize', onResize);
PRECOMPUTE();
updateAria(progress);
if (reduceMQ.matches){
RENDER(progress);
} else {
rafId = requestAnimationFrame(frame);
}
return () => {
rootEl.removeEventListener('click', onActivate);
rootEl.removeEventListener('keydown', onActivate);
window.removeEventListener('resize', onResize);
if (rafId) cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.rv} is-demo`} tabIndex={0} role="button" aria-label="리스트 화면 — 클릭하거나 스페이스바로 그리드 화면으로 전환">
<canvas ref={cvRef} className={styles.rv__cv} width={480} height={300} />
<p className={styles.rv__hint}>클릭하거나 스페이스바로 전환해 보세요</p>
</div>
</main>
);
}09 · 補間tの値が軌跡を残して再形成する遷移 — 計測 累積面積 64.065% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function RENDER(p){
ctx.fillStyle = PANEL_BG;
ctx.fillRect(0, 0, W, H);
const gap = 1;
for (let k = 0; k < particles.length; k++){
const q = particles[k];
const bulgeAng = q.seed * Math.PI * 2;
const bulgeMag = TRAIL_BULGE * (0.5 + q.seed2);
for (let s = 0; s < TRAIL_SAMPLES; s++){
const t = clampN(p - s * 0.055, 0, 1);
const bulge = 2 * t * (1 - t);
const x = q.cx + Math.cos(bulgeAng) * bulgeMag * bulge;
const y = q.cy + Math.sin(bulgeAng) * bulgeMag * bulge;
const col = [
q.ca[0] + (q.cb[0] - q.ca[0]) * t,
q.ca[1] + (q.cb[1] - q.ca[1]) * t,
q.ca[2] + (q.cb[2] - q.ca[2]) * t
];
const alpha = s === 0 ? 1 : clampN(0.55 - s * 0.14, 0, 1);
const w = q.cellW + gap, h = q.cellH + gap;
drawParticleRect(ctx, x - w / 2, y - h / 2, w, h, col, alpha);
}
}
}
画面 切り替え アニメーション 素材 の数値を変えると感触はどう変わるか
以下の3つはグリッドも粒子数もイベント処理も変えず、定数をひとつだけ動かしたものです。感触の違いはすべてその1つの数値から来ています。
| バリエーション | 変更した値 | 感触の変化 |
|---|---|---|
| 渦の吸引力の値を調整する | 01 PULL_STRENGTH 1.6 → 3.2 |
指数を2倍にすると(1 − p)^nがより狭いpの範囲でゼロへ落ちるので、粒子は早く中心に着き、吸い込みは短く鋭くなります。 |
| 群れの分離の重みの値を調整する | 04 SEPARATION_WEIGHT 1.0 → 0.4 |
重みを0.4に落とすとクラスターごとの移動半径が縮み、6つの集団が離れすぎずまとまったまま隊形を組み直すようになります。 |
| 軌道の角速度の値を調整する | 06 ANGULAR_SPEED 4.0 → 8.0 |
角速度を2倍にすると同じpでも倍の角度が進むので、spanP(REVEAL_SPAN / ANGULAR_SPEED)は0.75から0.375へ縮み、螺旋ワイプはおよそ半分の時間で終わります。 |
reduced-motion が9パターンから消すもの — アクセシビリティの注意点
すべてのタイルはwindow.matchMedia('(prefers-reduced-motion: reduce)')を最初に一度だけ確認します。条件に一致すると、自動で流れる2秒のプレビューは最初から始まらず、クリックやEnter・Spaceの操作でprogressが目標値へ直接飛び、RENDER(progress)が一度だけ呼ばれます。画面は切り替わりますが、そのあいだを補うトゥイーンはありません。フォーカスはtabindex="0"とrole="button"のおかげでタイルにちゃんと乗り、aria-labelはモーション設定に関係なくRENDERが呼ばれるたびに2枚の画面の説明を切り替えるので、動きを見なくてもキーボード操作だけでどちらの画面が表示されているか分かります。
hfj36935はこの記事のどのパターンのzipを開くときも同じで、9本のRENDER(p)はそれぞれ250行前後あるファイルの一部にすぎず、PRECOMPUTE()や共通のpaintList・paintGrid、SCSS版とReact版はすべてzipの中に入っています。
メニューを閉じる・ダッシュボードを読み込む・ルートを変える — 実際に使う場面
01の渦の吸い込みはメニューを閉じる操作やモーダルを閉じる演出の裏でよく馴染みます。直前まで見ていた内容がただ消えるのではなく、ひとつの点へ吸い寄せられていくので、閉じるという操作が何か具体的なものに起きたように感じられます。02の磁場収束は逆の役割が得意です。ダッシュボードウィジェットやランディングヒーローが「フェードインした」のではなく「無から組み立てられた」ように見せたいとき、ランダムな方向から粒子が定位置へ吸い寄せられる動きは、なめらかな1本のカーブより効果的です。
09の軌跡を残す再形成はシングルページアプリのルート変更やカルーセルスライドに向いています。残していく軌跡が「どちらからどちらへ移動したか」という方向の感覚を伝えるからで、これは単純なカットやフェードにはできないことです。この3つのどれもページ全体を再読み込みする必要はなく、それぞれがすでに自分のリスト表示とグリッド表示のために描いている2枚のキャンバスだけで完結します。
リビール遷移・グリッチ遷移との違い
画像 切り替え エフェクト 9種 — キャンバスが計算するリビールは480×300の画面全体を横切る1本の境界線をテストしています。斜めのエッジ、放射状のスイープ、ピクセルごとの閾値判定など、常にひとつの境界がどちら側が古い画面でどちら側が新しい画面かを決めています。この記事の9パターンには境界という考え方自体がありません。1,960個の独立した粒子オブジェクトがそれぞれ自分の位置・色・位相を持ち、「見えてくる」のはそのうち十分な数が移動し終えたり消えたりした結果でしかありません。パーティクル トランジションのように境界ではなく粒子の数で作る動きを探しているなら、この記事の方が近いはずです。
css グリッチ エフェクト 9種の切り替え演出はgetImageData・putImageDataでバイト単位の処理にとどまっています。すべてのピクセルのチャンネル値を読み、新しいバイト配列として書き戻すので、1行を3ピクセルずらすような操作はできても、それがひとつのオブジェクトになることはありません。この記事はそのピクセル単位の精度をあえて手放し、480×300の画面をちょうど1,960個の矩形に分解しています。粗い分だけそれぞれが自分の速度・角度・バネの状態を持てるようになっていて、これは1バイトの情報だけでは表現できません。読み込み演出のほうが近いならローディング アニメーション js 9パターン — 物理演算で作るが同じ粒子の考え方をスピナーに当てています。キャンバスもJavaScriptも使わずに済ませたいなら、【JS最小・CSSだけ】css ページ遷移 アニメーション9選がview-transition-nameとキーフレームだけで作った版です。
パーティクル トランジションを組み込む前に確認しておきたいよくある質問
画面 切り替え アニメーション フリー のライブラリと組み合わせて使えますか?
いいえ、この9パターンにライブラリもWebGLもCDNスクリプトも使われていません。それぞれがgetImageData、ひとつのシード生成器(makeLcg)、requestAnimationFrameだけで、1枚のHTMLファイルの中で完結しています。
03だけひとかたまりで落ちずに上から順に崩れるのはなぜですか?
各行のローカル進行度を(行番号 / 35) · 0.5だけ遅らせているためです。上の行がすでに半分落ちきった頃に、ようやく下の行が距離を落下速度へ変え始めます。この遅延のせいで、動いたフレームが23分の21を下回る唯一のパターンにもなっています。
480×300のキャンバスをページ全体の背景に広げても大丈夫ですか?
大丈夫ですが、先にCOLSとROWSを新しいピクセル数に合わせて再計算してください。この大きさでもすでに1,960回の矩形塗りを1フレームごとに確定させているので、グリッドを見直さないままページ全体に広げると、60fpsでrequestAnimationFrameが処理しきれる量を超えてしまいます。
サイト外の参考資料として、MDNのprefers-reduced-motionのリファレンスは、9枚のタイルがプレビューループを始める前に確認しているメディアクエリそのものを解説しています。









