GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

화면전환 애니메이션 효과 9가지 — 파티클 물리로 만들기

먼저 소용돌이 흡입 반지름 값을 재고, 그 값으로 입자가 몇 프레임 만에 중심에 모이는지 정한다 — 자기수렴 전환은 인력 세기 값으로 수렴 속도를 계산하고, 궤도 나선 전환은 각속도 값으로 다음 화면이 드러나는 시점을 정한다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

흡입·수렴·붕괴·인계·정착·와이프·표류·폭발·재형성 순서로 묶었습니다. 화면을 이루는 픽셀이 어떤 힘을 받아 좌표를 바꾸는지 계산 방식이 하나씩 다른 순서로, CSS transform 한 줄로는 못 만드는 움직임입니다.

입자 수와 프레임 예산을 먼저 정하지 않으면 아홉 개가 끊겨 깨지는 이유

아홉 개 모두 격자를 56×35 = 1,960개 입자로 고정해 썼습니다. PRECOMPUTE() 가 시작될 때 딱 한 번 두 화면을 getImageData 로 읽어 입자마다 시작 색·도착 색·좌표를 캐싱해 두고, 이후 매 프레임의 RENDER(p) 는 이 캐시된 값으로 좌표와 알파만 다시 계산해 fillRect 로 그립니다 — 픽셀을 다시 읽는 비용은 없고, 그리는 비용만 매 프레임 남습니다.

09 궤적 재형성은 입자 하나당 TRAIL_SAMPLES(4개)의 잔상을 함께 그리므로 같은 1,960개 격자에서도 한 프레임에 최대 7,840번의 fillRect 호출이 들어갑니다. 격자를 촘촘하게 올려 입자를 더 잘게 쪼개고 싶다면 09 부터 프레임이 먼저 떨어집니다 — 실측에서도 03 중력 붕괴(19/23)·06 궤도 와이프(21/23)가 이미 23/23 에 못 미쳐, 격자를 키우기 전에 얼마나 여유가 있는지부터 재는 게 순서입니다.

transform 대신 왜 픽셀을 입자로 바꿔 계산하는가

CSS transform은 레이어 전체에 같은 값 하나를 적용하는 데는 강하지만, 입자마다 다른 힘 — 흡입 반지름·인력 세기·낙하 가속도·분리 가중치·스프링 강성·각속도·노이즈 방향·반발 충격량·보간 진행률 — 을 따로 계산해 각자 다른 궤적으로 움직이게 만들지는 못합니다. getImageData 로 픽셀 배열을 직접 들고 있어야 입자 하나하나에 서로 다른 물리 계산을 적용할 수 있습니다. css 부드러운 화면 전환을 필터 한 겹으로 끝내지 않고 아홉 가지 계산으로 나눈 이유가 여기 있습니다.

아홉 개를 24프레임 캡처해 잰 실측치

각 데모를 24프레임 캡처해 인접한 23개 구간(델타)으로 움직임을 쟀습니다. 누적면적은 프레임마다 바뀐 픽셀 비율을 더한 값, 강도_평균은 바뀐 픽셀의 평균 밝기 차, 움직인프레임은 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

01소용돌이 흡입력 값이 화면을 빨아들이는 전환

이전 화면을 그린 캔버스 픽셀을 흩어 놓은 1,960개 입자마다 흡입력 값(PULL_STRENGTH)만큼 반지름을 (1-p)^n 으로 줄이고 회전 값(TWIST)으로 각도 변화를 p² 배로 가속하면, 소용돌이 중심으로 빨려 들어가며 다음 화면이 드러납니다. 메뉴 전환이나 모달을 닫을 때 씁니다.

  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);
    }
  }
소용돌이 흡입력 값이 화면을 빨아들이는 전환
새 창에서 열기

01 · 소용돌이 흡입력 값이 화면을 빨아들이는 전환 — 측정 누적면적 58.349% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PULL_STRENGTHTWISTgetImageData

02인력 세기 값이 조립하는 자기수렴 전환

진행률이 절반을 넘기 전에는 u(2-u) 곡선으로 흩어지고, 절반을 넘으면 인력 세기 값(MAGNETIC_STRENGTH)만큼 (1-v)^n 곡선으로 잦아들며 다음 화면 좌표로 되돌아와 조립됩니다. 랜딩 히어로 진입이나 대시보드 위젯이 뜰 때 어울립니다.

  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);
    }
  }
인력 세기 값이 조립하는 자기수렴 전환
새 창에서 열기

02 · 인력 세기 값이 조립하는 자기수렴 전환 — 측정 누적면적 73.549% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAGNETIC_STRENGTHenvgetImageData

03중력 가속도 값이 무너뜨리는 붕괴 전환

