애니메이션 12원칙

The 12 principles of animation

디즈니 애니메이터 올리 존스턴과 프랭크 토마스가 1981년 저서 《The Illusion of Life》에서 정리한, 그림이 살아있는 것처럼 보이게 만드는 12가지 원칙. 오늘날 UI 모션 어휘의 상당수가 여기서 나왔습니다.

다른 이름: Disney의 12원칙Ollie Johnston & Frank Thomas12 Basic Principles of Animation
···
html
<div class="hud"><span id="pname">Squash &amp; Stretch</span><span id="pcount">1 / 12</span></div>
<canvas id="pc"></canvas>
css
.hud{position:absolute;top:7%;left:6%;right:6%;z-index:2;display:flex;justify-content:space-between;align-items:baseline;
  font:700 clamp(12px,3.4vmin,17px)/1 sans-serif;color:var(--fg)}
#pcount{font:11px/1 ui-monospace,monospace;color:var(--muted);font-weight:400}
#pc{position:absolute;inset:0;display:block;width:100%;height:100%}
js
const canvas = document.getElementById('pc');
const ctx = canvas.getContext('2d');
const nameEl = document.getElementById('pname');
const countEl = document.getElementById('pcount');
const cs = getComputedStyle(document.documentElement);
function col(name, fb) { const v = cs.getPropertyValue(name).trim(); return v || fb; }
const FG = col('--fg', '#15151a');
const MUTED = col('--muted', '#6b6b76');
const LINE = col('--line', '#e2e0d8');
const ACCENT = col('--accent', '#5b5bf7');
const ACCENT2 = col('--accent-2', '#f25c8a');
const ACCENT3 = col('--accent-3', '#18c29c');
let w = 0, h = 0;
function resize() {
  const dpr = Math.min(devicePixelRatio || 1, 2);
  w = innerWidth; h = innerHeight;
  canvas.width = w * dpr; canvas.height = h * dpr;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener('resize', resize);
resize();

function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function easeOut(t) { return 1 - Math.pow(1 - t, 2); }
function R() { return h * 0.065; }
function ball(x, y, rx, ry, color, alpha) {
  ctx.globalAlpha = alpha === undefined ? 1 : alpha;
  ctx.beginPath();
  ctx.ellipse(x, y, Math.max(rx, 1), Math.max(ry, 1), 0, 0, Math.PI * 2);
  ctx.fillStyle = color;
  ctx.fill();
  ctx.globalAlpha = 1;
}
function seg(x1, y1, x2, y2, color, width, dash) {
  ctx.strokeStyle = color; ctx.lineWidth = width || 1;
  ctx.setLineDash(dash || []);
  ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
  ctx.setLineDash([]);
}

const PHASES = [
  { name: 'Squash & Stretch', draw: function (t) {
    const gy = h * 0.72;
    seg(w * 0.1, gy, w * 0.9, gy, LINE, 1);
    const b = Math.abs(Math.sin(t * Math.PI));
    const y = gy - b * h * 0.4;
    const sq = 1 - b;
    ball(w * 0.5, y - R() * (1 - sq * 0.3), R() * (1 + sq * 0.5), R() * (1 - sq * 0.35), ACCENT);
  } },
  { name: 'Anticipation', draw: function (t) {
    const gy = h * 0.55;
    seg(w * 0.1, gy, w * 0.9, gy, LINE, 1);
    const x = t < 0.3
      ? w * 0.2 - easeOut(t / 0.3) * w * 0.06
      : w * 0.14 + easeOut(Math.min((t - 0.3) / 0.55, 1)) * w * 0.62;
    ball(x, gy, R(), R(), ACCENT);
  } },
  { name: 'Staging', draw: function (t) {
    const gy = h * 0.55;
    ball(w * 0.22, gy, R() * 0.8, R() * 0.8, MUTED, 0.25);
    ball(w * 0.78, gy, R() * 0.8, R() * 0.8, MUTED, 0.25);
    const pulse = 1 + Math.sin(t * Math.PI * 2) * 0.1;
    ball(w * 0.5, gy, R() * 1.3 * pulse, R() * 1.3 * pulse, ACCENT2);
  } },
  { name: 'Straight Ahead vs Pose to Pose', draw: function (t) {
    const y1 = h * 0.32, y2 = h * 0.68;
    seg(w * 0.1, y1, w * 0.9, y1, LINE, 1, [2, 4]);
    seg(w * 0.1, y2, w * 0.9, y2, LINE, 1, [2, 4]);
    const x1 = w * 0.15 + t * w * 0.7;
    ball(x1, y1 + Math.sin(t * 22) * h * 0.05, R() * 0.7, R() * 0.7, ACCENT);
    const poses = [0.15, 0.4, 0.65, 0.85];
    const idx = Math.min(Math.floor(t * poses.length), poses.length - 1);
    ball(w * poses[idx], y2, R() * 0.85, R() * 0.85, ACCENT2);
  } },
  { name: 'Follow Through & Overlap', draw: function (t) {
    const gy = h * 0.55;
    const x = w * 0.5 + Math.sin(t * Math.PI * 2) * w * 0.32;
    ball(w * 0.5 + Math.sin((t - 0.09) * Math.PI * 2) * w * 0.32, gy, R() * 0.65, R() * 0.65, ACCENT, 0.35);
    ball(w * 0.5 + Math.sin((t - 0.045) * Math.PI * 2) * w * 0.32, gy, R() * 0.82, R() * 0.82, ACCENT, 0.6);
    ball(x, gy, R(), R(), ACCENT);
  } },
  { name: 'Slow In & Slow Out', draw: function (t) {
    const gy = h * 0.55;
    for (let i = 0; i <= 8; i++) {
      const px = w * 0.15 + easeInOut(i / 8) * w * 0.7;
      ball(px, gy, R() * 0.16, R() * 0.16, MUTED, 0.5);
    }
    ball(w * 0.15 + easeInOut(t) * w * 0.7, gy, R() * 0.85, R() * 0.85, ACCENT3);
  } },
  { name: 'Arcs', draw: function (t) {
    const x0 = w * 0.15, x1 = w * 0.85, gy = h * 0.68, apex = h * 0.22;
    seg(x0, gy, x1, gy, LINE, 1, [2, 4]);
    ctx.beginPath();
    for (let i = 0; i <= 30; i++) {
      const tt = i / 30;
      const px = x0 + (x1 - x0) * tt;
      const py = gy - Math.sin(tt * Math.PI) * (gy - apex);
      if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
    }
    ctx.strokeStyle = LINE; ctx.lineWidth = 1; ctx.stroke();
    const px = x0 + (x1 - x0) * t;
    const py = gy - Math.sin(t * Math.PI) * (gy - apex);
    ball(px, py, R() * 0.75, R() * 0.75, ACCENT);
  } },
  { name: 'Secondary Action', draw: function (t) {
    const gy = h * 0.55;
    const x = w * 0.15 + t * w * 0.7;
    ball(x, gy, R(), R(), ACCENT);
    const ang = t * Math.PI * 10;
    ball(x + Math.cos(ang) * R() * 1.7, gy + Math.sin(ang) * R() * 1.7, R() * 0.3, R() * 0.3, ACCENT2);
  } },
  { name: 'Timing', draw: function (t) {
    const y1 = h * 0.32, y2 = h * 0.68;
    const x = w * 0.15 + t * w * 0.7;
    ball(x, y1 - Math.abs(Math.sin(t * 3 * Math.PI)) * h * 0.15, R() * 0.5, R() * 0.5, ACCENT);
    ball(x, y2 - Math.abs(Math.sin(t * Math.PI)) * h * 0.28, R() * 1.15, R() * 1.15, ACCENT2);
  } },
  { name: 'Exaggeration', draw: function (t) {
    const gy = h * 0.72;
    seg(w * 0.1, gy, w * 0.9, gy, LINE, 1);
    const b = Math.abs(Math.sin(t * Math.PI));
    const sq = 1 - b;
    const y = gy - b * h * 0.46;
    ball(w * 0.35, gy - Math.abs(Math.sin(t * Math.PI)) * h * 0.25 - R() * 0.5, R() * 0.5, R() * 0.5, MUTED, 0.4);
    ball(w * 0.65, y - R() * 1.15 * (1 - sq * 0.9), R() * 1.15 * (1 + sq * 1.5), R() * 1.15 * (1 - sq * 0.8), ACCENT2);
  } },
  { name: 'Solid Drawing', draw: function (t) {
    const cx = w * 0.5, cy = h * 0.55;
    const rot = t * Math.PI * 2;
    const rx = Math.max(Math.abs(Math.cos(rot)) * R() * 1.3, R() * 0.35);
    ball(cx, cy, rx, R() * 1.3, ACCENT);
    ball(cx + Math.sin(rot) * rx * 0.4, cy - R() * 0.5, R() * 0.22, R() * 0.32, '#ffffff', 0.5);
  } },
  { name: 'Appeal', draw: function (t) {
    const cx = w * 0.5, cy = h * 0.58;
    const breathe = 1 + Math.sin(t * Math.PI * 2) * 0.04;
    const rx = R() * 1.5 * breathe, ry = R() * 1.3 * breathe;
    ball(cx, cy, rx, ry, ACCENT3);
    const blinkFrac = t % 1;
    const blink = blinkFrac > 0.92 ? 0.15 : 1;
    ball(cx - rx * 0.32, cy - ry * 0.15, R() * 0.14, R() * 0.14 * blink, FG);
    ball(cx + rx * 0.32, cy - ry * 0.15, R() * 0.14, R() * 0.14 * blink, FG);
    ctx.strokeStyle = FG; ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(cx, cy + ry * 0.28, rx * 0.28, 0.15 * Math.PI, 0.85 * Math.PI);
    ctx.stroke();
  } },
];

let phaseIdx = -1;
const DUR = 1900;
const startT = performance.now();
function loop(now) {
  const elapsed = Math.max(0, now - startT);
  const idx = Math.floor(elapsed / DUR) % PHASES.length;
  const t = (elapsed % DUR) / DUR;
  if (idx !== phaseIdx) {
    phaseIdx = idx;
    nameEl.textContent = PHASES[idx].name;
    countEl.textContent = (idx + 1) + ' / ' + PHASES.length;
  }
  ctx.clearRect(0, 0, w, h);
  PHASES[idx].draw(t);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

올리 존스턴과 프랭크 토마스는 디즈니의 1930~80년대 애니메이터들이 시행착오로 쌓아온 노하우를 12가지로 정리했습니다. 스쿼시 앤 스트레치, 애니티시페이션, 스테이징, 스트레이트 어헤드 vs 포즈 투 포즈, 팔로우 스루와 오버래핑 액션, 슬로우 인 슬로우 아웃, 아크, 세컨더리 액션, 타이밍, 이그재저레이션, 솔리드 드로잉, 어필 — 이 열두 가지입니다.

원래는 셀룰로이드에 손으로 그리는 애니메이션을 위한 지침이었지만, 대부분은 "물체가 실제 물리 법칙과 감정을 따르는 것처럼 보이게 하려면 무엇이 필요한가"라는 더 일반적인 질문에 대한 답이라 3D, 게임, 지금의 UI 모션에도 그대로 옮겨옵니다. 버튼이 눌릴 때 살짝 움츠렸다 튀어 오르는 것(애니티시페이션), 모달이 뜰 때 한 가지만 강조하는 것(스테이징), 드래그가 끝나도 관성으로 한 박자 더 움직이는 것(팔로우 스루) — 전부 이 12원칙의 UI 버전입니다.

이 페이지의 데모는 12가지를 하나씩 순환하며 아주 짧게 보여주는 개요입니다. 각 원칙은 아래 관련 항목에서 따로 더 자세히 다룹니다.

언제 쓰나

12개 전부를 한 화면에 우겨넣지 마세요. UI 모션 하나에는 보통 2~3개 원칙이면 충분합니다 — 나머지는 다른 상황을 위해 아껴두세요.