SOITZ

requestAnimationFrame으로 애니메이션 구현하기

Published on

웹에서 Javascript로 움직임을 구현하려면 requestAnimationFrame을 사용합니다. requestAnimationFrame은 웹 브라우저가 제공하는 API 중 하나로, 웹 페이지에서 애니메이션을 구현할 때 사용됩니다. 이 함수는 다음 화면 재생 (repaint) 이 일어나기 이전에 호출되어야 할 콜백 함수를 웹 브라우저에 요청합니다. 이 방식을 사용하면 브라우저의 화면 갱신 주기에 맞춰 부드러운 애니메이션을 만들 수 있으며, 브라우저 최적화의 이점을 활용할 수 있습니다.

requestAnimationFrame 사용

  1. 먼저 애니메이션을 실행할 콜백 함수를 정의합니다.
  2. 해당 함수 내에서 requestAnimationFrame을 호출하여 무한 루프를 만듭니다. 이렇게 하면 함수가 반복적으로 실행됩니다.

이 코드를 브라우저의 개발자 도구에서 확인하면 console.log가 지속적으로 출력되는 것을 확인할 수 있습니다.

function animate() {
  // 업데이트하고자 하는 애니메이션 로직을 구현합니다.
  console.log(timestamp);
  // 브라우저에 다시 그리기를 요청합니다.
  requestAnimationFrame(animate);
}
// 콜백 함수를 최초로 호출하여 애니메이션을 시작합니다.
requestAnimationFrame(animate);

requestAnimationFrame 취소

cancelAnimationFrame 함수를 사용하여 예정된 애니메이션을 취소할 수 있습니다. 이 함수는 requestAnimationFrame이 반환하는 고유한 식별자를 인자로 받습니다.

  1. requestAnimationFrame이 반환하는 ID를 저장합니다.
  2. 취소하고자 할 때 cancelAnimationFrame으로 해당 ID의 콜백을 취소합니다.
// 애니메이션 ID를 저장할 변수를 선언합니다.
let animationId;
function animate() {
  animationId = requestAnimationFrame(animate);
  // 애니메이션 로직...
}

// 애니메이션 시작
animationId = requestAnimationFrame(animate);

// 어떤 조건하에서 애니메이션을 중단하고 싶을 때
// 예: 사용자가 페이지를 벗어날 때
cancelAnimationFrame(animationId);

좀 더 구조적이고 관리하기 쉽게 하기 위해서 SimpleMotion 이라는 클래스를 만들어 보겠습니다.

SimpleMotion 클래스 정의

SimpleMotion 이라는 클래스를 정의합니다. SimpleMotion 클래스를 선언할 때 지속시간, 애니메이션 callback, easing 함수를 파라메터로 받습니다. SimpleMotion 인스턴스의 start() 메서드를 이용해서 애니메이션을 실행할 수 있습니다.

class SimpleMotion {
  constructor({ duration = 0, update, easing = (n) => n }) {
    this.duration = duration; // 지속 시간
    this.update = update; // 애니메이션 진행되는 동안의 callback
    this.easing = easing; // easing 함수
  }
  start() {
    let startTime = 0;
    const stepping = (timestamp) => {
      if (startTime === 0) {
        startTime = timestamp; // 초기시간이 설정되어 있지 않다면 timestamp를 반영
      }
      const pastTime = timestamp - startTime; // 애니메이션 진행시간
      const progress = pastTime / this.duration; // 진행률

      if (pastTime >= this.duration) {
        // 진행시간이 넘어섰다면 애니메이션 종료
        this.update(1);
        return;
      }
      this.update(this.easing(progress));
      window.requestAnimationFrame(stepping);
    };

    window.requestAnimationFrame(stepping);
  }
}

SimpleMotion 클래스 사용

// SimpleMotion의 instance 선언
const motion = new Motion({
  duration: 500, // 0.5초동안
  update: (progress) => {
    // 우측으로 500px 이동
    document.querySelector('.box').style.transform = `translateX(${
      progress * 100
    }px)`;
  },
});

// .box 클릭하면 0.5초동안 우측으로 100px 이동하는 애니메이션 실행
document.querySelector('.box').addEventListener('click', () => motion.start());

