08반발력 값이 터뜨려 여는 버스트 오픈 전환
화면 중심에 순간적인 반발력 값을 주고 거리에 반비례하게 힘을 약화시키면, 중심 근처 입자가 먼저 세게 튕기고 바깥쪽은 천천히 밀려나며 화면이 폭발하듯 열린다.
쓰임스플래시 종료·성공 모달 등장
BURST_FORCEimpulsegetImageData

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


