9 Custom Cursor CSS Effects — One Line, Copy-Paste
A custom cursor css effect swaps the browser's plain arrow for a shape, ring, or trailing dot built from CSS transforms and a few lines of JavaScript that
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Custom cursor image
- 02 Ring follows cursor
- 03 Magnetic button pull
- 04 Text label on hover
- 05 mix-blend-mode cursor
- 06 Cursor trail
- 07 Ring grows on hover
- 08 Hide native cursor
- 09 Touch-device fallback
The nine below move from the simplest possible substitution through to interaction-aware effects that watch what's underneath the pointer. 01 and 08 lay the groundwork — hiding the native arrow and putting a shaped element in its place without a line of custom cursor css js. 02 through 07 build motion on top of that base: a trailing ring, a magnetic button, a following label, a color-inverting blob, a fading trail, and a ring that grows over links. 09 closes the set with the one rule the other eight already depend on — what happens when there's no mouse to track at all. Every grid panel above loops a synthetic pointer path on its own inside a fixed, non-scrollable iframe, so watch it play through rather than reaching for your own mouse. For a short explanation riding next to the pointer instead, that's the CSS tooltip bubbles set, and for the effect where the text types itself rather than the pointer dressing up, the CSS typing effects hold it.
01Custom cursor image
Swapping in a custom cursor css svg
The .cursor element here stands in for an SVG loaded through cursor: url("pin.svg") 12 12, auto. That trailing 12 12 pair is the hotspot — the exact pixel the browser treats as the pointer's own tip once the custom shape takes over.
Where it fits
Get that pair wrong and clicks still land at the image's top-left corner instead of wherever the shape visually points.

<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);
}
}// 실제 포인터가 움직이면 Custom 커서가 그 좌표를 따라간다. 터치 기기는 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 · Custom cursor image — Measured area 5.176% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage { cursor: none; }
// Ships in production as an SVG referenced by
// cursor: url("pin.svg") 12 12, auto;
// — those two numbers are the hotspot: the exact
// pixel inside the image the OS treats as the tip.
.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;
}
02Ring follows cursor
How it works
A small dot jumps straight to the pointer's coordinates on every pointermove, while the ring around it only catches up because its CSS transition is set to 180ms. The dot is instant; the ring is deliberately late.
Where it fits
That gap between an instant value and a transitioned one is the entire trick; nothing here is running a requestAnimationFrame loop to fake the delay.

<main class="stage">
<p class="hint">Portfolio</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 · Ring follows cursor — Measured area 4.766% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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;
}
03Magnetic button pull
How it works
On every pointermove the script measures the straight-line distance from the pointer to the button's own center. Once that distance drops under 90px, the button nudges itself along the same vector, scaled down to about a third of the real gap.
Where it fits
Wire it to a CTA and the button starts drifting toward the cursor well before anyone actually reaches it.

<main class="stage">
<button class="btn is-demo" type="button">Get started</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 · Magnetic button pull — Measured area 4.833% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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;
var 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;
btn.style.transform =
'translate(' + (dx * pull * .35) + 'px,' + (dy * pull * .35) + 'px)';
});
04Text label on hover
How it works
The label tracks the pointer's raw coordinates at all times but only turns visible when card.contains(e.target) is true. That opacity transition is the only thing standing between a label glued to the cursor everywhere and one that reads as attached to a single card.
Where it fits
Swap the fixed word for anything shorter than a sentence — View, Drag, Open — and the transform logic underneath never has to change.

<main class="stage">
<div class="card"></div>
<span class="label is-demo" aria-hidden="true">View</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 · Text label on hover — Measured area 3.302% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.label {
position: absolute;
top: 0;
left: 0;
padding: 6px 12px;
border-radius: $r-pill;
background: $color;
color: #fff;
opacity: 0;
transition: opacity 140ms $easing;
pointer-events: none;
transform: translate(-50%, -140%);
}
05mix-blend-mode cursor
How it works
A 70px circle painted with mix-blend-mode: difference sits above two differently colored halves of the stage, and wherever it overlaps either one the color underneath gets inverted rather than covered. mix-blend-mode has shipped in every major browser engine for years now.
Where it fits
The only real decision left is picking a blend color light enough to invert cleanly against both a dark and a light half.

