CSS 마우스 커서 효과 9종 — 한 줄 복붙으로
CSS 마우스 커서 효과는 브라우저 기본 화살표 대신 프로젝트에 맞춘 모양·잔상·블렌드 반응을 붙이는 것을 말합니다. 아홉 개 중 셋은 자석처럼 끌리거나 배경 색을 반전시키는, 흔한 예제 모음에서는 잘 안 보이는 조합이며, 아홉 개 전부 한 줄 복붙으로 붙습니다.
01 cursor-effects-url-cursor
02 cursor-effects-ring-follow
03 cursor-effects-magnetic-btn
04 cursor-effects-cursor-text-label
05 cursor-effects-blend-cursor
06 cursor-effects-trail-effect
07 cursor-effects-hover-grow-ring
08 cursor-effects-hide-native-cursor
09 cursor-effects-touch-fallback-safe
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 커스텀 커서 이미지
- 02 링이 따라옴
- 03 자석처럼 끌리는 버튼
- 04 호버 시 텍스트 라벨
- 05 mix-blend-mode 커서
- 06 잔상 트레일
- 07 호버 시 링 확대
- 08 기본 커서 숨김
- 09 터치 기기 대체 처리
아홉 개는 인기순이 아니라 커스텀 커서가 실제로 쌓이는 순서로 놓았습니다. 모양 자체(01) → 좌표 추적(02~04) → 배경과의 상호작용(05~06) → 요소별 반응(07) → 기반 처리(08) → 기기 분기(09) 순입니다. 아래 코드는 효과마다 핵심 규칙만 골랐고, 좌표를 계산하는 나머지 스크립트는 zip의 vanilla/ 폴더에서 통째로 확인할 수 있습니다. 포인터 옆에 짧은 설명을 띄우는 쪽은 CSS 툴팁 말풍선 9종이고, 커서가 아니라 글자가 스스로 찍히는 효과는 CSS 타이핑 효과 9종에 있습니다.
01커스텀 커서 이미지
어떻게 움직이나
cursor 속성에 url()로 이미지나 SVG를 지정하면 그 자리의 기본 화살표가 통째로 바뀝니다.
어디에 쓰나
헤드리스 렌더는 OS 커서를 못 찍기 때문에 이 데모는 같은 물방울 모양을 DOM 요소로 재현해 움직였습니다.