행마다 rowDelay 로 시작 시점을 늦추고, 중력 가속도 값(GRAV_ACCEL)을 진행률 제곱에 곱해 낙하 거리를 구하는 s=0.5·a·t² 닫힌형 공식 그대로 위쪽부터 차례로 무너져 내립니다. 설정 패널을 닫거나 경고 화면을 지울 때 씁니다.

  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);
    }
  }
중력 가속도 값이 무너뜨리는 붕괴 전환
새 창에서 열기

03 · 중력 가속도 값이 무너뜨리는 붕괴 전환 — 측정 누적면적 60.113% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GRAV_ACCELrowDelaygetImageData

04무리 짓기 규칙 값이 대형을 바꾸는 군집 인계 전환

입자를 6개 무리(CLUSTER_COUNT)로 나눠 분리 가중치 값(SEPARATION_WEIGHT)만큼 무리 중심에서 4p(1-p) 포물선을 그리며 벌어졌다가, 다음 화면 대형으로 다시 뭉칩니다. 탭 전환이나 온보딩 단계 이동에 씁니다.

  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);
    }
  }
무리 짓기 규칙 값이 대형을 바꾸는 군집 인계 전환
새 창에서 열기

04 · 무리 짓기 규칙 값이 대형을 바꾸는 군집 인계 전환 — 측정 누적면적 70.649% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SEPARATION_WEIGHTCLUSTER_COUNTgetImageData

05스프링 강성 값이 흩었다 정착시키는 전환

스프링 강성 값(SPRING_K)과 감쇠 값(DAMPING)으로 각진동수를 계산해 decay·sin(wd·p) 진동으로 튕겨 나갔다가, 서서히 다음 화면 색으로 잦아들며 정착합니다. 카드 스와이프나 알림 배지를 바꿀 때 씁니다.

  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);
    }
  }
스프링 강성 값이 흩었다 정착시키는 전환
새 창에서 열기

05 · 스프링 강성 값이 흩었다 정착시키는 전환 — 측정 누적면적 64.357% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_KDAMPINGgetImageData

06각속도 값이 나선으로 지우는 궤도 와이프 전환

입자마다 유지하는 극좌표 반지름·각도에 각속도 값(ANGULAR_SPEED)만큼 각도를 더하고, 반지름 비율이 노출 문턱(threshold)을 지나면 알파가 꺼져 나선을 그리며 안쪽부터 지워집니다. 풀스크린 메뉴나 이미지 갤러리 전환에 씁니다.

  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);
    }
  }
각속도 값이 나선으로 지우는 궤도 와이프 전환
새 창에서 열기

06 · 각속도 값이 나선으로 지우는 궤도 와이프 전환 — 측정 누적면적 69.013% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ANGULAR_SPEEDREVEAL_SPANgetImageData

07노이즈 필드 값이 흩뜨리는 표류 용해 전환

좌표마다 sin·cos 를 섞어 고정한 노이즈 각도(noiseAngle) 방향으로 표류 속도 값(DRIFT_SPEED)만큼 밀어내고 알파를 진행률에 비례해 낮추면, 안개처럼 흩어지며 화면이 용해됩니다. 히어로 배경 교체나 다크모드 전환에 씁니다.

  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);
    }
  }
노이즈 필드 값이 흩뜨리는 표류 용해 전환
새 창에서 열기

07 · 노이즈 필드 값이 흩뜨리는 표류 용해 전환 — 측정 누적면적 56.724% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DRIFT_SPEEDnoiseAnglegetImageData

08반발력 값이 터뜨려 여는 버스트 오픈 전환

1-(1-p)³ 로 가속되는 충격량(impulse = BURST_FORCE/(r0+20))을 반지름에 더해, 중심에 가까운 입자일수록 더 세게 튕겨 나가며 화면이 폭발하듯 열립니다. 스플래시 종료나 성공 모달 등장에 씁니다.

  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);
    }
  }
반발력 값이 터뜨려 여는 버스트 오픈 전환
새 창에서 열기

08 · 반발력 값이 터뜨려 여는 버스트 오픈 전환 — 측정 누적면적 56.887% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BURST_FORCEimpulsegetImageData

09보간 값이 궤적을 남기며 재형성하는 전환

입자마다 TRAIL_SAMPLES(4개)의 지연된 진행률로 2t(1-t) 굽이를 그리며 시작 색에서 도착 색으로 섞여, 흐르는 잔상과 함께 다음 화면으로 재형성됩니다. 라우트 변경이나 캐러셀 슬라이드에 씁니다.

  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);
      }
    }
  }
보간 값이 궤적을 남기며 재형성하는 전환
새 창에서 열기

09 · 보간 값이 궤적을 남기며 재형성하는 전환 — 측정 누적면적 64.065% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TRAIL_BULGETRAIL_SAMPLESgetImageData

대시보드·탭·풀스크린 메뉴에 이 화면전환 애니메이션을 붙이는 자리

관리자 대시보드에서 위젯이 로드될 때는 02 자기수렴 스왑이 잘 맞습니다. 흩어진 조각이 위젯 자리로 끌려 들어오는 움직임이 데이터가 모이는 중이라는 인상을 주기 때문입니다.