전체 코드

아래는 SimpleMotion 을 사용한 전체 코드입니다.

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="initial-scale=1.0, minimum-scale=1.0, width=device-width, viewport-fit=cover"
    />
    <style>
      .box {
        width: 60px;
        height: 60px;
        background-color: #f00;
      }
    </style>
    <title>VanilaJS Simple Motion</title>
  </head>

  <body>
    <div class="box"></div>
    <script type="module">
      class SimpleMotion {
        constructor({ duration = 0, update, easing = (n) => n }) {
          this.duration = duration; // 지속 시간
          this.update = update; // 애니메이션 진행되는 동안의 callback
          this.easing = easing; // easing 함수
        }
        start() {
          let startTime = 0;
          const stepping = (timestamp) => {
            if (startTime === 0) {
              startTime = timestamp; // 초기시간이 설정되어 있지 않다면 timestamp를 반영
            }
            const pastTime = timestamp - startTime; // 애니메이션 진행시간
            const progress = pastTime / this.duration; // 진행률

            if (pastTime >= this.duration) {
              // 진행시간이 넘어섰다면 애니메이션 종료
              this.update(1);
              return;
            }
            this.update(this.easing(progress));
            window.requestAnimationFrame(stepping);
          };

          window.requestAnimationFrame(stepping);
        }
      }

      // SimpleMotion의 instance 선언
      const motion = new SimpleMotion({
        duration: 500,
        update: (progress) => {
          // 우측으로 100px 이동
          document.querySelector('.box').style.transform = `translateX(${
            progress * 100
          }px)`;
        },
      });

      // .box 클릭하면 0.5초동안 우측으로 100px 이동하는 애니메이션 실행
      document
        .querySelector('.box')
        .addEventListener('click', () => motion.start());
    </script>
  </body>
</html>

조금 더 발전시켜서 애니메이션을 중지시키거나 애니메이션 종료 callback도 추가해보겠습니다.

그 전에 easing 함수에 대해서 정리해보겠습니다. SimpleMotion에서는 easing에 linear함수를 사용했지만 이번에는 다양한 easing 함수를 정의해서 사용해보겠습니다. 이 함수에 대한 내용은 여기서 한 번 정리했습니다. http://blog.soitz.com/2024/03/easing-functions

Easing Functions

/* 
  https://github.com/ai/easings.net/blob/master/src/easings/easingsFunctions.ts
*/
const bounceOut = function (x) {
  const n1 = 7.5625;
  const d1 = 2.75;

  if (t < 1 / d1) {
    return n1 * t * t;
  } else if (t < 2 / d1) {
    return n1 * (t -= 1.5 / d1) * t + 0.75;
  } else if (t < 2.5 / d1) {
    return n1 * (t -= 2.25 / d1) * t + 0.9375;
  } else {
    return n1 * (t -= 2.625 / d1) * t + 0.984375;
  }
};

const pow = Math.pow;
const sqrt = Math.sqrt;
const sin = Math.sin;
const cos = Math.cos;
const PI = Math.PI;
const c1 = 1.70158;
const c2 = c1 * 1.525;
const c3 = c1 + 1;
const c4 = (2 * PI) / 3;
const c5 = (2 * PI) / 4.5;

