Yongwoo (Jake) Jeong Blog

Blog Navigation

Search

Search Form

Results

    No result!

마우스 끈적임 효과 만들기

March 18, 2023

Keita Yamada는 일본의 뛰어난 개발자이자 디자이너 중 한 명이다. 그의 작업물은 독창적이며 아름답다. 나는 그의 포트폴리오 사이트를 이따금 들락거리며 배우고 영감을 얻는다. 그의 Experiments 프로젝트에서는 마우스가 요소에 들러붙는 듯한 느낌을 주는 효과를 볼 수 있다. 그 효과가 매력적으로 느껴져서, 한 번 만들어보고자 하는 마음이 생겼다.

결과물

다음은 내가 일차적으로 완성한 마우스 끈적임 효과다.

제작 과정

Keita Yamamda의 프로젝트에서 당장 눈에 보이는 특징은 다음과 같았다.


  • 마우스 커서 움직임에 시간차가 있다.
  • 마우스 커서를 목표 영역에 올릴 때
    • 마우스 커서가 목표 영역 중앙으로 이동한다.
    • 마우스 커서의 크기가 일정 크기까지 점점 커진다.
    • 마우스 커서와 목표 영역 내용물이 마우스가 움직이는 방향을 따라간다.
  • 마우스 커서가 목표 영역을 벗어나면
    • 마우스 커서가 다시 마우스 움직임을 따른다.
    • 마우스 커서의 크기가 기본 크기로 점점 줄어든다.
    • 목표 영역의 내용물이 점점 원래 위치로 돌아온다.

나는 먼저 마우스의 시간차 효과를 어떻게 만들지 고민했다. 문득 표시 커서가 실제 커서가 아닐 것이라는 생각이 들었다. 개발자 도구를 켜고 마우스 커서를 웹 페이지 영역 밖으로 옮겼다가 다른 위치에서 안쪽으로 옮겨보았다. 표시 커서가 빠르게 실제 마우스 커서 위치로 이동하는 걸 볼 수 있었다. 표시 커서 요소가 있을 것이라 예상했다. 코드를 뒤졌고 해당 요소가 있음을 확인했다. 그리고 CSS body 선택자 속성에 cursor: none 값이 있는 것도 확인했다. 그래서 먼저 표시 커서 요소와 목표 요소를 만들었다.


다음으로 document 객체에 mousemove 이벤트를 추가했다.

// script.js

// ...

const handleActualCursorPositionSettingInDocument = (event) => {
  actualCursorX = event.pageX;
  actualCursorY = event.pageY;
};

// ...

document.addEventListener(
  "mousemove",
  handleActualCursorPositionSettingInDocument
);

// ...

해당 이벤트가 발생할 때마다, 실제 마우스 커서의 위치를 구하고 변수에 담았다.



💡 event 객체에서 특정 위치의 값을 구하는 방법



그 다음 rAF(requestAnimationFrame)을 추가했다. 다음 로직을 rAF 콜백함수 안에 코드로 작성했다.

  1. 표시 커서의 다음 위치를 구하는 공식 만들기.
  2. 공식을 통해 구한 표시 커서의 현재 위치를 변수에 담기.
  3. 2번에서 담은 변수 값을 표시 커서 요소에 transform 속성 값으로 주기.

마지막으로 CSS body 선택자에 cursor: none 값을 줘서 실제 커서를 숨겼다. 아래는 관련 코드다.

// script.js

// ...

const triggerDisplayedCursorAnimation = () => {
  // ...

  // 표시 커서가 실제 커서를 따라다니는 효과.
  displayedCursorX -= (displayedCursorX - actualCursorX) * 0.8;
  displayedCursorY -= (displayedCursorY - actualCursorY) * 0.8;

  $displayedCursor.style.transform = `translate3D(${displayedCursorX}px, ${displayedCursorY}px, 0)`;

  // ...
};

// ...
/* style.css */

/* ... */

body {
  background-color: #000;
  min-height: 100vh;
  overflow: hidden;
  cursor: none;
}

/* ... */

그 중에 표시 커서의 다음 위치를 구하는 공식을 들여다보자.

// script.js

// ...

// 표시 커서가 실제 커서를 따라다니는 효과.
displayedCursorX -= (displayedCursorX - actualCursorX) * 0.8;
displayedCursorY -= (displayedCursorY - actualCursorY) * 0.8;

// ...

이 공식은 rAF 콜백함수 안에 있다. 그 말인즉슨 프레임이 진행됨에 따라 값이 변화한다는 뜻이다. 이 공식을 이해하기 쉽게 그림을 그려보면 다음과 같다.