<main class="stage">
<div class="board">
<span class="chip">CSS</span>
<span class="chip">SVG</span>
<span class="chip">cursor</span>
</div>
<div class="cursor is-demo" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 커서가 보드를 한 바퀴 도는 시간
$easing: $ease-spring;
$color: $subject-cream; // 커서 모양 색
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; cursor: none; } // 기본 화살표를 지우고 커스텀 모양만 보인다
.board {
display: flex;
gap: clamp(6px, 2vw, 10px);
padding: clamp(10px, 3vw, 16px) clamp(14px, 4vw, 20px);
background: rgba(255, 255, 255, .06);
border-radius: $r-card;
}
.chip {
padding: clamp(6px, 2vw, 9px) clamp(10px, 3vw, 14px);
border-radius: $r-pill;
background: $subject-blue;
color: #fff;
font-weight: 600; font-size: clamp(11px, 3vw, 14px); line-height: 1; font-family: inherit;
}
// 실제 서비스에서는 이 모양을 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; // 물방울(핀) 모양 — url() 커서 이미지를 흉내
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; }
}
@keyframes cursor-path {
0% { transform: translate(40px, 30px) rotate(-45deg); }
35% { transform: translate(220px, 60px) rotate(-45deg); }
70% { transform: translate(140px, 140px) rotate(-45deg); }
100% { transform: translate(40px, 30px) rotate(-45deg); }
}
@media (pointer: coarse) {
.cursor { display: none; } // 터치 기기엔 커스텀 커서 개념이 없다
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo { animation: none; transform: translate(40px, 30px) rotate(-45deg); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.board {
display: flex;
gap: clamp(6px, 2vw, 10px);
padding: clamp(10px, 3vw, 16px) clamp(14px, 4vw, 20px);
background: rgba(255, 255, 255, 0.06);
border-radius: 16px;
}
.chip {
padding: clamp(6px, 2vw, 9px) clamp(10px, 3vw, 14px);
border-radius: 999px;
background: #2f6df6;
color: #fff;
font-weight: 600;
font-size: clamp(11px, 3vw, 14px);
line-height: 1;
font-family: inherit;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 26px;
height: 26px;
border-radius: 50% 50% 50% 4px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(var(--x, 60px), var(--y, 40px)) rotate(-45deg);
pointer-events: none;
}
.cursor.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cursor-path {
0% {
transform: translate(40px, 30px) rotate(-45deg);
}
35% {
transform: translate(220px, 60px) rotate(-45deg);
}
70% {
transform: translate(140px, 140px) rotate(-45deg);
}
100% {
transform: translate(40px, 30px) rotate(-45deg);
}
}
@media (pointer: coarse) {
.cursor {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo {
animation: none;
transform: translate(40px, 30px) rotate(-45deg);
}
}// 실제 포인터가 움직이면 커스텀 커서가 그 좌표를 따라간다. 터치 기기는 pointer:coarse 로 건너뛴다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage');
var cur = document.querySelector('.cursor');
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
cur.classList.remove('is-demo');
cur.style.setProperty('--x', (e.clientX - r.left) + 'px');
cur.style.setProperty('--y', (e.clientY - r.top) + 'px');
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./CustomCursorImage.module.scss";
export type CustomCursorImageProps = {
chips?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CustomCursorImage({
chips = ["CSS", "SVG", "cursor"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: CustomCursorImageProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 60, y: 40 });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
setPos({ x: e.clientX - r.left, y: e.clientY - r.top });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<div className={styles.board}>
{chips.map((c) => (
<span key={c} className={styles.chip}>{c}</span>
))}
</div>
<div
className={demo ? `${styles.cursor} ${styles.isDemo}` : styles.cursor}
style={demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px) rotate(-45deg)` }}
aria-hidden="true"
/>
</div>
);
}01 · 커스텀 커서 이미지 — 측정 누적면적 5.176% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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으로 따라가 살짝 뒤처집니다.
어디에 쓰나
포트폴리오 히어로 영역처럼 클릭 가능한 자리를 은근히 알리고 싶을 때 씁니다.

<main class="stage">
<p class="hint">포트폴리오</p>
<div class="dot is-demo" aria-hidden="true"></div>
<div class="ring is-demo" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 점이 경로를 한 바퀴 도는 시간
$easing: $ease-spring;
$color: $subject-ink; // 링 색
@include stage($stage-yellow);
.stage { position: relative; overflow: hidden; cursor: none; }
.hint {
margin: 0;
font-weight: 700; font-size: clamp(16px, 5vw, 22px); line-height: 1.3; font-family: inherit;
color: rgba($stage-ink, .72); // AA: 옐로 면 위 6.06:1
}
.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; // 실제 조작에서 링이 뒤늦게 쫓아오는 이유
}
.dot.is-demo { animation: cursor-path $duration $easing infinite; }
.ring.is-demo { animation: cursor-path $duration $easing infinite; animation-delay: -140ms; }
@keyframes cursor-path {
0% { transform: translate(50px, 40px); }
35% { transform: translate(230px, 70px); }
70% { transform: translate(150px, 150px); }
100% { transform: translate(50px, 40px); }
}
@media (pointer: coarse) {
.dot, .ring { display: none; }
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.dot.is-demo, .ring.is-demo { animation: none; transform: translate(50px, 40px); }
.ring { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.hint {
margin: 0;
font-weight: 700;
font-size: clamp(16px, 5vw, 22px);
line-height: 1.3;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
}
.dot,
.ring {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
pointer-events: none;
}
.dot {
width: 8px;
height: 8px;
background: #17141a;
}
.ring {
width: 34px;
height: 34px;
margin: -13px 0 0 -13px;
border: 2px solid #17141a;
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dot.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ring.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -140ms;
}
@keyframes cursor-path {
0% {
transform: translate(50px, 40px);
}
35% {
transform: translate(230px, 70px);
}
70% {
transform: translate(150px, 150px);
}
100% {
transform: translate(50px, 40px);
}
}
@media (pointer: coarse) {
.dot, .ring {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.dot.is-demo, .ring.is-demo {
animation: none;
transform: translate(50px, 40px);
}
.ring {
transition: none;
}
}// 실제 포인터가 움직이면 점과 링이 같은 좌표를 따라가되 링에는 transition 지연이 있어 뒤처져 보인다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage');
var dot = document.querySelector('.dot');
var ring = document.querySelector('.ring');
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
dot.classList.remove('is-demo'); ring.classList.remove('is-demo');
var v = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
dot.style.transform = v; ring.style.transform = v;
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./CursorRingFollow.module.scss";
export type CursorRingFollowProps = {
hint?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CursorRingFollow({
hint = "포트폴리오",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: CursorRingFollowProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 50, y: 40 });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
setPos({ x: e.clientX - r.left, y: e.clientY - r.top });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const t = `translate(${pos.x}px,${pos.y}px)`;
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<p className={styles.hint}>{hint}</p>
<div className={demo ? `${styles.dot} ${styles.isDemo}` : styles.dot} style={demo ? undefined : { transform: t }} />
<div className={demo ? `${styles.ring} ${styles.isDemo}` : styles.ring} style={demo ? undefined : { transform: t }} />
</div>
);
}02 · 링이 따라옴 — 측정 누적면적 4.766% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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 버튼처럼 눈에 띄어야 하는 자리에 잘 맞습니다.

<main class="stage">
<button class="btn is-demo" type="button">시작하기</button>
<div class="cursor is-demo" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 커서가 버튼을 스치는 한 바퀴
$easing: $ease-spring;
$color: $subject-cream; // 버튼 색
@include stage($stage-orange);
.stage { position: relative; overflow: hidden; cursor: none; }
.btn {
position: relative;
border: 0;
border-radius: $r-pill;
padding: clamp(12px, 3.4vw, 18px) clamp(26px, 7vw, 40px);
font-weight: 700; font-size: clamp(14px, 3.8vw, 18px); line-height: 1; font-family: inherit;
color: $subject-orange-deep; // AA: 크림 면 위 주황 글자 5.04:1
background: $color;
cursor: pointer;
transition: transform 160ms $easing; // 실제 조작에서 버튼이 끌려가는 부드러움
&.is-demo { animation: magnet-pull $duration $easing infinite; }
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: $stage-ink;
pointer-events: none;
&.is-demo { animation: cursor-path $duration $easing infinite; }
}
@keyframes magnet-pull {
0%, 100% { transform: translate(0, 0); }
35% { transform: translate(12px, -8px); }
55% { transform: translate(0, 0); }
}
@keyframes cursor-path {
0% { transform: translate(30px, 150px); }
35% { transform: translate(150px, 60px); } // 버튼 근처를 스친다
70% { transform: translate(280px, 190px); }
100% { transform: translate(30px, 150px); }
}
@media (pointer: coarse) {
.cursor { display: none; }
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.btn.is-demo { animation: none; }
.cursor.is-demo { animation: none; transform: translate(30px, 150px); }
.btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.btn {
position: relative;
border: 0;
border-radius: 999px;
padding: clamp(12px, 3.4vw, 18px) clamp(26px, 7vw, 40px);
font-weight: 700;
font-size: clamp(14px, 3.8vw, 18px);
line-height: 1;
font-family: inherit;
color: #c93000;
background: #fff7e6;
cursor: pointer;
transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.btn.is-demo {
animation: magnet-pull 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.cursor.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes magnet-pull {
0%, 100% {
transform: translate(0, 0);
}
35% {
transform: translate(12px, -8px);
}
55% {
transform: translate(0, 0);
}
}
@keyframes cursor-path {
0% {
transform: translate(30px, 150px);
}
35% {
transform: translate(150px, 60px);
}
70% {
transform: translate(280px, 190px);
}
100% {
transform: translate(30px, 150px);
}
}
@media (pointer: coarse) {
.cursor {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.btn.is-demo {
animation: none;
}
.cursor.is-demo {
animation: none;
transform: translate(30px, 150px);
}
.btn {
transition: none;
}
}// 실제 포인터가 버튼 반경 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)';
});
}import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./MagneticCursorButton.module.scss";
export type MagneticCursorButtonProps = {
label?: string;
radius?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MagneticCursorButton({
label = "시작하기",
radius = 90,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: MagneticCursorButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);
const [demo, setDemo] = useState(true);
const [cur, setCur] = useState({ x: 30, y: 150 });
const [pull, setPull] = useState({ x: 0, y: 0 });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
const x = e.clientX - r.left, y = e.clientY - r.top;
setCur({ x, y });
const br = btnRef.current!.getBoundingClientRect();
const cx = br.left - r.left + br.width / 2, cy = br.top - r.top + br.height / 2;
const dx = x - cx, dy = y - cy, dist = Math.hypot(dx, dy), p = dist < radius ? (radius - dist) / radius : 0;
setPull({ x: dx * p * 0.35, y: dy * p * 0.35 });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<button
ref={btnRef}
type="button"
className={demo ? `${styles.btn} ${styles.isDemo}` : styles.btn}
style={demo ? undefined : { transform: `translate(${pull.x}px,${pull.y}px)` }}
>
{label}
</button>
<div
className={demo ? `${styles.cursor} ${styles.isDemo}` : styles.cursor}
style={demo ? undefined : { transform: `translate(${cur.x}px,${cur.y}px)` }}
aria-hidden="true"
/>
</div>
);
}03 · 자석처럼 끌리는 버튼 — 측정 누적면적 4.833% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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호버 시 텍스트 라벨
어떻게 움직이나
카드 위에서만 포인터를 따라다니는 말풍선에 "보기" 같은 짧은 안내를 띄웁니다.
어디에 쓰나
이미지 카드나 캐러셀에서 다음 동작을 말없이 알리는 용도입니다.

<main class="stage">
<div class="card"></div>
<span class="label is-demo" aria-hidden="true">보기</span>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 라벨이 카드 위를 도는 한 바퀴
$easing: $ease-spring;
$color: $subject-blue; // 라벨 배경색
@include stage($stage-paper);
.stage { position: relative; overflow: hidden; cursor: none; }
.card {
width: clamp(140px, 42vw, 220px);
height: clamp(90px, 28vw, 140px);
border-radius: $r-card;
background: $subject-ink;
}
.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-weight: 700; font-size: clamp(11px, 3vw, 13px); line-height: 1; font-family: 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;
}
}
@keyframes label-path {
0% { transform: translate(calc(-50% + 70px), calc(-140% + 40px)); }
40% { transform: translate(calc(-50% + 150px), calc(-140% + 90px)); }
60% { transform: translate(calc(-50% + 150px), calc(-140% + 90px)); }
100% { transform: translate(calc(-50% + 70px), calc(-140% + 40px)); }
}
@keyframes label-fade {
0%, 100% { opacity: 0; }
30%, 65% { opacity: 1; }
}
@media (pointer: coarse) {
.label { display: none; }
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.label.is-demo { animation: none; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.card {
width: clamp(140px, 42vw, 220px);
height: clamp(90px, 28vw, 140px);
border-radius: 16px;
background: #17141a;
}
.label {
position: absolute;
top: 0;
left: 0;
padding: clamp(5px, 1.6vw, 7px) clamp(10px, 3vw, 14px);
border-radius: 999px;
background: #2f6df6;
color: #fff;
font-weight: 700;
font-size: clamp(11px, 3vw, 13px);
line-height: 1;
font-family: inherit;
opacity: 0;
transition: opacity 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
transform: translate(-50%, -140%);
}
.label.is-demo {
animation: label-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, label-fade 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes label-path {
0% {
transform: translate(calc(-50% + 70px), calc(-140% + 40px));
}
40% {
transform: translate(calc(-50% + 150px), calc(-140% + 90px));
}
60% {
transform: translate(calc(-50% + 150px), calc(-140% + 90px));
}
100% {
transform: translate(calc(-50% + 70px), calc(-140% + 40px));
}
}
@keyframes label-fade {
0%, 100% {
opacity: 0;
}
30%, 65% {
opacity: 1;
}
}
@media (pointer: coarse) {
.label {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.label.is-demo {
animation: none;
opacity: 0;
}
}// 카드 위에서만 라벨이 보이고, 실제 포인터 좌표를 그대로 따라간다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage'), card = document.querySelector('.card'), label = document.querySelector('.label');
stage.addEventListener('pointermove', function (e) {
label.classList.remove('is-demo');
var r = stage.getBoundingClientRect();
label.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
label.style.opacity = card.contains(e.target) ? 1 : 0;
});
}import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./CursorTextLabel.module.scss";
export type CursorTextLabelProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CursorTextLabel({
label = "보기",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CursorTextLabelProps) {
const cardRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 0, y: 0, on: false });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
const on = cardRef.current!.contains(e.target as Node);
setPos({ x: e.clientX - r.left, y: e.clientY - r.top, on });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<div ref={cardRef} className={styles.card} />
<span
className={demo ? `${styles.label} ${styles.isDemo}` : styles.label}
style={demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px)`, opacity: pos.on ? 1 : 0 }}
aria-hidden="true"
>
{label}
</span>
</div>
);
}04 · 호버 시 텍스트 라벨 — 측정 누적면적 3.302% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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%로 가장 커서, 배경이 밝든 어둡든 커서가 묻히지 않는다는 걸 렌더 수치로도 확인했습니다.

<main class="stage">
<div class="half half--left"></div>
<div class="half half--right"></div>
<p class="banner">반전</p>
<div class="cursor is-demo" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 원이 두 영역을 오가는 한 바퀴
$easing: $ease-spring;
$color: $stage-paper; // 블렌드 원은 흰색이어야 difference 가 또렷하다
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; cursor: none; }
.half {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
z-index: 0;
}
.half--left { left: 0; background: $stage-ink; }
.half--right { right: 0; background: $subject-blue; }
.banner {
position: relative;
z-index: 1;
margin: 0;
font-weight: 800; font-size: clamp(34px, 11vw, 56px); line-height: 1; font-family: inherit;
color: $subject-cream;
letter-spacing: -.02em;
}
.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; }
}
@keyframes cursor-path {
0% { transform: translate(80px, 60px); }
35% { transform: translate(280px, 150px); } // 오른쪽 영역으로 넘어간다
70% { transform: translate(120px, 200px); }
100% { transform: translate(80px, 60px); }
}
@media (pointer: coarse) {
.cursor { display: none; }
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo { animation: none; transform: translate(80px, 60px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.half {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
z-index: 0;
}
.half--left {
left: 0;
background: #17141a;
}
.half--right {
right: 0;
background: #2f6df6;
}
.banner {
position: relative;
z-index: 1;
margin: 0;
font-weight: 800;
font-size: clamp(34px, 11vw, 56px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
letter-spacing: -0.02em;
}
.cursor {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 70px;
height: 70px;
margin: -35px 0 0 -35px;
border-radius: 50%;
background: #ffffff;
mix-blend-mode: difference;
pointer-events: none;
}
.cursor.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cursor-path {
0% {
transform: translate(80px, 60px);
}
35% {
transform: translate(280px, 150px);
}
70% {
transform: translate(120px, 200px);
}
100% {
transform: translate(80px, 60px);
}
}
@media (pointer: coarse) {
.cursor {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo {
animation: none;
transform: translate(80px, 60px);
}
}// 실제 포인터가 움직이면 블렌드 원이 그 좌표를 따라가며 지나는 배경 색을 반전시킨다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage'), cur = document.querySelector('.cursor');
stage.addEventListener('pointermove', function (e) {
cur.classList.remove('is-demo');
var r = stage.getBoundingClientRect();
cur.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./BlendModeCursor.module.scss";
export type BlendModeCursorProps = {
text?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BlendModeCursor({
text = "반전",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffffff",
}: BlendModeCursorProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 80, y: 60 });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
setPos({ x: e.clientX - r.left, y: e.clientY - r.top });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<div className={`${styles.half} ${styles.halfLeft}`} />
<div className={`${styles.half} ${styles.halfRight}`} />
<p className={styles.banner}>{text}</p>
<div
className={demo ? `${styles.cursor} ${styles.isDemo}` : styles.cursor}
style={demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px)` }}
aria-hidden="true"
/>
</div>
);
}05 · mix-blend-mode 커서 — 측정 누적면적 20.953% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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로 겹쳐, 순간마다 서로 다른 위치에 있게 해서 옅어지는 꼬리처럼 보이게 합니다.
어디에 쓰나
크리에이티브 포트폴리오에 어울립니다.

<main class="stage">
<div class="trail is-demo" aria-hidden="true">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="cursor is-demo" aria-hidden="true"></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 경로를 한 바퀴 도는 시간
$easing: $ease-spring;
$color: $subject-ink; // 점 색
@include stage($stage-yellow);
.stage { position: relative; overflow: hidden; cursor: none; }
.cursor {
position: absolute;
top: 0;
left: 0;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
border-radius: 50%;
background: $color;
pointer-events: none;
&.is-demo { animation: cursor-path $duration $easing infinite; }
}
.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;
}
// 점마다 같은 경로를 시차(음수 delay)를 두고 돌아 순간마다 서로 다른 위치에 있게 되고, 그게 꼬리로 보인다.
@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;
}
}
@keyframes cursor-path {
0% { transform: translate(40px, 200px); }
30% { transform: translate(140px, 60px); }
60% { transform: translate(260px, 180px); }
100% { transform: translate(40px, 200px); }
}
@media (pointer: coarse) {
.cursor, .trail { display: none; }
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo { animation: none; transform: translate(40px, 200px); }
.trail.is-demo span { animation: none; opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.cursor.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.trail span {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
border-radius: 50%;
background: #17141a;
pointer-events: none;
}
.trail.is-demo span:nth-child(1) {
opacity: 0.87;
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -90ms;
}
.trail.is-demo span:nth-child(2) {
opacity: 0.74;
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -180ms;
}
.trail.is-demo span:nth-child(3) {
opacity: 0.61;
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -270ms;
}
.trail.is-demo span:nth-child(4) {
opacity: 0.48;
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -360ms;
}
.trail.is-demo span:nth-child(5) {
opacity: 0.35;
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -450ms;
}
.trail.is-demo span:nth-child(6) {
opacity: 0.22;
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -540ms;
}
@keyframes cursor-path {
0% {
transform: translate(40px, 200px);
}
30% {
transform: translate(140px, 60px);
}
60% {
transform: translate(260px, 180px);
}
100% {
transform: translate(40px, 200px);
}
}
@media (pointer: coarse) {
.cursor, .trail {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo {
animation: none;
transform: translate(40px, 200px);
}
.trail.is-demo span {
animation: none;
opacity: 0;
}
}// 실제 포인터를 움직이면 맨 앞 점이 그 좌표를 따라간다. 꼬리 점들은 같은 경로를 시차를 두고 도는 채로 남는다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage'), cur = document.querySelector('.cursor');
stage.addEventListener('pointermove', function (e) {
cur.classList.remove('is-demo');
var r = stage.getBoundingClientRect();
cur.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./CursorTrail.module.scss";
export type CursorTrailProps = {
dots?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CursorTrail({
dots = 6,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: CursorTrailProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 40, y: 200 });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
setPos({ x: e.clientX - r.left, y: e.clientY - r.top });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
{demo &&
Array.from({ length: dots }).map((_, i) => (
<span
key={i}
className={styles.trailDot}
style={{ opacity: 1 - (i + 1) * 0.13, animationDelay: `${-(i + 1) * 90}ms` }}
/>
))}
<div
className={demo ? `${styles.cursor} ${styles.isDemo}` : styles.cursor}
style={demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px)` }}
aria-hidden="true"
/>
</div>
);
}06 · 잔상 트레일 — 측정 누적면적 2.407% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
@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가 따로 없습니다.

<main class="stage">
<a class="link link--a" href="#">기능</a>
<a class="link link--b" href="#">가격</a>
<div class="cursor-pos is-demo" aria-hidden="true"><div class="ring"></div></div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 링이 두 링크를 도는 한 바퀴
$easing: $ease-spring;
$color: $subject-cream; // 링 색
@include stage($stage-orange);
.stage { position: relative; overflow: hidden; cursor: none; }
.link {
position: absolute;
padding: clamp(8px, 2.4vw, 12px) clamp(16px, 4.4vw, 22px);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
color: $subject-ink; // AA: 주황 면(흰 14% 겹침 #d2ada4) 위 5.96:1
font-weight: 700; font-size: clamp(12px, 3.2vw, 15px); line-height: 1; font-family: inherit;
text-decoration: none;
}
.link--a { top: 22%; left: 14%; }
.link--b { bottom: 22%; right: 14%; }
.cursor-pos {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
&.is-demo { animation: cursor-path $duration $easing infinite; }
}
.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; }
@keyframes cursor-path {
0% { transform: translate(90px, 60px); }
40% { transform: translate(90px, 60px); } // link--a 위
50% { transform: translate(300px, 210px); }
90% { transform: translate(300px, 210px); } // link--b 위
100% { transform: translate(90px, 60px); }
}
@keyframes ring-grow {
0%, 100% { transform: scale(1); }
20%, 40% { transform: scale(1.6); }
50%, 70% { transform: scale(1.6); }
80% { transform: scale(1); }
}
@media (pointer: coarse) {
.cursor-pos { display: none; }
.stage { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.cursor-pos.is-demo { animation: none; transform: translate(90px, 60px); }
.cursor-pos.is-demo .ring { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
cursor: none;
}
.link {
position: absolute;
padding: clamp(8px, 2.4vw, 12px) clamp(16px, 4.4vw, 22px);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #17141a;
font-weight: 700;
font-size: clamp(12px, 3.2vw, 15px);
line-height: 1;
font-family: inherit;
text-decoration: none;
}
.link--a {
top: 22%;
left: 14%;
}
.link--b {
bottom: 22%;
right: 14%;
}
.cursor-pos {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
.cursor-pos.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ring {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
border: 2px solid #fff7e6;
border-radius: 50%;
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.stage:has(.link:hover) .ring {
transform: scale(1.6);
}
.cursor-pos.is-demo .ring {
animation: ring-grow 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cursor-path {
0% {
transform: translate(90px, 60px);
}
40% {
transform: translate(90px, 60px);
}
50% {
transform: translate(300px, 210px);
}
90% {
transform: translate(300px, 210px);
}
100% {
transform: translate(90px, 60px);
}
}
@keyframes ring-grow {
0%, 100% {
transform: scale(1);
}
20%, 40% {
transform: scale(1.6);
}
50%, 70% {
transform: scale(1.6);
}
80% {
transform: scale(1);
}
}
@media (pointer: coarse) {
.cursor-pos {
display: none;
}
.stage {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.cursor-pos.is-demo {
animation: none;
transform: translate(90px, 60px);
}
.cursor-pos.is-demo .ring {
animation: none;
}
}// 실제 포인터가 움직이면 링이 좌표를 따라가고, 링크 위에서는 :has() 로 링이 커진다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage'), pos = document.querySelector('.cursor-pos');
stage.addEventListener('pointermove', function (e) {
pos.classList.remove('is-demo');
var r = stage.getBoundingClientRect();
pos.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./HoverGrowRing.module.scss";
export type HoverGrowRingProps = {
links?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HoverGrowRing({
links = ["기능", "가격"],
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: HoverGrowRingProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 90, y: 60 });
const [grow, setGrow] = useState(false);
const move = (e: PointerEvent<HTMLDivElement>) => {
setDemo(false);
const r = e.currentTarget.getBoundingClientRect();
setPos({ x: e.clientX - r.left, y: e.clientY - r.top });
setGrow((e.target as HTMLElement).closest("a") !== null);
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<a className={`${styles.link} ${styles.linkA}`} href="#">{links[0]}</a>
<a className={`${styles.link} ${styles.linkB}`} href="#">{links[1]}</a>
<div
className={demo ? `${styles.cursorPos} ${styles.isDemo}` : styles.cursorPos}
style={demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px)` }}
aria-hidden="true"
>
<div className={grow ? `${styles.ring} ${styles.ringGrow}` : styles.ring} />
</div>
</div>
);
}07 · 호버 시 링 확대 — 측정 누적면적 3.167% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.ring {
border: 2px solid $color;
border-radius: 50%;
transition: transform 180ms $easing;
}
.stage:has(.link:hover) .ring { transform: scale(1.6); }
08기본 커서 숨김
어떻게 움직이나
cursor: none으로 브라우저 기본 화살표를 지우는, 나머지 여덟 개가 딛고 서는 처리입니다.
어디에 쓰나
이 속성 없이는 커스텀 점을 아무리 그려도 진짜 화살표와 겹쳐 두 개로 보입니다.

<main class="stage">
<div class="zone zone--default"><span>기본</span></div>
<div class="zone zone--custom">
<span>커스텀</span>
<div class="cursor is-demo" aria-hidden="true"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 점이 오른쪽 영역을 도는 한 바퀴
$easing: $ease-spring;
$color: $subject-blue; // 커스텀 점 색
@include stage($stage-paper);
.stage { display: flex; gap: clamp(6px, 2vw, 10px); padding: clamp(10px, 3vw, 16px); }
.zone {
position: relative;
overflow: hidden;
flex: 1;
min-height: clamp(80px, 34vw, 220px);
border-radius: $r-card;
display: grid;
place-items: center;
font-weight: 700; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
}
.zone--default { background: $subject-cream; color: $stage-ink; cursor: auto; } // 브라우저 기본 화살표
.zone--custom { background: $stage-ink; color: $subject-cream; cursor: none; } // 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; }
}
@keyframes cursor-path {
0% { transform: translate(30px, 30px); }
35% { transform: translate(110px, 90px); }
70% { transform: translate(50px, 130px); }
100% { transform: translate(30px, 30px); }
}
@media (pointer: coarse) {
.cursor { display: none; }
.zone--custom { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo { animation: none; transform: translate(30px, 30px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
display: flex;
gap: clamp(6px, 2vw, 10px);
padding: clamp(10px, 3vw, 16px);
}
.zone {
position: relative;
overflow: hidden;
flex: 1;
min-height: clamp(80px, 34vw, 220px);
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
}
.zone--default {
background: #fff7e6;
color: #17141a;
cursor: auto;
}
.zone--custom {
background: #17141a;
color: #fff7e6;
cursor: none;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: #2f6df6;
pointer-events: none;
}
.cursor.is-demo {
animation: cursor-path 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cursor-path {
0% {
transform: translate(30px, 30px);
}
35% {
transform: translate(110px, 90px);
}
70% {
transform: translate(50px, 130px);
}
100% {
transform: translate(30px, 30px);
}
}
@media (pointer: coarse) {
.cursor {
display: none;
}
.zone--custom {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo {
animation: none;
transform: translate(30px, 30px);
}
}// 오른쪽 영역 안에서만 커스텀 점이 실제 포인터를 따라간다. 왼쪽은 기본 화살표 그대로.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage'), dot = document.querySelector('.cursor'), zone = document.querySelector('.zone--custom');
stage.addEventListener('pointermove', function (e) {
var zr = zone.getBoundingClientRect();
var inside = e.clientX >= zr.left && e.clientX <= zr.right && e.clientY >= zr.top && e.clientY <= zr.bottom;
dot.style.opacity = inside ? 1 : 0;
if (inside) { dot.classList.remove('is-demo'); dot.style.transform = 'translate(' + (e.clientX - zr.left) + 'px,' + (e.clientY - zr.top) + 'px)'; }
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./HideNativeCursor.module.scss";
export type HideNativeCursorProps = {
defaultLabel?: string;
customLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HideNativeCursor({
defaultLabel = "기본",
customLabel = "커스텀",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: HideNativeCursorProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: 0, y: 0, on: false });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
setPos({ x: e.clientX - r.left, y: e.clientY - r.top, on: true });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.zoneDefault}><span>{defaultLabel}</span></div>
<div className={styles.zoneCustom} onPointerMove={move} onPointerLeave={() => setPos((p) => ({ ...p, on: false }))}>
<span>{customLabel}</span>
<div
className={demo ? `${styles.cursor} ${styles.isDemo}` : styles.cursor}
style={demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px)`, opacity: pos.on ? 1 : 0 }}
aria-hidden="true"
/>
</div>
</div>
);
}08 · 기본 커서 숨김 — 측정 누적면적 1.843% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.zone--custom {
cursor: none; // 이 한 줄이 모든 커스텀 커서 효과의 전제
}
.cursor {
position: absolute;
transform: translate(var(--x), var(--y));
}
09터치 기기 대체 처리
어떻게 움직이나
@media (pointer: coarse)로 터치 기기를 감지해 커스텀 커서 스크립트를 아예 실행하지 않고, 원래 탭 동작을 그대로 남깁니다.
어디에 쓰나
모바일에서도 열리는 페이지라면 반드시 필요합니다.

<main class="stage">
<div class="panel panel--mouse is-demo">
<div class="cursor" aria-hidden="true"></div>
<span class="label">마우스 감지</span>
</div>
<div class="panel panel--touch is-demo">
<button class="tap" type="button">탭하세요</button>
<span class="label">터치 감지</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 두 패널이 교차하는 한 바퀴
$easing: $ease-spring;
$color: $subject-blue; // 커서 점 색
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; }
.panel {
position: absolute;
inset: clamp(14px, 4vw, 24px);
display: grid;
place-items: center;
gap: clamp(8px, 2.4vw, 12px);
border-radius: $r-card;
background: rgba(255, 255, 255, .06);
opacity: 0;
}
.label {
font-weight: 700; font-size: clamp(12px, 3.4vw, 15px); line-height: 1; font-family: inherit;
color: $subject-cream;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: $color;
pointer-events: none;
}
.tap {
border: 0;
border-radius: $r-pill;
padding: clamp(10px, 3vw, 14px) clamp(22px, 6vw, 30px);
font-weight: 700; font-size: clamp(13px, 3.6vw, 16px); line-height: 1; font-family: inherit;
color: $stage-ink;
background: $subject-cream;
}
.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; }
}
// 마우스 패널의 점은 pointer:fine 실제 기기에서만 움직인다 (JS 가 담당). 여기선 시각 기준점만 둔다.
.panel--mouse .cursor { transform: translate(50%, 50%); }
// 실사용에서는 이 미디어쿼리 하나가 실제 폴백이다 — 터치 기기는 처음부터 터치 패널만 보인다
@media (pointer: coarse) {
.panel--mouse { display: none; }
.panel--touch { opacity: 1; animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.panel.is-demo { animation: none; }
.panel--mouse.is-demo { opacity: 1; }
.panel--touch.is-demo { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.panel {
position: absolute;
inset: clamp(14px, 4vw, 24px);
display: grid;
place-items: center;
gap: clamp(8px, 2.4vw, 12px);
border-radius: 16px;
background: rgba(255, 255, 255, 0.06);
opacity: 0;
}
.label {
font-weight: 700;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border-radius: 50%;
background: #2f6df6;
pointer-events: none;
}
.tap {
border: 0;
border-radius: 999px;
padding: clamp(10px, 3vw, 14px) clamp(22px, 6vw, 30px);
font-weight: 700;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1;
font-family: inherit;
color: #17141a;
background: #fff7e6;
}
.panel--mouse.is-demo {
animation: panel-cross 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.panel--touch.is-demo {
animation: panel-cross 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(2s / -2);
}
@keyframes panel-cross {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.panel--mouse .cursor {
transform: translate(50%, 50%);
}
@media (pointer: coarse) {
.panel--mouse {
display: none;
}
.panel--touch {
opacity: 1;
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.panel.is-demo {
animation: none;
}
.panel--mouse.is-demo {
opacity: 1;
}
.panel--touch.is-demo {
opacity: 0;
}
}// pointer:fine 인 실제 기기에서만 점이 포인터를 따라간다. 나머지는 @media (pointer: coarse) 가 CSS로 대체한다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage'), dot = document.querySelector('.cursor');
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
dot.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
}import { useState } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./TouchFallbackCursor.module.scss";
export type TouchFallbackCursorProps = {
mouseLabel?: string;
touchLabel?: string;
tapLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TouchFallbackCursor({
mouseLabel = "마우스 감지",
touchLabel = "터치 감지",
tapLabel = "탭하세요",
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TouchFallbackCursorProps) {
const [pos, setPos] = useState({ x: 50, y: 50 });
const move = (e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setPos({ x: e.clientX - r.left, y: e.clientY - r.top });
};
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars} onPointerMove={move}>
<div className={`${styles.panel} ${styles.panelMouse} ${styles.isDemo}`}>
<div className={styles.cursor} style={{ transform: `translate(${pos.x}px,${pos.y}px)` }} aria-hidden="true" />
<span className={styles.label}>{mouseLabel}</span>
</div>
<div className={`${styles.panel} ${styles.panelTouch} ${styles.isDemo}`}>
<button className={styles.tap} type="button">{tapLabel}</button>
<span className={styles.label}>{touchLabel}</span>
</div>
</div>
);
}09 · 터치 기기 대체 처리 — 측정 누적면적 4.146% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// pointer:fine 인 실제 기기에서만 스크립트가 돈다. 아래 미디어쿼리가 실제 폴백이다
@media (pointer: coarse) {
.cursor { display: none; } // 점을 지운다
.stage { cursor: auto; } // 기본 탭 동작을 되돌린다
}
@media (pointer: fine) {
.stage { cursor: none; } // 마우스가 있는 기기만 기본 화살표를 지운다
}
어디서 깨지는가 — 함정 하나
pointer-events 를 빼먹으면
아홉 개를 렌더하면서 실제로 걸린 함정은 pointer-events였습니다. 04의 말풍선과 02의 링에 pointer-events: none을 빼먹으면, 그 요소 자신이 elementFromPoint나 contains() 판정의 대상이 되어 카드나 버튼 위에 있어도 "밖에 있다"고 잘못 읽힙니다.
왜 통과시켜야 하나
커서를 그리는 요소는 늘 포인터 이벤트를 통과시켜야 그 아래 진짜 타깃이 이벤트를 받습니다. 포인터를 쫓는 반짝임을 아예 디자인 언어로 밀고 싶다면 번쩍이는 Y2K 웹 디자인의 크롬·홀로그램 부품이 그 방향의 끝입니다. 이 수정을 담아 압축한 파일을 열 때 필요한 암호는 x65p86j3이며, 아래 버튼을 누르면 내려받을 수 있습니다.
:has() 를 못 읽는 브라우저
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)로 걸러 터치 기기에서는 스크립트를 아예 실행하지 않습니다. 터치 화면엔 애초에 상시 커서가 없으므로 대체 신호(탭 피드백 등)를 따로 준비해야 합니다. 커서가 문장을 지나가며 남기는 흔적을 글자 단위로 보여주고 싶다면 CSS 텍스트 강조 애니메이션이 그 역할을 맡습니다.
실제 프로젝트에서도 커서를 DOM 요소로 그려야 합니까?
아닙니다. 01처럼 cursor: url() 한 줄로 끝나는 경우가 많습니다. 이 글의 데모가 DOM 점을 쓴 건 헤드리스 렌더가 OS 커서를 못 찍기 때문이고, zip의 vanilla/는 실제 포인터를 그대로 추적하는 코드입니다.
마우스 커서 이펙트를 여러 개 한 페이지에 같이 써도 됩니까?
가능하지만 08의 cursor: none을 먼저 걸고, 나머지 효과가 그 위에 그리는 순서로 쌓는 걸 권합니다. 02의 링과 06의 트레일처럼 좌표를 공유하는 효과는 같은 포인터 이벤트 하나에 묶어야 어긋나지 않습니다.









