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

캔버스 로딩 애니메이션 효과 9가지 — 코드 그대로

로딩 애니메이션 효과 를 캔버스로 만들 때 값 하나만 정해두면 나머지는 계산이 다 합니다. 감쇠 상수 0.92 가 중력 로더의 튕김 높이를 줄이고, 셀룰러 오토마타는 생존 규칙 값 하나로 세대마다 채워진 셀 비율을 계산하며, 자기장 로더는 궤도 반지름 값이 0 에 가까워질 때 끝난다

아홉 개 모두 캔버스 2D 컨텍스트 위에서 requestAnimationFrame 루프 하나로 돌아갑니다. 클릭·스페이스바(정지 토글)·R(재시작)·창 크기 조절까지 같은 이벤트 네 가지를 공유하고, Math.random() 은 한 번도 쓰지 않은 채 고정 시드 LCG 로만 흔들림을 만듭니다.

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

왜 이 아홉 로더는 트윈이 아니라 계산으로 진행률을 그리나

CSS 트랜지션이나 transform 트윈은 시작값과 끝값 사이를 브라우저가 알아서 보간해 줄 뿐, 그 사이에서 물이 출렁이거나(01) 블록이 튕기다 잦아들거나(03) 스프링이 감쇠하며 흔들리는 움직임(07)은 만들지 못합니다. 이 아홉 개는 전부 draw(sec) 안에서 사인파·감쇠 지수함수·이웃 셀 규칙 같은 실제 물리·수학 계산을 한 겹 돌려 그 결과로 좌표를 정하고, 그 좌표가 곧 진행률이 됩니다. 아래 표는 각 로더가 한 루프(23 프레임 델타) 동안 화면을 얼마나 바꾸는지 실측한 값입니다.

항목 누적면적 강도_평균 움직인프레임
01 파동 수면 57.1% 187.0 23/23
02 입자 수렴 15.6% 41.7 23/23
03 중력 블록 6.8% 145.2 23/23
04 보로노이 셀 37.3% 82.1 23/23
05 자기장 흡인 11.7% 65.7 23/23
06 노이즈 침식 8.5% 99.3 23/23
07 스프링 파동 22.8% 58.1 23/23
08 위성 궤도 19.3% 51.2 23/23
09 셀룰러 채움 33.6% 43.4 23/23

01진행률 값이 차오르는 액체 파동 로더

진행률(0~1)을 캔버스 수면 높이로 매핑하고 사인파 두 개를 겹쳐 표면을 흔들어, 값이 오를수록 액체가 차오르듯 로더 안쪽이 채워집니다. 그라디언트로 채운 다각형이라 수면 아래는 항상 불투명하게 덮입니다.

진행률 값이 차오르는 액체 파동 로더
새 창에서 열기

01 · 진행률 값이 차오르는 액체 파동 로더 — 측정 누적면적 57.08% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FREQcreateLinearGradientLIQUID_DEEP
  function waveY(x) {
    return y0
      + AMP1 * Math.sin(x * FREQ + sec * SPEED1)
      + AMP2 * Math.sin(x * FREQ * 1.8 + sec * SPEED2);
  }

  ctx.beginPath();
  ctx.moveTo(0, H);
  ctx.lineTo(0, waveY(0));
  var step = 4;
  for (var x = 0; x <= W; x += step) ctx.lineTo(x, waveY(x));
  ctx.lineTo(W, H);
  ctx.closePath();
  var grad = ctx.createLinearGradient(0, y0 - 24, 0, H);
  grad.addColorStop(0, LIQUID);
  grad.addColorStop(1, LIQUID_DEEP);
  ctx.fillStyle = grad;
  ctx.fill();

02도착 입자 수 값이 채우는 군집 수렴 로더

무작위로 흩어진 입자 42개 중 중심에 도착한 개수를 전체 개수로 나눈 비율을 진행률로 삼아, 도착한 입자가 늘수록 중심 원이 밝아집니다. 아직 도착하지 않은 입자는 제자리 근처에서 작게 맴돕니다.

도착 입자 수 값이 채우는 군집 수렴 로더
새 창에서 열기

02 · 도착 입자 수 값이 채우는 군집 수렴 로더 — 측정 누적면적 15.628% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