위 그림은 실제 커서를 움직인 순간의 장면이다. 검은색 원은 표시 커서다. X축 식과 Y축 식이 X축인지 Y축인지 구분하는 표현만 제외하면 동일하므로 Y축 식은 생략했다.
내가 구해야 하던 건 표시 커서의 다음 위치였다. 표시 커서의 다음 위치를 구하는 식을 짧게 표현하면 dCX - (dCX - aCX)다. 하지만 이 식만으로는 시간차 효과를 충분히 내지 못한다. 표시 커서와 실제 커서의 위치가 거의 일치하기 때문이다. 따라서 완충제 역할을 하는 계수를 추가해줘야 한다.
계수를 추가한 식은 dCX - 0.8(dCX - aCX)다. 계수가 0.8이면 표시 커서의 현재 위치에서 실제 커서의 현재 위치로 80% 이동한 위치가 표시 커서의 다음 위치가 된다. 즉, 이 계수 값이 낮아질수록 표시 커서가 실제 커서를 천천히 따라가게 된다.



💡transform 속성 값 옵션 중에 3d가 붙는 옵션은 하드웨어 가속을 사용하게 만든다. 하드웨어 가속을 사용하면 성능을 향상시킬 수 있다.



다음으로 마우스 커서를 목표 요소에 올릴 때와 벗어날 때 생기는 효과를 어떻게 만들지 고민했다. mouseenter와 mouseover는 마우스 커서를 목표 요소에 올릴 때 이벤트를 발생시키는 이벤트 타입이다. 두 타입의 차이는 버블링 발생 유무다. mouseover는 버블링이 발생한다. 내게 필요한 건 버블링이 발생하지 않는 mouseenter였다. 마우스 커서가 목표 요소에 들어갈 때 이벤트를 한 번만 발생시키면 되기 때문이다.
마우스 커서가 목표 요소를 벗어날 때 이벤트를 발생시키는 이벤트 타입은 mouseleave와 mouseout이다. mouseleave는 mouseenter에 대응하고 mouseout은 mouseover에 대응한다. mouseleave도 mouseenter와 마찬가지로 버블링이 발생하지 않는다. 그러므로 내게 필요한 건 mouseleave였다.
mouseenter 이벤트 콜백함수에서 실제 커서를 목표 요소 위에 올릴 때 실행될 rAF를 추가했다. mouseleave 이벤트 콜백함수에서는 시간차 효과 로직이 있는 rAF가 다시 실행되게 만들었다. 그리고 여기서 플래그 변수를 선언했다.

// script.js

// ...

let displayedCursorIsSticked = false;

// ...

const handleDisplayedCursorSticksToTargetElement = (event) => {
  // ...

  displayedCursorIsSticked = true;

  triggerDisplayedCursorStickAnimation();
};

const handleDisplayedCursorDeviateFromTargetElement = () => {
  displayedCursorIsSticked = false;

  triggerDisplayedCursorAnimation();
};

// ...

이 플래그 변수를 이용해 mouseenter 이벤트가 실행되면 시간차 효과 rAF가 멈추고, mouseleave 이벤트가 실행되면 시간차 효과 rAF가 다시 작동되도록 만들었다. 이는 각 rAF 콜백함수의 재귀 코드에 if 조건문을 걸어서 구현할 수 있다.

// script.js

// ...

const triggerDisplayedCursorAnimation = () => {
  if (!displayedCursorIsSticked) {
    requestAnimationFrame(triggerDisplayedCursorAnimation);
  }

  // ...
};

// ...

const triggerDisplayedCursorStickAnimation = () => {
  if (displayedCursorIsSticked) {
    requestAnimationFrame(triggerDisplayedCursorStickAnimation);
  }

  // ...
};

// ...

mouseenter 이벤트가 실행되면 다음 로직이 실행되어야 했다.

  1. 표시 커서 크기가 증가.
  2. 표시 커서가 중앙으로 이동.
  3. 표시 커서와 목표 요소가 중앙을 기준으로 실제 커서 위치에 반응해 실제 커서 방향으로 끌려가기.

먼저 1번, 표시 커서 크기가 증가하는 효과는 CSS에 클래스 선택자를 만들고 JS로 클래스를 추가시키는 방향으로 구현했다. transform: scale3d()와 transition을 사용하면 크기가 점점 커지는 효과를 구현할 수 있다.
다음은 관련 완성 코드다.

