GODRICH

9 hiệu ứng custom cursor css — copy dán một dòng

Custom cursor css là cách thay mũi tên mặc định của trình duyệt bằng một phần tử tự vẽ, bám theo toạ độ chuột thật qua pointermove.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Thứ tự chín hiệu ứng đi từ đơn giản đến nặng tính toán. Mục 01 chỉ đổi hình dạng con trỏ, không cần JavaScript nào ngoài việc bám toạ độ. Từ 02 đến 07 mỗi mục thêm một phần tử phản hồi — bám theo, bị hút lại, đổi màu nền, hoặc phóng to khi hover. Mục 08 là nền tảng mà bảy mục phía trên đều ngầm dựa vào: cursor: none. Mục 09 đóng lại bằng phần dự phòng cho thiết bị cảm ứng, và thật ra cả chín demo đều tự tắt qua @media (pointer: coarse), không chỉ riêng mục cuối.

01Ảnh cursor tùy chỉnh

Thuộc tính cursor trỏ tới một ảnh hoặc SVG qua url(), thay cho mũi tên mặc định bằng một con trỏ mang đúng hình ảnh thương hiệu. Bản demo dựng lại hình đó bằng một phần tử DOM vì bản ghi hình không thấy được con trỏ thật của hệ điều hành.

cursor: url()hotspot 좌표fallback 커서
// 실제 서비스에서는 이 모양을 SVG 로 그려 cursor: url("cursor.svg") 12 12, auto; 로 쓴다.
// 데모는 헤드리스 렌더에서 OS 커서가 안 보이므로 같은 모양을 DOM 요소로 재현해 움직인다.
.cursor {
  position: absolute;
  top: 0;
  left: 0;
  width: 26px;
  height: 26px;
  border-radius: 50% 50% 50% 4px;
  background: $color;
  box-shadow: $shadow-press;
  transform: translate(var(--x, 60px), var(--y, 40px)) rotate(-45deg);
  pointer-events: none;

  &.is-demo { animation: cursor-path $duration $easing infinite; }
}

02Vòng tròn bám theo con trỏ

Một chấm nhỏ dịch chuyển ngay lập tức theo toạ độ chuột, còn vòng tròn viền quanh nó có transition riêng nên luôn chậm hơn một nhịp. Chính độ trễ 180ms đó tạo cảm giác vòng tròn đang đuổi theo chấm chứ không dính liền vào nó.

pointermovetransform: translate지연 추적
.dot,
.ring {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 50%;
  pointer-events: none;
}

.dot {
  width: 8px;
  height: 8px;
  background: $color;
}

.ring {
  width: 34px;
  height: 34px;
  margin: -13px 0 0 -13px;
  border: 2px solid $color;
  transition: transform 180ms $easing;
}

03Nút bị hút như nam châm

Khoảng cách giữa chuột và tâm nút được đo trong pointermove, rồi nút dịch chuyển một phần theo hướng đó — dist càng nhỏ thì giá trị pull (lực hút) càng lớn. Bán kính hút ở đây là 90px, ngoài phạm vi đó nút đứng yên.

pointermove거리 벡터transform: translate
// 실제 포인터가 버튼 반경 90px 안에 들어오면 거리에 비례해 버튼이 그쪽으로 끌려간다.
if (matchMedia('(pointer: fine)').matches) {
  var stage = document.querySelector('.stage'), btn = document.querySelector('.btn'), cur = document.querySelector('.cursor');
  stage.addEventListener('pointermove', function (e) {
    cur.classList.remove('is-demo'); btn.classList.remove('is-demo');
    var r = stage.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
    cur.style.transform = 'translate(' + x + 'px,' + y + 'px)';
    var br = btn.getBoundingClientRect(), cx = br.left - r.left + br.width / 2, cy = br.top - r.top + br.height / 2;
    var dx = x - cx, dy = y - cy, dist = Math.hypot(dx, dy), pull = dist < 90 ? (90 - dist) / 90 : 0;
    btn.style.transform = 'translate(' + (dx * pull * .35) + 'px,' + (dy * pull * .35) + 'px)';
  });
}

04Nhãn chữ hiện khi hover

Một bong bóng nhỏ bám theo chuột hiện chữ ngắn như Xem hay Kéo, chỉ hiện lên (đổi opacity) khi con trỏ nằm trong vùng của card, còn lại thì ẩn hẳn. Hai keyframes chạy song song — một lo vị trí, một lo độ mờ.

pointermoveopacity 전환라벨 텍스트
.label {
  position: absolute;
  top: 0;
  left: 0;
  padding: clamp(5px, 1.6vw, 7px) clamp(10px, 3vw, 14px);
  border-radius: $r-pill;
  background: $color;
  color: #fff;
  font: 700 clamp(11px, 3vw, 13px)/1 inherit;
  opacity: 0;
  transition: opacity 140ms $easing;
  pointer-events: none;
  transform: translate(-50%, -140%);

  &.is-demo {
    animation: label-path $duration $easing infinite,
               label-fade $duration $easing infinite;
  }
}

