06각속도 값이 나선으로 지우는 궤도 와이프 전환
화면을 덮은 입자마다 극좌표 각도와 반지름을 갖게 하고 각속도 값만큼 각도를 늘리며 반지름을 줄이면, 나선을 그리며 안쪽부터 지워져 다음 화면이 드러난다.
쓰임풀스크린 메뉴 열기·이미지 갤러리 전환
ANGULAR_SPEEDREVEAL_SPANgetImageData

<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 원작 · zip 에 SCSS·React 포함 · 화면전환 애니메이션 효과 9가지 — 파티클 물리로 만들기
이 데모가 속한 글
화면전환 애니메이션 효과 9가지 — 파티클 물리로 만들기먼저 소용돌이 흡입 반지름 값을 재고, 그 값으로 입자가 몇 프레임 만에 중심에 모이는지 정한다 — 자기수렴 전환은 인력 세기 값으로 수렴 속도를 계산하고, 궤도 나선 전환은 각속도 값으로 다음 화면이 드러나는 시점 이 글의 데모 전부 보기