const easingFunctions = {
  // 선형 easing은 변환되지 않은 비율을 반환합니다. 애니메이션은 균일한 속도로 진행됩니다.
  linear: (t) => t,

  /*
    Quad (Quadratic)
    Quadratic easing은 제곱을 사용하여 애니메이션의 속도를 조절합니다. 
    t^2의 형태로 나타나며, 애니메이션은 부드러운 시작과 끝을 가지되, 중간에는 상대적으로 빠른 속도로 진행됩니다. 
    이는 기본적이면서도 널리 사용되는 easing 형태로, 약간의 가속이 필요할 때 유용합니다.
  */
  easeInQuad: (t) => t * t,
  easeOutQuad: (t) => t * (2 - t),
  easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),

  /*
    Cubic
    Cubic easing은 세제곱을 사용하여 속도를 조절합니다. 
    t^3의 형태로 표현되며, Quad보다 더 빠른 가속을 제공합니다. 
    시작과 끝에서 더 부드럽게, 중간에는 더욱 가파르게 속도가 변화합니다. 
    이 방식은 더욱 동적인 움직임이 요구될 때 적합합니다.
  */
  easeInCubic: (t) => t * t * t,
  easeOutCubic: (t) => --t * t * t + 1,
  easeInOutCubic: (t) =>
    t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,

  /*
    Quart (Quartic)
    Quartic easing은 네제곱을 사용하여 애니메이션의 속도를 조절합니다. 
    t^4의 형태로 나타나며, Cubic에 비해 더욱 강한 가속과 감속을 제공합니다. 
    이 유형의 easing은 매우 부드러운 시작과 끝, 그리고 중간에 매우 급격한 속도 변화를 원할 때 사용됩니다.
  */
  easeInQuart: (t) => t * t * t * t,
  easeOutQuart: (t) => 1 - --t * t * t * t,
  easeInOutQuart: (t) =>
    t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,

  /*
    Quint (Quintic)
    Quintic easing은 다섯 제곱을 사용하여 속도를 조절합니다. 
    t^5의 형태로, 시작과 종료 시 매우 부드러운 움직임과 함께, 가장 강력한 가속을 경험할 수 있습니다. 
    이는 Quart보다 더욱 강조된 가속과 감속이 특징으로, 매우 동적이고 강력한 애니메이션 효과를 원할 때 적합합니다.
  */
  easeInQuint: (t) => t * t * t * t * t,
  easeOutQuint: (t) => 1 + --t * t * t * t * t,
  easeInOutQuint: (t) =>
    t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t,

  /*
    Sine (Sinusoidal)
    Sinusoidal easing은 사인 곡선의 특성을 이용하여 애니메이션의 속도를 조절합니다. 
    sin(t)의 형태로, 자연스러운 움직임을 생성하기 위해 사용됩니다. 
    Sine easing은 시작과 종료가 매우 부드럽고, 전체적으로 부드러운 속도 변화를 나타내는 것이 특징입니다. 
    이 유형은 자연스러운 움직임을 시뮬레이션하거나, 부드러운 전환 효과가 필요할 때 유용합니다.
  */
  easeInSine: (t) => 1 - cos((t * PI) / 2),
  easeOutSine: (t) => sin((t * PI) / 2),
  easeInOutSine: (t) => -(cos(PI * t) - 1) / 2,

  /*
    Expo (Exponential)
    Exponential easing은 지수 함수를 사용하여 매우 느린 시작 후 급격한 가속 또는 급격한 감속 후 매우 느린 종료를 표현합니다. 
    이 유형의 easing은 애니메이션의 효과가 시간이 지남에 따라 지수적으로 증가하거나 감소함을 의미합니다. 
    예를 들어, easeInExpo는 애니메이션이 매우 천천히 시작되어 끝으로 갈수록 급격히 속도가 증가하는 효과를 만듭니다.
  */
  easeInExpo: (t) => (t === 0 ? 0 : pow(2, 10 * (t - 1))),
  easeOutExpo: (t) => (t === 1 ? 1 : 1 - pow(2, -10 * t)),
  easeInOutExpo: (t) =>
    t === 0
      ? 0
      : t === 1
        ? 1
        : t < 0.5
          ? pow(2, 20 * t - 10) / 2
          : (2 - pow(2, -20 * t + 10)) / 2,

  /*
    Circ (Circular)
    Circular easing은 원형 경로를 따라 움직이는 것처럼 보이게 하는 easing 방식입니다. 
    시작과 끝에서 매끄럽게 속도가 변화하며, 특히 easeInCirc와 easeOutCirc는 각각 원의 내부와 외부 경로를 따라 움직이는 것처럼 보이게 합니다. 
    이 유형은 애니메이션에 부드러운 시작과 끝을 제공하면서도 중간에는 상대적으로 급격한 속도 변화를 가져옵니다.
  */
  easeInCirc: (t) => 1 - sqrt(1 - t * t),
  easeOutCirc: (t) => sqrt(1 - --t * t),
  easeInOutCirc: (t) =>
    t < 0.5
      ? (1 - sqrt(1 - 2 * t * (2 * t))) / 2
      : (sqrt(1 - (-2 * t + 2) * (-2 * t + 2)) + 1) / 2,

  /*
    Back
    Back easing은 애니메이션이 시작점이나 종료점에서 약간 후퇴한 후, 목표 지점으로 빠르게 전진하거나 부드럽게 정착하는 효과를 만듭니다. 
    이는 마치 애니메이션이 뒤로 잠깐 후퇴하는 듯한 인상을 주며, 이러한 효과는 애니메이션에 독특한 '밀어내기' 느낌을 추가합니다.
  */
  easeInBack: (t) => c3 * t * t * t - c1 * t * t,
  easeOutBack: (t) => 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2),
  easeInOutBack: (t) =>
    t < 0.5
      ? (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
      : (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2,

  /*
    Elastic
    Elastic easing은 고무줄이 늘어났다가 돌아오는 것처럼, 애니메이션이 목표 지점을 넘어서 잠깐 확장되었다가 다시 돌아오는 효과를 만듭니다. 
    이 유형의 easing은 시작과 끝에서 '바운스' 효과를 나타내며, 마치 물체가 탄성을 가진 것처럼 늘어나고 줄어드는 모션을 보여줍니다.
  */
  easeInElastic: (t) =>
    t === 0
      ? 0
      : t === 1
        ? 1
        : -pow(2, 10 * t - 10) * sin((t * 10 - 10.75) * c4),
  easeOutElastic: (t) =>
    t === 0 ? 0 : t === 1 ? 1 : pow(2, -10 * t) * sin((t * 10 - 0.75) * c4) + 1,
  easeInOutElastic: (t) =>
    t === 0
      ? 0
      : t === 1
        ? 1
        : t < 0.5
          ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2
          : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 +
            1,

  /*
    Bounce
    Bounce easing은 물체가 떨어져서 바닥에 닿았을 때 여러 번 튀어오르는 것과 유사한 효과를 애니메이션에 추가합니다. 
    이 easing 유형은 물체가 마지막 목적지에 도달하기 전에 여러 번 바운스(튕겨오름)하는 모습을 통해 생동감과 재미를 더합니다.
    easeOutBounce는 목표 지점에 도착하여 멈추기 전에 몇 번의 반동을 포함하는 효과를 제공합니다.
  */
  easeInBounce: (t) => 1 - bounceOut(1 - t),
  easeOutBounce: bounceOut,
  easeInOutBounce: (t) =>
    t < 0.5
      ? (1 - easeOutBounce(1 - 2 * t)) / 2
      : (1 + easeOutBounce(2 * t - 1)) / 2,
};

Motion

이번에는 mjs를 사용해서 Motion 클래스를 정의하겠습니다.

// motion.mjs

// ... easing functions 정의

class Motion {
  constructor({
    duration = 0, // 애니메이션 지속 시간
    easing = 'linear', // 기본값으로 'linear' 문자열 지정
    loop = false, // 반복
    reverse = false, // 거꾸로
  }) {
    this.duration = duration; // 지속 시간
    this.easing = easingFunctions[easing] || easingFunctions.linear;
    this.loop = loop;
    this.reverse = reverse;

    this.animationFrameId = null; // requestAnimationFrame의 id
    this.startTime = null; // 애니메이션 시작 시각
    this.isPaused = false; // 일시정지 boolean
    this.pausedTime = null; // 일시정지 시각
    this.progress = 0; // 진행률
    this.direction = 1; // 방향

    // 이벤트 핸들러
    this.handlers = {
      start: () => {},
      update: () => {},
      paused: () => {},
      done: () => {},
      stopped: () => {},
    };
  }

  on(eventHandlers) {
    // 이벤트 핸들러 설정
    Object.keys(eventHandlers).forEach((event) => {
      if (this.handlers[event]) {
        this.handlers[event] = eventHandlers[event];
      }
    });
    return this;
  }

  // 애니메이션 시작
  start() {
    if (!this.startTime) {
      this.startTime = performance.now(); // 애니메이션 시작 시간 설정
      this.handlers.start();
    }

    const animate = (timestamp) => {
      if (this.isPaused) {
        // 일시정지 상태이면 return
        return;
      }

      const elapsedTime = timestamp - this.startTime; // 소요시간
      const progress = elapsedTime / this.duration; // 진행률
      this.progress = this.reverse
        ? 1 - Math.abs((progress % 2) - 1)
        : progress % 1;

      this.handlers.update(this.easing(Math.min(this.progress, 1))); // 진행률은 최대 1

      if (!this.loop && elapsedTime >= this.duration) {
        // 애니메이션 종료 확인
        this.handlers.done();
      } else {
        this.animationFrameId = requestAnimationFrame(animate);
      }
    };

    this.animationFrameId = requestAnimationFrame(animate);
  }

  // 애니메이션 일시정지
  pause() {
    if (this.animationFrameId && !this.isPaused) {
      this.pausedTime = performance.now();
      this.isPaused = true;
      cancelAnimationFrame(this.animationFrameId);
      this.handlers.paused();
    }
  }

  // 애니메이션 다시 시작
  resume() {
    if (this.animationFrameId && this.isPaused) {
      this.isPaused = false;
      this.startTime += performance.now() - this.pausedTime;
      this.start();
    }
  }

  // 애니메이션 중지
  stop() {
    if (this.animationFrameId) {
      cancelAnimationFrame(this.animationFrameId);
      this.animationFrameId = null;
      this.startTime = null;
      this.isPaused = false;
      this.pausedTime = null;
      this.handlers.stopped();
    }
  }
}

export default Motion;

Motion 클래스를 사용해보겠습니다.

HTML

네모난 .box div를 생성하고 하단에는 start / pause / resume / stop 버튼을 만듭니다.

<div class="box"></div>
<div>
  <button id="btn-start">start</button>
  <button id="btn-pause">pause</button>
  <button id="btn-resume">resume</button>
  <button id="btn-stop">stop</button>
</div>

Javascript 동작 구현하기

type="module" 을 지정하고 Motion을 import 합니다. 2초간 easeInOutSine easing 함수를 가지는 Motion 인스턴스를 생성합니다. 각각의 버튼에 Click EventListener를 등록해서 motion의 메서드를 실행합니다.

  • motion.start()
  • motion.pause()
  • motion.resume()
  • motion.stop()
<script type="module">
  import Motion from './motion.mjs';
  // 예제 사용법
  const motion = new Motion({
    duration: 2000,
    easing: 'easeInOutSine', // 이 함수는 애니메이션 시간 진행대로 값 반환 (자신의 이징 함수를 정의해서 사용 가능)
    loop: true,
    reverse: false,
  }).on({
    start() {
      console.log('Animation Started!');
    },
    update(progress) {
      document.querySelector('.box').style.transform = `translateX(${
        progress * 500
      }px)`;
    },
    paused() {
      console.log('Animation Paused!');
    },
    done() {
      console.log('Animation Done!');
      document.querySelector('.box').style.transform = `translateX(${500}px)`;
    },
    stopped() {
      console.log('Animation Stopped!');
    },
  });

  // motion 실행
  document.querySelector('#btn-start').addEventListener('click', () => {
    motion.start();
  });
  // motion 일시정지
  document.querySelector('#btn-pause').addEventListener('click', () => {
    motion.pause();
  });

  // motion 다시실행
  document.querySelector('#btn-resume').addEventListener('click', () => {
    motion.resume();
  });
  // motion 중지
  document.querySelector('#btn-stop').addEventListener('click', () => {
    motion.stop();
  });
</script>

전체코드

다음은 전체 코드입니다.

requestanimationframe

motion.mjs

/* motion.mjs */
/* 
  https://github.com/ai/easings.net/blob/master/src/easings/easingsFunctions.ts
*/
const bounceOut = function (x) {
  const n1 = 7.5625;
  const d1 = 2.75;

  if (t < 1 / d1) {
    return n1 * t * t;
  } else if (t < 2 / d1) {
    return n1 * (t -= 1.5 / d1) * t + 0.75;
  } else if (t < 2.5 / d1) {
    return n1 * (t -= 2.25 / d1) * t + 0.9375;
  } else {
    return n1 * (t -= 2.625 / d1) * t + 0.984375;
  }
};

const pow = Math.pow;
const sqrt = Math.sqrt;
const sin = Math.sin;
const cos = Math.cos;
const PI = Math.PI;
const c1 = 1.70158;
const c2 = c1 * 1.525;
const c3 = c1 + 1;
const c4 = (2 * PI) / 3;
const c5 = (2 * PI) / 4.5;

const easingFunctions = {
  // 선형 easing은 변환되지 않은 비율을 반환합니다. 애니메이션은 균일한 속도로 진행됩니다.
  linear: (t) => t,

  /*
    Quad (Quadratic)
    Quadratic easing은 제곱을 사용하여 애니메이션의 속도를 조절합니다. 
    t^2의 형태로 나타나며, 애니메이션은 부드러운 시작과 끝을 가지되, 중간에는 상대적으로 빠른 속도로 진행됩니다. 
    이는 기본적이면서도 널리 사용되는 easing 형태로, 약간의 가속이 필요할 때 유용합니다.
  */
  easeInQuad: (t) => t * t,
  easeOutQuad: (t) => t * (2 - t),
  easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),

  /*
    Cubic
    Cubic easing은 세제곱을 사용하여 속도를 조절합니다. 
    t^3의 형태로 표현되며, Quad보다 더 빠른 가속을 제공합니다. 
    시작과 끝에서 더 부드럽게, 중간에는 더욱 가파르게 속도가 변화합니다. 
    이 방식은 더욱 동적인 움직임이 요구될 때 적합합니다.
  */
  easeInCubic: (t) => t * t * t,
  easeOutCubic: (t) => --t * t * t + 1,
  easeInOutCubic: (t) =>
    t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,

  /*
    Quart (Quartic)
    Quartic easing은 네제곱을 사용하여 애니메이션의 속도를 조절합니다. 
    t^4의 형태로 나타나며, Cubic에 비해 더욱 강한 가속과 감속을 제공합니다. 
    이 유형의 easing은 매우 부드러운 시작과 끝, 그리고 중간에 매우 급격한 속도 변화를 원할 때 사용됩니다.
  */
  easeInQuart: (t) => t * t * t * t,
  easeOutQuart: (t) => 1 - --t * t * t * t,
  easeInOutQuart: (t) =>
    t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,

  /*
    Quint (Quintic)
    Quintic easing은 다섯 제곱을 사용하여 속도를 조절합니다. 
    t^5의 형태로, 시작과 종료 시 매우 부드러운 움직임과 함께, 가장 강력한 가속을 경험할 수 있습니다. 
    이는 Quart보다 더욱 강조된 가속과 감속이 특징으로, 매우 동적이고 강력한 애니메이션 효과를 원할 때 적합합니다.
  */
  easeInQuint: (t) => t * t * t * t * t,
  easeOutQuint: (t) => 1 + --t * t * t * t * t,
  easeInOutQuint: (t) =>
    t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t,

  /*
    Sine (Sinusoidal)
    Sinusoidal easing은 사인 곡선의 특성을 이용하여 애니메이션의 속도를 조절합니다. 
    sin(t)의 형태로, 자연스러운 움직임을 생성하기 위해 사용됩니다. 
    Sine easing은 시작과 종료가 매우 부드럽고, 전체적으로 부드러운 속도 변화를 나타내는 것이 특징입니다. 
    이 유형은 자연스러운 움직임을 시뮬레이션하거나, 부드러운 전환 효과가 필요할 때 유용합니다.
  */
  easeInSine: (t) => 1 - cos((t * PI) / 2),
  easeOutSine: (t) => sin((t * PI) / 2),
  easeInOutSine: (t) => -(cos(PI * t) - 1) / 2,

  /*
    Expo (Exponential)
    Exponential easing은 지수 함수를 사용하여 매우 느린 시작 후 급격한 가속 또는 급격한 감속 후 매우 느린 종료를 표현합니다. 
    이 유형의 easing은 애니메이션의 효과가 시간이 지남에 따라 지수적으로 증가하거나 감소함을 의미합니다. 
    예를 들어, easeInExpo는 애니메이션이 매우 천천히 시작되어 끝으로 갈수록 급격히 속도가 증가하는 효과를 만듭니다.
  */
  easeInExpo: (t) => (t === 0 ? 0 : pow(2, 10 * (t - 1))),
  easeOutExpo: (t) => (t === 1 ? 1 : 1 - pow(2, -10 * t)),
  easeInOutExpo: (t) =>
    t === 0
      ? 0
      : t === 1
        ? 1
        : t < 0.5
          ? pow(2, 20 * t - 10) / 2
          : (2 - pow(2, -20 * t + 10)) / 2,

  /*
    Circ (Circular)
    Circular easing은 원형 경로를 따라 움직이는 것처럼 보이게 하는 easing 방식입니다. 
    시작과 끝에서 매끄럽게 속도가 변화하며, 특히 easeInCirc와 easeOutCirc는 각각 원의 내부와 외부 경로를 따라 움직이는 것처럼 보이게 합니다. 
    이 유형은 애니메이션에 부드러운 시작과 끝을 제공하면서도 중간에는 상대적으로 급격한 속도 변화를 가져옵니다.
  */
  easeInCirc: (t) => 1 - sqrt(1 - t * t),
  easeOutCirc: (t) => sqrt(1 - --t * t),
  easeInOutCirc: (t) =>
    t < 0.5
      ? (1 - sqrt(1 - 2 * t * (2 * t))) / 2
      : (sqrt(1 - (-2 * t + 2) * (-2 * t + 2)) + 1) / 2,

  /*
    Back
    Back easing은 애니메이션이 시작점이나 종료점에서 약간 후퇴한 후, 목표 지점으로 빠르게 전진하거나 부드럽게 정착하는 효과를 만듭니다. 
    이는 마치 애니메이션이 뒤로 잠깐 후퇴하는 듯한 인상을 주며, 이러한 효과는 애니메이션에 독특한 '밀어내기' 느낌을 추가합니다.
  */
  easeInBack: (t) => c3 * t * t * t - c1 * t * t,
  easeOutBack: (t) => 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2),
  easeInOutBack: (t) =>
    t < 0.5
      ? (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
      : (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2,

  /*
    Elastic
    Elastic easing은 고무줄이 늘어났다가 돌아오는 것처럼, 애니메이션이 목표 지점을 넘어서 잠깐 확장되었다가 다시 돌아오는 효과를 만듭니다. 
    이 유형의 easing은 시작과 끝에서 '바운스' 효과를 나타내며, 마치 물체가 탄성을 가진 것처럼 늘어나고 줄어드는 모션을 보여줍니다.
  */
  easeInElastic: (t) =>
    t === 0
      ? 0
      : t === 1
        ? 1
        : -pow(2, 10 * t - 10) * sin((t * 10 - 10.75) * c4),
  easeOutElastic: (t) =>
    t === 0 ? 0 : t === 1 ? 1 : pow(2, -10 * t) * sin((t * 10 - 0.75) * c4) + 1,
  easeInOutElastic: (t) =>
    t === 0
      ? 0
      : t === 1
        ? 1
        : t < 0.5
          ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2
          : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 +
            1,

  /*
    Bounce
    Bounce easing은 물체가 떨어져서 바닥에 닿았을 때 여러 번 튀어오르는 것과 유사한 효과를 애니메이션에 추가합니다. 
    이 easing 유형은 물체가 마지막 목적지에 도달하기 전에 여러 번 바운스(튕겨오름)하는 모습을 통해 생동감과 재미를 더합니다.
    easeOutBounce는 목표 지점에 도착하여 멈추기 전에 몇 번의 반동을 포함하는 효과를 제공합니다.
  */
  easeInBounce: (t) => 1 - bounceOut(1 - t),
  easeOutBounce: bounceOut,
  easeInOutBounce: (t) =>
    t < 0.5
      ? (1 - easeOutBounce(1 - 2 * t)) / 2
      : (1 + easeOutBounce(2 * t - 1)) / 2,
};

class Motion {
  constructor({
    duration = 0, // 애니메이션 지속 시간
    easing = 'linear', // 기본값으로 'linear' 문자열 지정
    loop = false, // 반복
    reverse = false, // 거꾸로
  }) {
    this.duration = duration; // 지속 시간
    this.easing = easingFunctions[easing] || easingFunctions.linear;
    this.loop = loop;
    this.reverse = reverse;

    this.animationFrameId = null; // requestAnimationFrame의 id
    this.startTime = null; // 애니메이션 시작 시각
    this.isPaused = false; // 일시정지 boolean
    this.pausedTime = null; // 일시정지 시각
    this.progress = 0; // 진행률
    this.direction = 1; // 방향

    // 이벤트 핸들러
    this.handlers = {
      start: () => {},
      update: () => {},
      paused: () => {},
      done: () => {},
      stopped: () => {},
    };
  }

  on(eventHandlers) {
    // 이벤트 핸들러 설정
    Object.keys(eventHandlers).forEach((event) => {
      if (this.handlers[event]) {
        this.handlers[event] = eventHandlers[event];
      }
    });
    return this;
  }

  // 애니메이션 시작
  start() {
    if (!this.startTime) {
      this.startTime = performance.now(); // 애니메이션 시작 시간 설정
      this.handlers.start();
    }

    const animate = (timestamp) => {
      if (this.isPaused) {
        // 일시정지 상태이면 return
        return;
      }

      const elapsedTime = timestamp - this.startTime; // 소요시간
      const progress = elapsedTime / this.duration; // 진행률
      this.progress = this.reverse
        ? 1 - Math.abs((progress % 2) - 1)
        : progress % 1;

      this.handlers.update(this.easing(Math.min(this.progress, 1))); // 진행률은 최대 1

      if (!this.loop && elapsedTime >= this.duration) {
        // 애니메이션 종료 확인
        this.handlers.done();
      } else {
        this.animationFrameId = requestAnimationFrame(animate);
      }
    };

    this.animationFrameId = requestAnimationFrame(animate);
  }

  // 애니메이션 일시정지
  pause() {
    if (this.animationFrameId && !this.isPaused) {
      this.pausedTime = performance.now();
      this.isPaused = true;
      cancelAnimationFrame(this.animationFrameId);
      this.handlers.paused();
    }
  }

  // 애니메이션 다시 시작
  resume() {
    if (this.animationFrameId && this.isPaused) {
      this.isPaused = false;
      this.startTime += performance.now() - this.pausedTime;
      this.start();
    }
  }

  // 애니메이션 중지
  stop() {
    if (this.animationFrameId) {
      cancelAnimationFrame(this.animationFrameId);
      this.animationFrameId = null;
      this.startTime = null;
      this.isPaused = false;
      this.pausedTime = null;
      this.handlers.stopped();
    }
  }
}

export default Motion;

html

<!-- index.html -->
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="initial-scale=1.0, minimum-scale=1.0, width=device-width, viewport-fit=cover"
    />
    <style>
      .box {
        width: 60px;
        height: 60px;
        background-color: #f00;
      }
    </style>
    <title>VanilaJS Motion</title>
  </head>

  <body>
    <div class="box"></div>
    <div>
      <button id="btn-start">start</button>
      <button id="btn-pause">pause</button>
      <button id="btn-resume">resume</button>
      <button id="btn-stop">stop</button>
    </div>
    <script type="module">
      import Motion from './motion.mjs';
      // 예제 사용법
      const motion = new Motion({
        duration: 2000,
        easing: 'easeInOutSine', // 이 함수는 애니메이션 시간 진행대로 값 반환 (자신의 이징 함수를 정의해서 사용 가능)
        loop: true,
        reverse: false,
      }).on({
        start() {
          console.log('Animation Started!');
        },
        update(progress) {
          document.querySelector('.box').style.transform = `translateX(${
            progress * 500
          }px)`;
        },
        paused() {
          console.log('Animation Paused!');
        },
        done() {
          console.log('Animation Done!');
          document.querySelector('.box').style.transform =
            `translateX(${500}px)`;
        },
        stopped() {
          console.log('Animation Stopped!');
        },
      });

      document.querySelector('#btn-start').addEventListener('click', () => {
        motion.start();
      });
      document.querySelector('#btn-pause').addEventListener('click', () => {
        motion.pause();
      });
      document.querySelector('#btn-resume').addEventListener('click', () => {
        motion.resume();
      });
      document.querySelector('#btn-stop').addEventListener('click', () => {
        motion.stop();
      });
    </script>
  </body>
</html>