createRadialGradientglobalAlphaPAPER_FADE
  for (var i = 0; i < N; i++) {
    var q = particles[i];
    var tx, ty;
    if (q.rank < arrived) {
      var orbit = 5 + 3 * Math.sin(sec * q.jf + q.jp);
      tx = cx + Math.cos(sec * 1.4 + q.jp) * orbit;
      ty = cy + Math.sin(sec * 1.4 + q.jp) * orbit;
    } else {
      tx = q.hx * W + Math.sin(sec * q.jf + q.jp) * 4;
      ty = q.hy * H + Math.cos(sec * q.jf * 0.8 + q.jp) * 4;
    }
    q.px += (tx - q.px) * 0.14;
    q.py += (ty - q.py) * 0.14;
  }

03낙하 높이 값이 쌓는 중력 블록 로더

진행률만큼의 블록 수를 정해 위에서 하나씩 중력 가속도로 떨어뜨리고, 바닥에 닿을 때마다 튕기는 높이를 감쇠시켜 쌓인 블록 수로 진행률을 보여줍니다. 튕김 속도가 REST_V 밑으로 떨어지면 그 블록은 멈춘 것으로 칩니다.

낙하 높이 값이 쌓는 중력 블록 로더
새 창에서 열기

03 · 낙하 높이 값이 쌓는 중력 블록 로더 — 측정 누적면적 6.757% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DAMPINGGRAVITYarcTo
    if (b.state === 'falling') {
      b.vy += GRAVITY * dt;
      b.y += b.vy * dt;
      var floorY = (H - margin) - (i + 1) * blockH;
      if (b.y >= floorY) {
        b.y = floorY;
        b.vy = -b.vy * DAMPING;
        if (Math.abs(b.vy) < REST_V) { b.state = 'settled'; b.vy = 0; }
      }
    }
    if (b.state === 'settled') settled++;

04시드 개수 값이 넓히는 보로노이 셀 로더

진행률에 비례해 씨앗점 개수를 늘려 매 프레임 보로노이 다이어그램을 브루트포스로 다시 계산하고, 셀이 촘촘해질수록 로더 안쪽이 완성된 모자이크로 채워집니다. 격자 칸마다 가장 가까운 씨앗을 전수 비교해 색을 정합니다.

시드 개수 값이 넓히는 보로노이 셀 로더
새 창에서 열기

04 · 시드 개수 값이 넓히는 보로노이 셀 로더 — 측정 누적면적 37.312% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAXSEEDSwobbleRsetTransform
  for (var r = 0; r < rows; r++) {
    var cy = r * CELL + CELL / 2;
    for (var c = 0; c < cols; c++) {
      var cx = c * CELL + CELL / 2;
      var best = 0, bestD = Infinity;
      for (var s = 0; s < count; s++) {
        var dx = cx - seeds[s].px, dy = cy - seeds[s].py;
        var d = dx * dx + dy * dy;
        if (d < bestD) { bestD = d; best = s; }
      }
      ctx.fillStyle = PALETTE[best % PALETTE.length];
      ctx.globalAlpha = 0.55;
      ctx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
    }
  }

05궤도 반경 값이 좁히는 자기장 로더

진행률을 궤도 반지름 값으로 바꿔 입자들이 자기장선을 따라 중심으로 끌려오듯 원 궤도를 그리며 좁혀 들고, 반지름이 0에 가까워지면 로더가 끝납니다. 흡인이 진행될수록 중심 코어도 함께 커집니다.

궤도 반경 값이 좁히는 자기장 로더
새 창에서 열기

05 · 궤도 반경 값이 좁히는 자기장 로더 — 측정 누적면적 11.671% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

createRadialGradientPAPER_FADEshrink
  ctx.strokeStyle = LINE;
  ctx.lineWidth = 1.6;
  for (var i = 0; i < N; i++) {
    var q = parts[i];
    var rad = fieldR * q.r0 * shrink;
    var ang = q.phase + sec * q.speed;
    var x = cxp + Math.cos(ang) * rad;
    var y = cyp + Math.sin(ang) * rad;
    ctx.beginPath();
    ctx.moveTo(cxp, cyp);
    ctx.lineTo(x, y);
    ctx.stroke();
    ctx.fillStyle = PALETTE[i % PALETTE.length];
    ctx.beginPath();
    ctx.arc(x, y, 6.2, 0, Math.PI * 2);
    ctx.fill();
  }

