GODRICH

CSS 마우스 커서 효과 9종 — 한 줄 복붙으로

CSS 마우스 커서 효과는 브라우저 기본 화살표 대신 프로젝트에 맞춘 모양·잔상·블렌드 반응을 붙이는 것을 말합니다. 아홉 개 중 셋은 자석처럼 끌리거나 배경 색을 반전시키는, 흔한 예제 모음에서는 잘 안 보이는 조합이며, 아홉 개 전부 한 줄 복붙으로 붙습니다.

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

아홉 개는 인기순이 아니라 커스텀 커서가 실제로 쌓이는 순서로 놓았습니다. 모양 자체(01) → 좌표 추적(02~04) → 배경과의 상호작용(05~06) → 요소별 반응(07) → 기반 처리(08) → 기기 분기(09) 순입니다. 아래 코드는 효과마다 핵심 규칙만 골랐고, 좌표를 계산하는 나머지 스크립트는 zip의 vanilla/ 폴더에서 통째로 확인할 수 있습니다.

01커스텀 커서 이미지

cursor 속성에 url()로 이미지나 SVG를 지정하면 그 자리의 기본 화살표가 통째로 바뀝니다. 헤드리스 렌더는 OS 커서를 못 찍기 때문에 이 데모는 같은 물방울 모양을 DOM 요소로 재현해 움직였습니다.

cursor: url()hotspot 좌표fallback 커서
.stage { cursor: url("cursor.svg") 12 12, auto; }  // hotspot 12,12 + auto 폴백

.cursor {                         // 데모용 재현. 실제 서비스는 cursor 속성 하나로 끝난다
  width: 26px; height: 26px;
  border-radius: 50% 50% 50% 4px; // 물방울(핀) 모양
  background: $color;
  transform: translate(var(--x, 60px), var(--y, 40px)) rotate(-45deg);
}

02링이 따라옴

작은 점은 포인터 좌표에 즉시 붙고, 도넛 모양 링은 같은 좌표를 transition: transform으로 따라가 살짝 뒤처집니다. 포트폴리오 히어로 영역처럼 클릭 가능한 자리를 은근히 알리고 싶을 때 씁니다.

pointermovetransform: translate지연 추적
.dot { transform: translate(var(--x), var(--y)); }        // 지연 없음

.ring {
  border: 2px solid $color;
  border-radius: 50%;
  transition: transform 180ms $easing;                     // 이 한 줄이 "뒤처짐"의 전부
  transform: translate(var(--x), var(--y));
}

03자석처럼 끌리는 버튼

버튼 중심과 포인터 사이 거리를 재서 90px 안이면 그 방향으로 거리 비례만큼 버튼을 translate합니다. CTA 버튼처럼 눈에 띄어야 하는 자리에 잘 맞습니다.

pointermove거리 벡터transform: translate
stage.addEventListener('pointermove', function (e) {
  var r = stage.getBoundingClientRect();
  var x = e.clientX - r.left, y = e.clientY - r.top;
  var br = btn.getBoundingClientRect();
  var cx = br.left - r.left + br.width / 2, cy = br.top - r.top + br.height / 2;
  var dx = x - cx, dy = y - cy;
  var dist = Math.hypot(dx, dy);
  var pull = dist < 90 ? (90 - dist) / 90 : 0;      // 반경 90px 밖이면 0, 안이면 거리에 비례
  btn.style.transform = 'translate(' + (dx * pull * .35) + 'px,' + (dy * pull * .35) + 'px)';
});

04호버 시 텍스트 라벨

카드 위에서만 포인터를 따라다니는 말풍선에 "보기" 같은 짧은 안내를 띄웁니다. 이미지 카드나 캐러셀에서 다음 동작을 말없이 알리는 용도입니다.

pointermoveopacity 전환라벨 텍스트
.label {
  opacity: 0;
  transition: opacity 140ms $easing;
  transform: translate(var(--x), var(--y));
  pointer-events: none;                 // 카드의 실제 hover 판정을 가리지 않는다
}
.label.is-on { opacity: 1; }

05mix-blend-mode 커서

mix-blend-mode: difference를 준 흰 원이 포인터를 따라가며 그 아래 배경 색을 반전시킵니다. 아홉 개 중 이 데모의 누적 면적이 20.95%로 가장 커서, 배경이 밝든 어둡든 커서가 묻히지 않는다는 걸 렌더 수치로도 확인했습니다.

mix-blend-modedifferencez-index
.cursor {
  width: 70px; height: 70px;
  border-radius: 50%;
  background: #fff;
  mix-blend-mode: difference;
  transform: translate(var(--x), var(--y));
}

06잔상 트레일

포인터를 따라가는 점 하나에 같은 경로를 도는 점 5~6개를 음수 animation-delay로 겹쳐, 순간마다 서로 다른 위치에 있게 해서 옅어지는 꼬리처럼 보이게 합니다. 크리에이티브 포트폴리오에 어울립니다.