05Con trỏ mix-blend-mode

Một vòng tròn trắng có mix-blend-mode: difference đảo ngược đúng vùng màu nó đang che, nên cùng một con trỏ vừa đọc được trên nền tối vừa đọc được trên nền sáng mà không cần đổi màu bằng JavaScript. Xem MDN về mix-blend-mode để biết các chế độ trộn khác.

mix-blend-modedifferencez-index
.cursor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: 70px;
  height: 70px;
  margin: -35px 0 0 -35px;
  border-radius: 50%;
  background: $color;
  mix-blend-mode: difference;
  pointer-events: none;

  &.is-demo { animation: cursor-path $duration $easing infinite; }
}

06Vệt đuôi mờ dần

Sáu chấm nhỏ chạy cùng một quỹ đạo với chuột thật, nhưng mỗi chấm trễ hơn chấm trước 90ms và mờ dần theo thứ tự — một vòng lặp SCSS @for sinh ra cả sáu quy tắc đó thay vì gõ tay từng dòng.

requestAnimationFrameopacity 트랜지션잔상 점
.trail span {
  position: absolute;
  top: 0;
  left: 0;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  border-radius: 50%;
  background: $color;
  pointer-events: none;
}

@for $i from 1 through 6 {
  .trail.is-demo span:nth-child(#{$i}) {
    opacity: 1 - $i * .13;
    animation: cursor-path $duration $easing infinite;
    animation-delay: -#{$i * 90}ms;
  }
}

07Vòng tròn phóng to khi hover

Bộ chọn :has() theo dõi xem chuột thật có đang nằm trên một link hay không, và nếu có thì phóng vòng tròn lên 1.6 lần — không cần class .hover gắn bằng JavaScript nào cả, thuần một dòng CSS.

:hover 감지transform: scale0.2초
.ring {
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border: 2px solid $color;
  border-radius: 50%;
  transition: transform 180ms $easing;
}

// :has() 로 링크 위에 실제 포인터가 있을 때만 링을 키운다
.stage:has(.link:hover) .ring { transform: scale(1.6); }

.cursor-pos.is-demo .ring { animation: ring-grow $duration $easing infinite; }

08Ẩn con trỏ mặc định

cursor: none xoá mũi tên gốc của trình duyệt, nhường chỗ trống đó cho một phần tử con trỏ tự dựng. Đây là bước nền mà bảy hiệu ứng phía trên và cả cái bẫy dưới đây đều dựa vào.

cursor: none포인터 기기 감지기반 설정
.zone--default { background: $subject-cream; color: $stage-ink; cursor: auto; }
.zone--custom  { background: $stage-ink; color: $subject-cream; cursor: none; }

.cursor {
  position: absolute;
  top: 0;
  left: 0;
  width: 16px;
  height: 16px;
  margin: -8px 0 0 -8px;
  border-radius: 50%;
  background: $color;
  pointer-events: none;

  &.is-demo { animation: cursor-path $duration $easing infinite; }
}

09Xử lý dự phòng cho thiết bị cảm ứng

@media (pointer: coarse) phát hiện thiết bị cảm ứng và làm panel cảm ứng hiện sẵn, còn panel chuột thì biến mất hẳn — script pointermove không được gắn nữa vì không có gì để lắng nghe. Đây là bản vá đúng cho cái lỗ mà mục 08 mở ra.