06침식 두께 값이 갉아먹는 노이즈 링 로더

2D 노이즈 필드 위에 그린 링의 안쪽 반지름을 진행률만큼 갉아 들어가는 침식 두께 값으로 정해, 노이즈가 울퉁불퉁하게 파먹히며 링이 가늘어집니다. 옥타브 두 겹을 섞어 굴곡이 한 겹일 때보다 훨씬 거칠게 보입니다.

침식 두께 값이 갉아먹는 노이즈 링 로더
새 창에서 열기

06 · 침식 두께 값이 갉아먹는 노이즈 링 로더 — 측정 누적면적 8.508% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LATTICE_NfieldNoiseevenodd
function smooth(f) { return f * f * (3 - 2 * f); }
function valueNoise(x) {
  var xi = ((x % LATTICE_N) + LATTICE_N) % LATTICE_N;
  var i0 = Math.floor(xi);
  var i1 = (i0 + 1) % LATTICE_N;
  var f = smooth(xi - i0);
  return lattice[i0] * (1 - f) + lattice[i1] * f;
}
function fieldNoise(theta, sec) {
  var x1 = (theta / (Math.PI * 2)) * LATTICE_N + sec * 1.6;
  var x2 = (theta / (Math.PI * 2)) * LATTICE_N * 2.3 + 40 - sec * 2.4;
  return valueNoise(x1) * 0.68 + valueNoise(x2) * 0.32;
}

07파동 반경 값이 번지는 스프링 격자 로더

격자로 이은 점들을 스프링으로 연결해두고 진행률을 중심에서 퍼지는 파동 반경 값으로 삼아, 반경이 지나간 점들만 튕겨 올라 물결치는 격자가 남습니다. 각 점의 변위는 AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt) 라는 닫힌해로 한 번에 계산됩니다.

파동 반경 값이 번지는 스프링 격자 로더
새 창에서 열기

07 · 파동 반경 값이 번지는 스프링 격자 로더 — 측정 누적면적 22.819% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STIFFNESSDAMPripple
  ctx.strokeStyle = RING;
  ctx.lineWidth = 1.4;
  ctx.beginPath();
  ctx.arc(cxp, cyp, Math.max(2, frontR), 0, Math.PI * 2);
  ctx.stroke();

  var disp = new Array(pts.length);
  for (var i = 0; i < pts.length; i++) {
    var q = pts[i];
    var dt = (t - q.dist / speed);
    disp[i] = ripple(dt);
  }

08각도 값이 쓸어가는 위성 궤도 로더

진행률을 0~360도 사이 각도 값으로 바꿔 위성 점들이 그 각도까지만 궤도를 그리며 지나가게 하고, 지나간 자리에는 꼬리 자국을 남겨 각도가 곧 진행률로 읽힙니다. 궤도 세 개는 반지름에 반비례하는 배속으로 돌아 가까운 궤도일수록 빠릅니다.

각도 값이 쓸어가는 위성 궤도 로더
새 창에서 열기