// script.js

// ...

const triggerDisplayedCursorStickAnimation = () => {
  if (displayedCursorIsSticked) {
    requestAnimationFrame(triggerDisplayedCursorStickAnimation);
  }

  // 표시 커서가 목표 요소 중앙으로 이동하는 동시에, 실제 커서 위치에 반응해 끌려가는 효과.
  displayedCursorX -=
    (displayedCursorX -
      targetElementX -
      targetElementWidth / 2 -
      (targetElementActualCursorX - targetElementWidth / 2) * 0.2) *
    0.2;
  displayedCursorY -=
    (displayedCursorY -
      targetElementY -
      targetElementHeight / 2 -
      (targetElementActualCursorY - targetElementHeight / 2) * 0.2) *
    0.2;
  $displayedCursor.style.transform = `translate3D(${displayedCursorX}px, ${displayedCursorY}px, 0)`;

  // 표시 커서 크기가 커지는 효과.
  $displayedCursor.firstElementChild.classList.add("on-target-element");

  // 목표 요소가 실제 커서 위치에 반응해 끌려가는 효과.
  targetElementCircleX =
    (targetElementActualCursorX - targetElementWidth / 2) * 0.2;
  targetElementCircleY =
    (targetElementActualCursorY - targetElementHeight / 2) * 0.2;
  $targetElementCircle.style.transform = `translate3D(${targetElementCircleX}px, ${targetElementCircleY}px, 0)`;
};

// ...

$targetElements.forEach(($targetElement) => {
  $targetElement.addEventListener(
    "mouseenter",
    handleDisplayedCursorSticksToTargetElement
  );
});

// ...

triggerDisplayedCursorAnimation();
/* style.css */

/* ... */

.on-target-element {
  transform: scale3d(2, 2, 1);
}

transition을 활용하는 방법과 이유를 살펴보기 전에, 2번과 3번, 중앙 이동 효과와 끌려다니는 효과를 구현하기 위한 공식을 먼저 살펴보자.
다음은 완성된 공식이다. 이 공식은 rAF 콜백함수 안에 있다.

// script.js

// ...

// 표시 커서가 목표 요소 중앙으로 이동하는 동시에, 실제 커서 위치에 반응해 끌려가는 효과.
displayedCursorX -=
  (displayedCursorX -
    targetElementX -
    targetElementWidth / 2 -
    (targetElementActualCursorX - targetElementWidth / 2) * 0.2) *
  0.2;

// ...

위 공식을 그림으로 그려보면 다음과 같다.

위 그림은 실제 커서를 목표 요소에 재빠르게 올린 그 순간 한 프레임의 장면이다. 검은색 원은 표시 커서고, 9개의 사각형은 목표 요소이며, 파란색 원은 목표 요소의 자식 요소다. 파란색 원은 목표 요소의 정중앙에 위치한다. 앞서 봤던 표시 커서 위치를 구하는 공식 그림과 마찬가지로 Y축 식은 생략했다.
내가 원하던 건 현재 실제 커서가 올라가 있는 목표 요소의 중심으로부터 떨어진 실제 커서의 위치를 구하는 식이였다. 이 식을 짧게 표현하면 dCX - (dCX - tEX - tEW/2 - (tEACX - tEW/2))이 된다. 이 식도 효과를 충분히 보기 위해서는 완충제 역할을 하는 계수를 추가해야 한다.
계수를 추가한 식은 dCX - 0.2(dCX - tEX - tEW/2 - 0.2(tEACX - tEW/2))이 된다. 0.2(tEACX - tEW/2)의 계수는 표시 커서가 실제 커서와 목표 요소의 정중앙 사이에서 어디에 위치할지 결정한다. 0.2면 중앙에서 20% 떨어진 위치에 표시 커서가 최종적으로 위치하게 된다. 0.2(dCX - tEX - tEW/2 - 0.2(tEACX - tEW/2))의 계수는 표시 커서 이동을 얼마나 지연시킬지 결정한다. 이 계수의 역할은 표시 커서의 위치를 구하는 공식에서 계수가 하는 역할과 같다. 이 값을 올릴수록 표시 커서가 실제 커서를 천천히 따라가게 된다.


1번부터 3번까지 효과를 완성하기 위해서는 앞서 언급한 transition이 필요하다. 표시 커서에 transition 속성 값을 추가해줘야 한다. 그렇지 않으면 모션이 뚝뚝 끊기게 된다.

/* style.css */

/* ... */