온보딩처럼 단계를 넘길 때는 04 군집 인계가 어울립니다 — 무리가 다시 대형을 짜는 동안 이전 단계 내용이 자연스럽게 다음 단계로 이어지는 인상을 줍니다. 풀스크린 메뉴를 열 때는 06 궤도 와이프처럼 안쪽부터 지워지는 방향이 메뉴가 화면을 장악하는 느낌을 강조합니다.

편 388 리빌·392 글리치·005 CSS 전환과 갈라지는 지점

이미지 전환 효과 9종 — 캔버스 픽셀 계산으로 완성되는 리빌 은 픽셀의 좌표나 노이즈 문턱값만으로 보이고 안 보이고를 가르지만, 입자 자체는 제자리에 머뭅니다. 글리치 전환 효과 9가지 — css 글리치 효과 모음 은 채널을 어긋나게 오프셋만 주고 다시 되돌려 화면 자체는 흔들리지 않습니다. 이 편은 반대로 입자가 흡입·낙하·반발 같은 힘을 받아 실제로 좌표를 옮겨 다니는 물리 계산이라 x·y 가 매 프레임 달라진다는 점이 화면전환 애니메이션 효과의 갈래를 가릅니다.

물리 계산 없이 순수 CSS 값 전환만으로 화면을 바꾸고 싶다면 CSS 페이지 전환 애니메이션 9종 — JS 없이, CSS만으로 쪽이 더 가볍습니다. 로딩 화면에서 입자가 형태를 만드는 쪽이 궁금하면 캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로 도 같은 입자 계산 축입니다.

변형 세 가지와 reduced-motion 접근성

값 하나만 바꿔도 전환의 인상이 달라지고, zip 파일은 비밀번호 hfj36935 를 입력해야 열립니다.

항목 값 변경 느낌
01 소용돌이 흡입 PULL_STRENGTH 1.6 → 3.2 반지름 감소가 (1-p)^n 으로 더 가팔라져 더 적은 진행률 안에 중심까지 빨려 들어가 전환이 짧고 강해집니다.
04 군집 인계 SEPARATION_WEIGHT 1.0 → 0.4 무리 중심에서 벌어지는 오프셋 반경이 줄어 입자들이 더 가깝게 뭉치고 대형이 촘촘하게 다시 짜입니다.
06 궤도 와이프 ANGULAR_SPEED 4.0 → 8.0 같은 진행률에서 더 큰 각도를 돌아 노출 문턱에 더 빨리 닿고, spanP 도 줄어 나선 와이프가 더 짧게 끝납니다.

prefers-reduced-motion 이 참이면 아홉 개 모두 requestAnimationFrame 루프를 아예 시작하지 않습니다 — 평소에는 데모가 화면에 있는 동안 2초 주기로 자동 재생되지만, reduced-motion 에서는 이 자동 재생이 걸리지 않고 진행률이 멈춘 채로 남습니다. 클릭이나 Enter·스페이스바를 누르면 그 순간 진행률을 0 또는 1로 바로 지정해 RENDER() 를 한 번만 부르고, 오가는 애니메이션 없이 결과만 보여줍니다. 이 감지 조건은 MDN 의 prefers-reduced-motion 미디어 쿼리 설명을 기준 삼았습니다.

css 부드러운 화면 전환 아홉 개의 HTML·SCSS·JS 원본은 아래 카드에서 한 번에 받을 수 있습니다.

자주 묻는 질문 — 화면 전환 아홉 가지

소용돌이·자기수렴 같은 계산에 별도 라이브러리가 필요한가요?

아니요. 아홉 개 전부 순수 캔버스 2D API(getImageData·fillRect)와 삼각함수 계산만 씁니다. Three.js 같은 WebGL 라이브러리나 CDN 스크립트는 하나도 불러오지 않아 zip 을 열어 그대로 붙이면 그대로 동작합니다.

입자 격자를 더 촘촘하게 키워도 프레임이 안 떨어지나요?

현재 56×35(1,960개) 기준으로 이미 03 중력 붕괴·06 궤도 와이프가 23프레임 중 19~21프레임만 움직여 여유가 많지 않습니다. 09 궤적 재형성처럼 잔상 샘플로 그리기 호출이 곱해지는 항목부터 줄이고, 격자를 늘리는 순서를 권합니다.

아홉 개 항목의 색이나 캔버스 크기를 바꿀 수 있나요?

네, W·H·색 배열은 전부 파일 위쪽 상수 블록에 있어 값만 바꾸면 됩니다. 다만 COLS·ROWS(56×35)를 건드리면 PULL_STRENGTH·ANGULAR_SPEED 같은 힘 값도 같은 비율로 다시 맞춰야 소용돌이·와이프 속도감이 그대로 유지됩니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.