08 · 각도 값이 쓸어가는 위성 궤도 로더 — 측정 누적면적 19.327% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WIDE_SEGglobalAlphaRINGS
    var ang = sweep * ring.speed;
    for (var s = TRAIL_SEG; s >= 0; s--) {
      var a0 = ang - TRAIL_SPAN * (s / TRAIL_SEG);
      var a1 = ang - TRAIL_SPAN * ((s - 1) / TRAIL_SEG);
      var alpha = 1 - s / TRAIL_SEG;
      ctx.strokeStyle = ring.color;
      ctx.globalAlpha = alpha * alpha * 0.85;
      ctx.lineWidth = 2.6;
      ctx.beginPath();
      ctx.arc(cxp, cyp, rad, a0, a1);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;

09생존 규칙 값이 채우는 셀룰러 오토마타 로더

격자 셀마다 생존 규칙을 적용해 세대가 지날 때마다 켜진 셀 비율을 계산하고, 그 비율이 진행률 목표값에 닿을 때까지 세대를 이어가며 로더 안쪽을 채웁니다. 이웃이 3개 이상이면 태어나는 규칙 하나로 세대 16개를 미리 계산해 둡니다.

생존 규칙 값이 채우는 셀룰러 오토마타 로더
새 창에서 열기

09 · 생존 규칙 값이 채우는 셀룰러 오토마타 로더 — 측정 누적면적 33.618% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BIRTHneighborCountstepGen
function neighborCount(g, x, y) {
  var c = 0;
  for (var dy = -1; dy <= 1; dy++) {
    for (var dx = -1; dx <= 1; dx++) {
      if (dx === 0 && dy === 0) continue;
      var nx = x + dx, ny = y + dy;
      if (nx < 0 || ny < 0 || nx >= GX || ny >= GY) continue;
      if (g[ny * GX + nx]) c++;
    }
  }
  return c;
}
function stepGen(prev) {
  var next = new Uint8Array(TOTAL);
  for (var y = 0; y < GY; y++) {
    for (var x = 0; x < GX; x++) {
      var idx = y * GX + x;
      if (prev[idx]) { next[idx] = 1; continue; }
      next[idx] = neighborCount(prev, x, y) >= BIRTH ? 1 : 0;
    }
  }
  return next;
}

숫자 하나만 바뀌어도 아홉 계산이 동시에 다르게 움직인다

모두 draw(sec) 하나가 진행률 p 를 받아 좌표를 정하지만, p 가 하는 일은 항목마다 다릅니다. 01·07 은 p 를 수면 높이·파동 반경 값으로 바꿔 표면·격자를 흔들고, 03·04·09 는 p 를 블록 수·씨앗 수·세대 번호 같은 정수 카운터로 바꿔 그 개수만큼만 계산을 돌리며, 05 는 p 를 반지름 배율(1-p)로 직접 곱합니다. 같은 캔버스 2D 계산형이라도 값을 읽는 자리가 다르면 결과가 갈리는 예는 캔버스 메타볼 효과 9종 — 마칭 스퀘어로 합체하는 액체 덩어리 에서도 볼 수 있는데, 그쪽은 p 를 메타볼 반경으로 바꿔 덩어리가 합쳐지는 경계선을 마칭 스퀘어로 다시 그린다는 점에서 이 편의 04 보로노이 재계산과 비슷한 「매 프레임 전수 재계산」 방식을 씁니다. 씨앗 하나로 매번 다른 배경을 그리는 CSS 배경 패턴 9종 — 시드 하나로 다시 그리기 도 같은 고정 시드 LCG 를 쓰지만 그쪽은 진행률이 아니라 페이지를 새로고침할 때만 값을 다시 뽑는다는 점이 다릅니다.

온보딩 진행 바 대신 이 계산형 로더를 어디에 붙이나

파일 업로드율처럼 값이 끊김 없이 늘어야 하는 자리에는 01 의 파동 수면이나 03 의 중력 블록이 맞습니다. 특히 03 은 블록이 하나씩 쌓이는 모습이라 「몇 개 중 몇 번째」가 중요한 대량 업로드 목록에서, 퍼센트 숫자보다 먼저 눈에 들어오는 진행 신호가 됩니다.

결제 승인이나 네트워크 확인처럼 「끝이 보이지 않다가 갑자기 끝나는」 대기에는 05 의 자기장 흡인이 어울립니다. 궤도 반지름이 서서히 줄다가 정확히 0 이 되는 순간 코어가 커지며 입자를 흡수하는 연출이라, 결제창이 닫히기 직전의 긴장감을 값 하나로 전달합니다.

대시보드나 지도 타일처럼 화면이 조각조각 채워지는 초기 로딩에는 09 의 셀룰러 채움이나 04 의 보로노이 셀이 맞습니다. 09 는 칸이 규칙에 따라 하나씩 살아나는 유기적인 채움이고, 04 는 색 모자이크가 촘촘해지는 채움이라 지도·타일 그리드의 실제 로딩 순서와 시각적으로 가깝습니다. 이렇게 값 하나만 바꿔 완성하는 로딩 애니메이션 만들기 연습은, 코드를 처음부터 다시 짜지 않고도 아홉 개의 인상을 전부 다르게 만들 수 있다는 점에서 html 로딩 애니메이션 을 처음 붙이는 사람에게도 진입 장벽이 낮습니다.

값이 1.0 에 닿지 않아 깨지는 지점 — 04 와 02 의 함정

t 는 sec % LOOP 라 진행률 p = t / LOOP 는 연속된 루프 안에서 이론상 1.0 에 정확히 닿지 않고 다음 루프의 0 으로 곧장 넘어갑니다. 04 는 count = Math.max(2, 2 + Math.floor(p * (MAXSEEDS - 2))) 로 씨앗 개수를 정하는데, p 가 1 에 아무리 가까워져도 Math.floor 가 소수점을 버려 count 는 최대 15(전체 16개 중)까지만 올라가고, 화면의 퍼센트 배지도 100% 가 아니라 93% 에서 다음 루프로 넘어갑니다. 02 도 같은 이유로 arrived = Math.floor(p * N) 이 42개 중 41개에서 멈춘 채 순간적으로 0 으로 리셋됩니다. 소스를 내려받아 이 부분을 직접 고쳐보려는 분은 5ert9w9q 을 입력해 압축을 풀면 아홉 개 원본을 한 번에 받을 수 있습니다.

접근성 설정 하나로 아홉 로더가 동시에 멈추는 이유 — reduced-motion 뒤에 클릭해도 안 움직인다

prefers-reduced-motion 이 참이면 로드 시점에 한 번만 판정해 아홉 개 모두 requestAnimationFrame 루프를 아예 시작하지 않고, LOOP * 0.62 지점의 정지 프레임 한 장만 그립니다. 이 상태에서 클릭이나 스페이스바를 눌러도 togglePause() 는 is-paused 클래스만 토글할 뿐 애초에 돌지 않던 루프를 되살리지 못해 화면은 그대로입니다. 반대로 R 키의 restart() 는 reduced-motion 여부와 상관없이 항상 draw(0) 을 한 번 실행하므로, 정지 프레임이 62% 지점에서 0% 지점으로 옮겨질 뿐 애니메이션이 시작되는 것은 아닙니다.

FREQ·DAMPING·STIFFNESS 세 값만 바꿔도 로딩 애니메이션 css 느낌이 달라진다

항목 값 바꾸면
01 FREQ 0.045 → 0.09 진동수 값을 높이면 액체 표면이 더 잘게 흔들려 파동이 빠르게 출렁이는 쪽에 가까워집니다.
03 DAMPING 0.92 → 0.6 감쇠 값을 낮추면 블록이 바닥에 닿을 때마다 더 낮게 튕겨 빨리 잦아드는 쪽에 가까워집니다.
07 STIFFNESS 9 → 16 강성 값을 키우면 격자점이 더 빨리 원래 자리로 되돌아가 파동이 짧게 끊기는 쪽에 가까워집니다.

캔버스 로더를 쓰기 전에 자주 나오는 질문에 답한다

로딩 애니메이션 gif 로 내보내서 써도 되나요?

캔버스를 화면 녹화해 gif 로 저장해도 되지만, 정지 이미지 한 장으로는 03·09 처럼 상태가 누적되는 계산을 보여줄 수 없습니다. 실제 페이지에서는 zip 안의 코드를 그대로 붙여 캔버스가 매 프레임 계산하게 두는 쪽을 권합니다.

아홉 개 중 성능이 가장 무거운 건 무엇인가요?

강도_평균이 가장 높은 01(187.0)이 매 프레임 다각형을 새로 채우는 비용이 커서 가장 무겁고, 04 는 격자 칸 수 × 씨앗 수만큼 거리 비교를 반복하는 브루트포스라 격자를 촘촘히 키울수록 느려집니다.

9개 중 하나만 붙이고 나머지는 빼도 되나요?

가능합니다. 각 <script> 블록은 id="cv"·id="field" 같은 자기 캔버스 요소만 참조하는 독립 모듈이라, 필요한 번호의 HTML·CSS·JS 세 파일만 골라 붙여도 다른 항목 없이 그대로 동작합니다.

이 계산형 진행률의 표준 근거는 requestAnimationFrame 문서와 prefers-reduced-motion 문서에 정리돼 있고, 픽셀이 아니라 도형을 직접 그리는 계산의 기준은 CanvasRenderingContext2D 문서를 따릅니다.

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

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