.displayed-cursor {
  position: absolute;
  top: -10px;
  left: -10px;
  z-index: 10000;
  mix-blend-mode: difference;
  pointer-events: none;
  transition: transform 0.1s;
}

.displayed-cursor-circle {
  position: absolute;
  top: 0;
  left: 0;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background-color: #fff;
  transition: transform 0.3s;
}

/* ... */

위 코드처럼 transition에 값을 줘야 자연스러운 1번, 2번, 3번 효과를 만들 수 있다. transition의 지속 시간을 적절히 조절하면 계수를 추가했던 것처럼 완충제 효과를 얻을 수 있다.


이제 목표 요소의 자식 요소가 실제 커서에 반응해 끌려다니는 효과를 만드는 공식을 살펴보자. 이건 이미 구한 공식의 0.2(tEACX - tEW/2)와 transition을 사용했다. tEAC는 실제 커서가 올려져 있는 목표 요소의 위치를 기준으로 실제 커서가 현재 어디에 있는지 알려주는 값이다. 이 값은 목표 요소에 이벤트를 걸고 event.offsetX와 event.offsetY를 통해 가져왔다.

// script.js

// ...

const handleActualCursorPositionSettingInTargetElement = (event) => {
  targetElementActualCursorX = event.offsetX;
  targetElementActualCursorY = event.offsetY;
};

// ...

$targetElements.forEach(($targetElement) => {
  $targetElement.addEventListener(
    "mousemove",
    handleActualCursorPositionSettingInTargetElement
  );
});

// ...

그러므로, 0.2(tEACX - tEW/2) 값만 있어도 실제 커서와 목표 요소 중앙 사이의 거리를 조정할 수 있는 것이다.


다음으로 목표 요소의 자식 요소에도 transition을 추가해준다.

/* style.css */

/* ... */

.target-element-circle {
  background-color: #eee;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  transition: transform 0.2s;
  pointer-events: none;
}

/* ... */

마지막으로 남은 건 실제 커서가 목표 요소 영역을 벗어났을 때, 즉 mouseleave 이벤트가 실행되었을 때였다. 앞서 설명했듯이, mouseleave 이벤트가 실행되면 시간차 효과 rAF가 실행된다. 그래서 시간차 효과 rAF 콜백함수에 다음 로직을 코드를 넣어줬다.

  1. 시간차 효과 재실행.
  2. 표시 커서 크기 원상태로 되돌리기.
  3. 목표 요소의 자식 요소 원위치.

이를 구현한 코드는 다음과 같다.

// script.js

// ...

const triggerDisplayedCursorAnimation = () => {
  if (!displayedCursorIsSticked) {
    requestAnimationFrame(triggerDisplayedCursorAnimation);
  }

  // 표시 커서가 실제 커서를 따라다니는 효과.
  displayedCursorX -= (displayedCursorX - actualCursorX) * 0.8;
  displayedCursorY -= (displayedCursorY - actualCursorY) * 0.8;

  $displayedCursor.style.transform = `translate3D(${displayedCursorX}px, ${displayedCursorY}px, 0)`;

  // 표시 커서가 작아지는 효과.
  $displayedCursor.firstElementChild.classList.remove("on-target-element");

  // 목표 요소가 제자리로 돌아가는 효과.
  targetElementCircleX = 0;
  targetElementCircleY = 0;
  if ($targetElementCircle) {
    $targetElementCircle.style.transform = `translate3D(${targetElementCircleX}px, ${targetElementCircleY}px, 0)`;
  }
};

// ...

1번, 시간차 효과는 무언가 할 필요가 없다. 그대로 두면 실제 커서가 목표 요소 영역에 들어오기 전처럼 다시 작동한다. 2번, 커졌던 표시 커서를 다시 작게 만들려면 추가했던 CSS 클래스 선택자만 제거해주면 된다. 3번, 목표 요소의 자식 요소는 transform 값을 0으로 만들어주면 된다. 원래 위치가 목표 요소의 정중앙이기 때문이다.

마치며

작업 과정에서 여러가지 문제를 만났고 해결해나가는 과정은 쉽지 않았지만, 그만큼 많이 배울 수 있었다. 나도 Keita Yamada가 만든 매력적인 끈적임 효과를 비슷하게나마 만들 수 있게 된 점이 뿌듯하다. 이제 프로토타입으로 만들어 본 이 작업을 다른 프로젝트에 녹여내는 것이 남은 숙제다. 나아가 내 색깔을 입힌 개성있는 작업물을 만들 수 있으면 좋겠다.