requestAnimationFrameopacity 트랜지션잔상 점
@for $i from 1 through 6 {
  .trail span:nth-child(#{$i}) {
    opacity: 1 - $i * .13;
    animation: cursor-path $duration $easing infinite;
    animation-delay: -#{$i * 90}ms;       // 음수 delay = 이미 그만큼 재생된 상태로 시작
  }
}

07호버 시 링 확대

링 커서가 클릭 가능한 링크 위에 오면 커집니다. 판정은 :has() 하나로 처리해서 hover마다 클래스를 토글하는 JS가 따로 없습니다.

:hover 감지transform: scale0.2초
.ring {
  border: 2px solid $color;
  border-radius: 50%;
  transition: transform 180ms $easing;
}
.stage:has(.link:hover) .ring { transform: scale(1.6); }

08기본 커서 숨김

cursor: none으로 브라우저 기본 화살표를 지우는, 나머지 여덟 개가 딛고 서는 처리입니다. 이 속성 없이는 커스텀 점을 아무리 그려도 진짜 화살표와 겹쳐 두 개로 보입니다.

cursor: none포인터 기기 감지기반 설정
.zone--custom {
  cursor: none;                     // 이 한 줄이 모든 커스텀 커서 효과의 전제
}
.cursor {
  position: absolute;
  transform: translate(var(--x), var(--y));
}

09터치 기기 대체 처리

@media (pointer: coarse)로 터치 기기를 감지해 커스텀 커서 스크립트를 아예 실행하지 않고, 원래 탭 동작을 그대로 남깁니다. 모바일에서도 열리는 페이지라면 반드시 필요합니다.

pointer: coarse미디어쿼리JS 건너뛰기
// pointer:fine 인 실제 기기에서만 스크립트가 돈다. 아래 미디어쿼리가 실제 폴백이다
@media (pointer: coarse) {
  .cursor { display: none; }        // 점을 지운다
  .stage { cursor: auto; }          // 기본 탭 동작을 되돌린다
}
@media (pointer: fine) {
  .stage { cursor: none; }          // 마우스가 있는 기기만 기본 화살표를 지운다
}

어디서 깨지는가 — 함정 하나

아홉 개를 렌더하면서 실제로 걸린 함정은 pointer-events였습니다. 04의 말풍선과 02의 링에 pointer-events: none을 빼먹으면, 그 요소 자신이 elementFromPointcontains() 판정의 대상이 되어 카드나 버튼 위에 있어도 "밖에 있다"고 잘못 읽힙니다. 커서를 그리는 요소는 늘 포인터 이벤트를 통과시켜야 그 아래 진짜 타깃이 이벤트를 받습니다. 이 수정을 담아 압축한 파일을 열 때 필요한 암호는 x65p86j3이며, 아래 버튼을 누르면 내려받을 수 있습니다.

07의 :has()는 Chrome·Edge 105+, Safari 15.4+, Firefox 121+에서 동작합니다(caniuse의 :has() 지원표). 이보다 오래된 브라우저에서는 셀렉터 자체가 무시되므로 링이 아예 안 커지는 것으로 그칩니다 — 다른 항목처럼 레이아웃이 깨지지는 않지만, 지원 브라우저를 먼저 확인하고 쓰는 게 안전합니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce에서 루프 애니메이션을 끄되, 02의 지연 추적이나 07의 확대처럼 실제 조작에 반응하는 동작은 남겼습니다. 움직임을 줄이는 설정과 기능을 아예 지우는 것은 다른 문제라서, 무엇을 끄고 무엇을 남길지는 MDN의 prefers-reduced-motion 문서를 기준으로 나눴습니다. 커스텀 커서 자체는 마우스로만 보이는 장식이라, 키보드나 스크린 리더로 쓰는 사람에게는 애초에 아무 의미가 없다는 점도 설계에 넣어야 합니다.

@media (prefers-reduced-motion: reduce) {
  .cursor.is-demo { animation: none; }
  .ring { transition: none; }
}

커서 효과 말고 다른 CSS 움직임은 CSS 애니메이션 카테고리 안에 모아뒀고, 사이트 운영 방식은 소개 페이지에 적어뒀습니다.

FAQ

커스텀 커서는 모바일에서도 보입니까?

아니요. 09처럼 @media (pointer: coarse)로 걸러 터치 기기에서는 스크립트를 아예 실행하지 않습니다. 터치 화면엔 애초에 상시 커서가 없으므로 대체 신호(탭 피드백 등)를 따로 준비해야 합니다.

실제 프로젝트에서도 커서를 DOM 요소로 그려야 합니까?

아닙니다. 01처럼 cursor: url() 한 줄로 끝나는 경우가 많습니다. 이 글의 데모가 DOM 점을 쓴 건 헤드리스 렌더가 OS 커서를 못 찍기 때문이고, zip의 vanilla/는 실제 포인터를 그대로 추적하는 코드입니다.

여러 효과를 한 페이지에 같이 써도 됩니까?

가능하지만 08의 cursor: none을 먼저 걸고, 나머지 효과가 그 위에 그리는 순서로 쌓는 걸 권합니다. 02의 링과 06의 트레일처럼 좌표를 공유하는 효과는 같은 포인터 이벤트 하나에 묶어야 어긋나지 않습니다.

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

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