pointer: coarse미디어쿼리JS 건너뛰기
.panel--mouse.is-demo { animation: panel-cross $duration $easing infinite; }
.panel--touch.is-demo { animation: panel-cross $duration $easing infinite; animation-delay: calc(#{$duration} / -2); }

@keyframes panel-cross {
  0%   { opacity: 1; }
  50%  { opacity: 0; }
  100% { opacity: 1; }
}

// 실사용에서는 이 미디어쿼리 하나가 실제 폴백이다
@media (pointer: coarse) {
  .panel--mouse { display: none; }
  .panel--touch { opacity: 1; animation: none; }
}

Chỗ dễ hỏng ở đâu? — cái bẫy

Cái bẫy đầu tiên là mix-blend-mode: difference âm thầm ngừng hoạt động nếu một thẻ cha của con trỏ có transform, filter, hoặc opacity khác 1 — bất kỳ thuộc tính nào tạo ra một ngữ cảnh xếp chồng mới đều cô lập việc trộn màu, nên con trỏ vẫn nằm đúng chỗ nhưng màu không đảo ngược nữa dù nhìn vào CSS vẫn thấy đúng. Lỗi này không báo gì trong console, DevTools vẫn hiện đúng thuộc tính, nên cách chắc nhất để phát hiện là kéo con trỏ ngang qua biên giữa hai nền sáng tối và quan sát bằng mắt xem màu có đổi hay không.

Cái bẫy thứ hai nối liền với mục 08 và mục 09: cursor: none là nền của tám trong chín hiệu ứng ở đây, nên nếu một trang chỉ chép mỗi dòng đó mà quên khối @media (pointer: coarse) đi kèm, người dùng trên điện thoại sẽ nhìn thấy một màn hình hoàn toàn không có con trỏ và cũng không có gì bám theo ngón tay để bấm — script pointermove còn chưa kịp gắn vì thiết bị cảm ứng vốn không bắn sự kiện đó. Chín file vanilla trong zip đều đã bọc sẵn cặp cursor: none và @media (pointer: coarse) đúng thứ tự, nhưng nếu bạn copy riêng phần .cursor mà bỏ sót khối media query thì lỗi y hệt như trên sẽ quay lại ngay trên chính bản sao của bạn. Nếu bạn muốn mở file zip ra kiểm tra hai khối @media này ngay bây giờ, mật khẩu của nó là x65p86j3.

Cái bẫy thứ ba nằm ở chính ô xem phía trên: lưới đó nhúng mỗi demo trong một iframe cố định 480×300 nhưng khoá pointer-events: none và không cuộn được, nên chuột thật của bạn không bao giờ chạm tới bên trong, và mỗi ô chỉ chạy vòng lặp .is-demo dựng sẵn thay cho pointermove thật. Ở đây bạn chỉ xem chuyển động lặp lại thôi; muốn thấy vòng tròn mục 02 hay nút mục 03 phản hồi đúng theo chuột của chính bạn, hãy mở riêng file vanilla trong một tab rồi tự tay rê chuột ở đó.

Khả năng tiếp cận

Chín demo đều có khối reduced-motion, nhưng cách xử lý khác nhau theo nhóm. Từ 01 đến 07 khoá animation ở vị trí bắt đầu — chấm và vòng tròn đứng yên tại toạ độ gốc thay vì chạy vòng lặp — vì bản thân việc bám theo mới là phần chuyển động, không phải trạng thái cuối. Mục 09 tắt hẳn animation nhưng vẫn giữ nguyên panel nào đang hiện, panel nào đang ẩn, vì đó là nội dung chứ không phải hiệu ứng trang trí. Tất cả chín mục cũng có khối @media (pointer: coarse) riêng, tắt hẳn phần tử con trỏ tự vẽ trên thiết bị cảm ứng chứ không chỉ làm nó đứng yên. Trang MDN về prefers-reduced-motion ghi rõ mức hỗ trợ trình duyệt cho media query này.

@media (prefers-reduced-motion: reduce) {
  .cursor.is-demo, .dot.is-demo, .ring.is-demo { animation: none; }
  .label.is-demo { animation: none; opacity: 0; }
  .trail.is-demo span { animation: none; opacity: 0; }
  .btn.is-demo { animation: none; }
}

Chín kiểu con trỏ ở trên chỉ là một nhánh nhỏ trong chuyên mục CSS của site — phần lớn còn lại là chuyển động cuộn và hover khác cùng kiểu chín-hiệu-ứng-một-file. Trang giới thiệu nói thẳng site này bán gì và vì sao mỗi bài đều đi kèm một file zip có khoá.

FAQ

Custom cursor css có bắt buộc dùng JavaScript không?

Không phải lúc nào cũng cần. Mục 01, 05 và 08 chỉ cần CSS thuần là chạy được — cursor: url(), mix-blend-mode và cursor: none đều là thuộc tính CSS thuần. Sáu mục còn lại (02, 03, 04, 06, 07 và 09) cần một listener pointermove ngắn để bám toạ độ chuột thật; ở mục 07 chỉ phần phóng to khi hover là CSS thuần nhờ :has().

cursor: url() khác gì với việc tự dựng một phần tử DOM làm con trỏ?

cursor: url() giao hẳn việc vẽ và di chuyển con trỏ cho trình duyệt, nhẹ nhưng bị giới hạn kích thước ảnh và không animate được. Một phần tử DOM tự vẽ nặng hơn một chút vì cần JavaScript để đọc toạ độ, nhưng đổi lại animate, đổi hình dạng theo ngữ cảnh, hay chạy mix-blend-mode đều làm được — cả chín demo ở đây chọn cách thứ hai vì cần animate.

Có dùng chung với Tailwind hoặc một dự án React được không?

Được. Chuyển động ở đây nằm trọn trong thuộc tính CSS và keyframes riêng, không đụng đến class tiện ích nào của Tailwind. Thư mục react/ trong file zip có sẵn một component cho mỗi hiệu ứng, nhận đúng những biến số này qua props nên không cần viết lại logic pointermove.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.