「カーソル エフェクト css」9選 — 一行でコピペOK
カーソル エフェクト cssは、既定の矢印カーソルを消し、代わりの要素をtransformで動かして見せる、CSSと数行のJavaScriptで作る演出です。この記事では9種類を選び、cursor: noneとpointermove、transformだけで実装しました。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カスタムカーソル画像
- 02 リングが追従
- 03 磁石のように引き寄せるボタン
- 04 ホバーでテキスト表示
- 05 mix-blend-modeカーソル
- 06 カーソルの残像
- 07 ホバーでリング拡大
- 08 デフォルトカーソルを隠す
- 09 タッチ端末向け代替処理
並び順は効果の凝った具合ではなく、pointermoveで取得した座標をどこまで使い回すかという段階です。まず矢印そのものを別の形に差し替える基本形から始め(01)、同じ座標にわずかな遅延を足してリングを追従させ(02)、その遅延した座標を要素自身の移動やテキストの表示へ応用し(03・04)、円ひとつの見た目をmix-blend-modeや複数の残像へ広げ(05・06)、:has()によるホバー判定を重ね(07)、最後に土台となるcursor: noneの設定(08)とタッチ端末での安全な代替(09)という前提部分を置きました。 ポインターの脇に短い説明を浮かべる向きはCSS ツールチップ 吹き出しアニメーション9選で、カーソルではなく文字そのものが打たれていく効果はCSS タイピング アニメーション 9選にあります。
01カスタムカーソル画像
どう動くか
実際のサイトではcursor: url("cursor.svg") 12 12, auto;のように矢印そのものを画像やSVGへ差し替えますが、ヘッドレス環境ではOSのカーソル画像が映らないため、このデモでは同じ雫形をDOM要素として再現し、pointermoveで取得した座標をtransformに渡して動かしています。
どこで使うか
ブランドロゴのランディングページや、ゲームのようなクリック体験を作りたいサイトに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.stage { position: relative; overflow: hidden; cursor: none; }
.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;
}
02リングが追従
マウス 追従 アニメーション css の遅らせ方
pointermoveで得たx・y座標を点とリングの両方へ同じtransform: translateとして渡していますが、リングだけにtransition: transform 180msを掛けているため、点より一瞬遅れてついてくるように見えます。
どこで使うか
ポートフォリオのヒーローエリアや、クリックできる範囲をさりげなく示したいリンク周りに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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;
}
03磁石のように引き寄せるボタン
どう動くか
ボタン中心と実際のポインター座標の差分dx・dyから距離distを求め、90px以内なら距離に反比例するpullの値でボタン自身を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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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)';
});
04ホバーでテキスト表示
どう動くか
pointermoveのイベント先をカードが含んでいるかをcard.contains(e.target)で判定し、含んでいれば吹き出しのopacityを1にして「見る」という短い案内を表示します。
どこで使うか
位置は同じ座標をそのままtransform: translateへ渡しているだけなので、文言だけ変えれば「ドラッグ」のような別の指示にも流用できます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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;
opacity: 0;
transition: opacity 140ms $easing;
pointer-events: none;
transform: translate(-50%, -140%);
}
05mix-blend-modeカーソル
どう動くか
直径70pxの円にmix-blend-mode: differenceを指定すると、円の下にある背景色との差分が計算され、暗い面では白く・明るい面では暗く反転して見えます。
どこで使うか
ダークとライトのセクションが混在するワンページサイトで、背景を問わずカーソルを常に視認させたいときに使えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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;
}
06残像トレイル
どう動くか
@forで6つのspanそれぞれにanimation-delayをマイナスの値で少しずつずらして与え、opacityも番号が進むごとに薄くしているので、同じ経路を追いながら透明度が違う点が尾のように連なって見えます。実際の操作では先頭の点だけがpointermoveの座標に直接反応し、残りの点はCSSのアニメーションだけで遅れてついてきます。
どこで使うか
クリエイティブなポートフォリオや、インタラクティブなアート系ランディングに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.trail span {
position: absolute;
top: 0;
left: 0;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
border-radius: 50%;
background: $color;
pointer-events: none;
}
@for $i from 1 through 6 {
.trail.is-demo span:nth-child(#{$i}) {
opacity: 1 - $i * .13;
animation: cursor-path $duration $easing infinite;
animation-delay: -#{$i * 90}ms;
}
}
07ホバーでリング拡大
どう動くか
.stage:has(.link:hover) .ringという一行だけで、リンクの上にリング状のカーソルがあるかどうかをJavaScriptなしに判定し、scale(1.6)まで広げています。
どこで使うか
カード型のリンク一覧や、アイコンボタンが並ぶツールバーで、次にどこを押せるかを視覚的に伝えるのに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.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);
}
08デフォルトカーソルを隠す
どう動くか
cursor: noneを指定した領域だけがOSの矢印を消し、そのすき間をpointer-events: noneを付けたカーソル用のdivが埋めるという、他の8つすべてが前提にしている土台の設定です。
どこで使うか
カスタムカーソルを使うすべてのインタラクティブサイトで、最初に書く一行になります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.zone--default { background: $subject-cream; color: $stage-ink; cursor: auto; }
.zone--custom { background: $stage-ink; color: $subject-cream; cursor: none; }
.cursor {
position: absolute;
top: 0;
left: 0;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: $color;
pointer-events: none;
}
09タッチ端末向け代替処理
どう動くか
@media (pointer: coarse)に一致する端末では、JavaScript側のif (matchMedia('(pointer: fine)').matches)という条件分岐がそもそも実行されず、カスタムカーソルのdivごとdisplay: noneで消えて標準のタッチ操作だけが残ります。
どこで使うか
ノートPCとタブレットの両方で開かれるハイブリッド端末でも、同じCSSひとつで安全に切り替わります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
.cursor {
position: absolute;
top: 0;
left: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: $color;
pointer-events: none;
}
@media (pointer: coarse) {
.cursor { display: none; }
.stage { cursor: auto; }
}
どこで崩れるか — 落とし穴
pointer-events を外すと
9つとも、動くのはカスタムのdivであって本物のOSカーソルではないので、pointer-events: noneをつけ忘れるとカスタムカーソル自身がクリックを奪ってしまいます。1つ目の落とし穴はまさにこれで、01の雫形にpointer-events: noneを外して試すと、カーソルの下にあるチップがクリックできなくなり、要素は見えているのにリンクだけ反応しない状態になりました。
ステージの外へ抜けたとき
2つ目は03の磁石ボタンを動かしていて気づいたことです。pointermoveしかイベントを登録していないので、ポインターがステージの外へ一気に抜けると、その後のイベントが一切発生せず、ボタンは引き寄せられた位置のまま止まって見えました。ステージの中でもう一度ポインターを動かせばpullの計算がやり直され位置は戻りますが、外に出たまま操作が終わると戻る保証がありません。修正済みの9ファイルを開くには x65p86j3 という文字列が必要です。下のカードからzipを取ってください。
:has() の対応状況
3つ目は07の.stage:has(.link:hover) .ringを試していたときのことです。MDNの:has()対応表によると、Chrome・Edgeは105以降、Safariは15.4以降、Firefoxは121以降で対応していて、これより古いブラウザではセレクタごと無視されるため、リングは大きくならないだけで他の8つのようにレイアウトが崩れることはありませんでした。 ポインターが纏う輝きをデザインの言語そのものへ伸ばしたい場合は、y2k webデザインのクロムとホログラフィックのLPパーツ9選がその方向の行き着く先です。
アクセシビリティ
動きを減らす設定では
9つともprefers-reduced-motion: reduceで自動再生のanimationを止めますが、止まった後の見た目は項目ごとに違います。01の雫形と02の点・リングは経路の途中の座標で静止したまま残り、06の残像だけはopacity: 0をそろえて尾ごと消してしまいます。09の二枚のパネルはanimation: noneにした上でマウス用パネルのopacityを1、タッチ用パネルのopacityを0に固定し、クロスフェードの途中ではなく片方だけが見える状態で止まります。
入力への反応は残す
07のリングが実際のホバーで大きくなるtransition: transform 180ms自体はreduced-motionの対象にしていないので、その環境でも実際にリンクへポインターを重ねれば拡大は動きます。
@media (prefers-reduced-motion: reduce) {
.cursor.is-demo,
.dot.is-demo,
.ring.is-demo,
.btn.is-demo,
.label.is-demo,
.trail.is-demo span,
.cursor-pos.is-demo,
.panel.is-demo { animation: none; }
.trail.is-demo span { opacity: 0; }
.panel--mouse.is-demo { opacity: 1; }
.panel--touch.is-demo { opacity: 0; }
}
カーソル以外のマウス ホバー エフェクトやCSSアニメーションはこちらのカテゴリー一覧に、このサイトの成り立ちは紹介ページにまとめています。
FAQ
マウス カーソル エフェクトは08のcursor: noneを使わずに01〜07だけで使えますか?
使えません。cursor: noneを外すとブラウザ本来の矢印カーソルとカスタムカーソルのdivが同時に表示され、二重のカーソルがずれて動く見た目になります。08で紹介した.stage { cursor: none; }が、残り8つすべての前提になっています。
Reactでは何を使って動きを再現しますか?
zipのreact/フォルダの各コンポーネントは、ここまでと同じクラス名をdivやbutton要素に付けているだけで、追従の速さや拡大縮小の曲線はCSSのtransformとanimationが担います。 ポインターが文の上に残す跡を文字単位で見せたい場合は、CSS 下線 アニメーション9選がその役割を引き受けます。座標を使う01〜07はReact側でもpointermoveのイベントハンドラを持ち、受け取った座標をインラインスタイルのtransformへ渡します。
スマホでもこれらは同じように動きますか?
いいえ。9つとも@media (pointer: coarse)でタッチ端末を検知しており、01〜08のカスタムカーソル要素はdisplay: noneで消え、09では二枚のパネルのうちタッチ用だけが最初から表示されます。指でなぞって動かすカーソルという概念自体がタッチ操作にはないため、これはバグではなく意図した代替です。