<main class="stage">
<div class="half half--left"></div>
<div class="half half--right"></div>
<p class="banner">Invert</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 cursor — Measured area 20.953% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.cursor {
position: absolute;
top: 0;
left: 0;
width: 70px;
height: 70px;
margin: -35px 0 0 -35px;
border-radius: 50%;
background: $color;
mix-blend-mode: difference;
pointer-events: none;
}
06Cursor trail
How it works
Six spans share one keyframe path but start it at staggered negative animation-delay values, 90ms apart, so at any single instant they're all mid-loop at slightly different points along it. That's the whole illusion of a fading tail, with nothing spawning or removing dots from the DOM.
Where it fits
Each span's opacity is written once with a Sass @for loop instead of six repeated selectors.

<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 · Cursor trail — Measured area 2.407% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.trail span {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
}
@for $i from 1 through 6 {
.trail span:nth-child(#{$i}) {
opacity: 1 - $i * .13;
animation-delay: -#{$i * 90}ms;
}
}
07Ring grows on hover
How it works
The ring's position still comes from pointermove, but its scale comes from a plain CSS selector: .stage:has(.link:hover) .ring. Growing to 1.6x on top of a link needs no extra hover listener in JavaScript at all.
Where it fits
:has() reaching up from the ring to check a sibling link is what makes that possible without wiring every link individually.

<main class="stage">
<a class="link link--a" href="#">Features</a>
<a class="link link--b" href="#">Pricing</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 · Ring grows on hover — Measured area 3.167% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
.ring {
width: 30px;
height: 30px;
margin: -15px 0 0 -15px;
border: 2px solid $color;
border-radius: 50%;
transition: transform 180ms $easing;
}
.stage:has(.link:hover) .ring {
transform: scale(1.6);
}
08Hide native cursor
How it works
Every other effect on this page depends on one line, cursor: none, which is why it gets its own side-by-side panel here against a zone that keeps the browser's default arrow for comparison.
Where it fits
Skip that line and a custom cursor element just draws a second shape floating a few pixels off from wherever the real system arrow already sits.

<main class="stage">
<div class="zone zone--default"><span>Default</span></div>
<div class="zone zone--custom">
<span>Custom</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);
}
}// 오른쪽 영역 안에서만 Custom 점이 실제 포인터를 따라간다. 왼쪽은 Default 화살표 그대로.
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 · Hide native cursor — Measured area 1.843% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.zone--default {
background: $subject-cream;
color: $stage-ink;
cursor: auto;
}
.zone--custom {
background: $stage-ink;
color: $subject-cream;
cursor: none;
}
.cursor {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
pointer-events: none;
}
09Touch-device fallback
How it works
@media (pointer: coarse) is the switch every one of the other eight demos already carries, and it's checked before any tracking script runs, rather than after. A touch screen never even attaches the pointermove listener the mouse-only panel needs.
Where it fits
The pointer media feature is supported across every browser people actually ship today, so there's no fallback-for-the-fallback left to write.

<main class="stage">
<div class="panel panel--mouse is-demo">
<div class="cursor" aria-hidden="true"></div>
<span class="label">Mouse detected</span>
</div>
<div class="panel panel--touch is-demo">
<button class="tap" type="button">Tap here</button>
<span class="label">Touch detected</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 · Touch-device fallback — Measured area 4.146% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
@media (pointer: coarse) {
.panel--mouse { display: none; }
.panel--touch {
opacity: 1;
animation: none;
}
}
@media (prefers-reduced-motion: reduce) {
.panel.is-demo { animation: none; }
}
Where it breaks — the traps
The one line doing the work
Every custom cursor element across all nine demos carries pointer-events: none, and that single line is doing more work than it looks like. Pushing the shine the pointer carries into a whole design language is where the Y2K chrome and holographic sections end up. Drop it from item 07's ring, and the ring itself becomes the thing the mouse is actually hovering over, so .stage:has(.link:hover) never fires. The link's own :hover state can't trigger, because the ring, not the link, now sits directly under the pointer. The same missing line breaks item 03 more quietly: the magnetic button still measures distance correctly, but the invisible cursor dot floating on top of it intercepts the click meant for the button underneath.
The coarse-pointer gap
Forgetting the @media (pointer: coarse) block causes a failure that's much harder to catch at a desk. Nothing looks wrong until someone opens the page on a phone and finds pointermove simply never fires, leaving the CSS's default state stuck on screen, with no way to clear it by tapping. A fourth, quieter version of the same bug shows up on item 05. Strip pointer-events: none from the 70px blend circle and it becomes the hovered element itself, so any :hover rule written for the banner text underneath stops firing the instant the pointer enters the stage — the exact failure mode item 07 has, two demos later.
Why the console stays quiet
None of these four are rendering errors the browser will warn about in its console; each one only shows up as a click, a hover, or a touch tap that quietly does nothing. All nine SCSS files, the matching HTML markup, and the production notes on hotspot coordinates and transition-based lag sit in one archive. It opens once x65p86j3 is typed exactly as it reads on this line, with no extra spaces added.
Accessibility
Reduced motion, unevenly
Reduced motion touches these nine unevenly, because a cursor effect's whole job is motion, and a couple of them have no honest version that keeps working while frozen. 01, 03, and 05 hold their shapes at a static rest position instead of looping — the pointer stand-in stops moving but stays visible in place. 02's ring keeps its position frozen too, and its own 180ms transition is turned off, since a lag effect built entirely on a transition timer has nothing left to demonstrate once nothing is transitioning.
When hiding beats freezing
04's label goes further and drops to opacity 0 permanently, because a label that used to fade in only within a bounded window has no honest static middle ground. Better hidden than stuck half-visible. 06 keeps its lead dot static but sets every trail span's opacity straight to 0, removing the ghost tail entirely rather than freezing six overlapping copies in place. 07 stops the ring's grow-on-hover animation but leaves the real :hover-triggered scale from .stage:has(.link:hover) untouched, since that transform is a direct response to input rather than a decorative loop.
The two outliers
09 turns off its panel-crossfade loop and settles on the touch panel staying visible, matching what a real touch device already shows under @media (pointer: coarse), regardless of motion settings. 08 is the outlier: it carries no reduced-motion override at all, because the entire point of that panel is the presence or absence of cursor: none rather than any animation. Its demo dot keeps looping no matter the setting. MDN's prefers-reduced-motion reference covers how operating systems expose this preference to the browser in the first place.
Every other pattern in this collection sits behind the css hub, and what actually ships inside a zip here — versus what stays out of it — is spelled out on the about page.
FAQ
Why is a custom cursor css not working at all?
The most common cause is a missing cursor: none on the element the pointer sits over. Without it, the browser keeps drawing its own arrow directly on top of whatever shape the CSS and JavaScript are trying to show, so the effect looks like it silently failed even though every rule fired correctly. A close second is a pointermove listener attached to a small inner element — often the button or link being decorated — instead of the larger container around it. That leaves the cursor frozen the moment the pointer strays outside that smaller box. It is also worth confirming pointer-events: none sits on the fake cursor itself, since without it the shape can block the very click or hover state it was meant to sit on top of.
Does a custom cursor css effect need JavaScript, or is CSS alone enough?
Three of the nine here — the plain image swap, the mix-blend-mode circle, and the cursor: none baseline — are pure CSS and need nothing beyond the cursor property and a couple of selectors. The other six need a small pointermove listener because CSS alone has no way to read where the pointer actually is on the page. The JavaScript in each of those demos does nothing more than write the current x and y into a transform, which a CSS transition or keyframe then carries from there. When the trace the pointer leaves should show up on the sentence itself, letter by letter, the text highlight animations take that part. None of the nine need a framework or a build step beyond compiling the Sass down to plain CSS.
Can a custom cursor css image be an SVG instead of a PNG?
Yes, and an SVG is usually the better pick over a PNG for this exact job. It stays sharp at any cursor size, and its file weight barely grows even if the shape gets more detailed later. Reference it the same way as a bitmap: cursor: url("shape.svg") 12 12, auto. The two trailing numbers still mark the hotspot pixel inside that SVG's own coordinate space, not its rendered size on screen. Keep a plain fallback keyword such as auto or pointer listed right after the URL. A browser that fails to load the file for any reason falls straight through to whatever comes next in that same cursor value.









