マウス カーソル エフェクト 9選 — コピペOK
マウス カーソル エフェクトとは、カーソルの移動やクリック・ドラッグ・スクロールに合わせて画面が反応する小さなインタラクションのことです。カーソルを動かしただけなのに、ボタンが寄ってきたりカードが立体的に傾いたりするものもここに含まれます。
01 cursor-shape-morph-link
02 cursor-magnetic-pull-button
03 cursor-spotlight-card-reveal
04 cursor-tilt-3d-card
05 cursor-drag-scroll-gallery
06 cursor-context-menu-custom
07 cursor-text-selection-highlight
08 cursor-scroll-progress-ring
09 cursor-hover-caption-grid
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 カーソル形状モーフ
- 02 マグネティックプルボタン
- 03 スポットライトカードリビール
- 04 3Dチルトカード
- 05 ドラッグスクロールギャラリー
- 06 カスタム右クリックメニュー
- 07 テキスト選択のマーカー色
- 08 スクロール進捗カーソルリング
- 09 ホバーキャプション画像グリッド
以下の9つは人気順ではなく、マウス ホバー エフェクトを含め、ポインタが画面の上で実際に出会う順番で並んでいます。じっとしていて、リンクの上で形が変わり(01)、ボタンに近づくと引き寄せられ(02)、カードの上を通ると光が追いかけて傾き(03・04)、画面を手で引っぱり(05)、右クリックでメニューが出て(06)、文章をなぞると色が付き(07)、ページを読み進めるとリングが満ちていき(08)、最後にグリッドを一巡するとキャプションが現れます(09)。マウスが無い画面へ同じ動きを指で移した版はスマホ ジェスチャー操作にあり、ポインタの軌跡をそのまま絵として残す側はcanvas 手書きサインと描画UIへ続きます。
01カーソル形状モーフ
カーソル エフェクト cssで何が見えるか
リンクにカーソルを乗せると小さな点が矢印入りの青い円に広がり、離れると点に戻ります。

<main class="stage">
<div class="cm-area is-demo">
<a class="cm-link" href="#" data-cursor="link" tabindex="-1">詳しく見る</a>
<div class="cm-cursor" aria-hidden="true">
<span class="cm-cursor__shape">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 커서 모양 전환 시간
$easing: $ease-spring;
$color: $stage-paper;
@include stage($stage-ink);
.cm-area {
position: relative;
width: min(220px, 74vw);
height: 110px;
}
.cm-link {
position: absolute;
left: 44px;
top: 30px;
width: 128px;
height: 40px;
display: grid;
place-items: center;
border-radius: $r-control;
border: 1px dashed rgba(255, 255, 255, .4);
color: $color;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
// pointer(마우스)가 있는 기기만 커스텀 커서를 그린다 — 손가락엔 커서가 없다
.cm-cursor {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
pointer-events: none;
transform: translate(6px, 90px);
transition: transform $duration $easing;
}
.cm-cursor__shape {
position: absolute;
left: -11px;
top: -11px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: $r-pill;
background: $color;
transition: transform $duration $easing, background $duration $easing;
}
.cm-cursor__shape svg {
width: 13px;
height: 13px;
color: $stage-ink;
opacity: 0;
transform: scale(.4);
transition: opacity $duration $easing, transform $duration $easing;
}
.cm-cursor.is-link .cm-cursor__shape {
transform: scale(1.9);
background: $subject-blue;
}
.cm-cursor.is-link .cm-cursor__shape svg {
opacity: 1;
transform: scale(1);
color: $stage-paper;
}
@media (hover: none), (pointer: coarse) {
.cm-cursor { display: none; } // 터치 기기: 커스텀 커서 자체가 의미 없다 (함정 참고)
}
// 데모 강제 루프 — 실제 pointermove 없이도 같은 두 상태를 오간다
.cm-area.is-demo .cm-cursor { animation: cm-move $dur-loop $easing infinite; }
.cm-area.is-demo .cm-cursor__shape { animation: cm-shape $dur-loop $easing infinite; }
.cm-area.is-demo .cm-cursor__shape svg { animation: cm-icon $dur-loop $easing infinite; }
@keyframes cm-move {
0%, 12% { transform: translate(6px, 90px); }
40%, 62% { transform: translate(108px, 50px); }
90%, 100% { transform: translate(6px, 90px); }
}
@keyframes cm-shape {
0%, 12% { transform: scale(1); background: $color; }
40%, 62% { transform: scale(1.9); background: $subject-blue; }
90%, 100% { transform: scale(1); background: $color; }
}
@keyframes cm-icon {
0%, 12% { opacity: 0; transform: scale(.4); color: $stage-ink; }
40%, 62% { opacity: 1; transform: scale(1); color: $stage-paper; }
90%, 100% { opacity: 0; transform: scale(.4); color: $stage-ink; }
}
@media (prefers-reduced-motion: reduce) {
.cm-cursor, .cm-cursor__shape, .cm-cursor__shape svg { animation: none !important; transition: none !important; }
}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);
}
.cm-area {
position: relative;
width: min(220px, 74vw);
height: 110px;
}
.cm-link {
position: absolute;
left: 44px;
top: 30px;
width: 128px;
height: 40px;
display: grid;
place-items: center;
border-radius: 12px;
border: 1px dashed rgba(255, 255, 255, 0.4);
color: #ffffff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.cm-cursor {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
pointer-events: none;
transform: translate(6px, 90px);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cm-cursor__shape {
position: absolute;
left: -11px;
top: -11px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
border-radius: 999px;
background: #ffffff;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cm-cursor__shape svg {
width: 13px;
height: 13px;
color: #17141a;
opacity: 0;
transform: scale(0.4);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cm-cursor.is-link .cm-cursor__shape {
transform: scale(1.9);
background: #2f6df6;
}
.cm-cursor.is-link .cm-cursor__shape svg {
opacity: 1;
transform: scale(1);
color: #ffffff;
}
@media (hover: none), (pointer: coarse) {
.cm-cursor {
display: none;
}
}
.cm-area.is-demo .cm-cursor {
animation: cm-move 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cm-area.is-demo .cm-cursor__shape {
animation: cm-shape 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.cm-area.is-demo .cm-cursor__shape svg {
animation: cm-icon 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cm-move {
0%, 12% {
transform: translate(6px, 90px);
}
40%, 62% {
transform: translate(108px, 50px);
}
90%, 100% {
transform: translate(6px, 90px);
}
}
@keyframes cm-shape {
0%, 12% {
transform: scale(1);
background: #ffffff;
}
40%, 62% {
transform: scale(1.9);
background: #2f6df6;
}
90%, 100% {
transform: scale(1);
background: #ffffff;
}
}
@keyframes cm-icon {
0%, 12% {
opacity: 0;
transform: scale(0.4);
color: #17141a;
}
40%, 62% {
opacity: 1;
transform: scale(1);
color: #ffffff;
}
90%, 100% {
opacity: 0;
transform: scale(0.4);
color: #17141a;
}
}
@media (prefers-reduced-motion: reduce) {
.cm-cursor, .cm-cursor__shape, .cm-cursor__shape svg {
animation: none !important;
transition: none !important;
}
}(function () {
var area = document.querySelector('.cm-area');
var cursor = area.querySelector('.cm-cursor');
var link = area.querySelector('.cm-link');
// 실제 사용자: pointermove 로 커서 점이 실제 포인터를 따라가고, data-cursor 요소 위에서 링+화살표로 커진다
area.addEventListener('pointermove', function (e) {
if (area.classList.contains('is-demo')) return;
var r = area.getBoundingClientRect();
cursor.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
link.addEventListener('pointerenter', function () { cursor.classList.add('is-link'); });
link.addEventListener('pointerleave', function () { cursor.classList.remove('is-link'); });
// 터치 기기엔 pointermove 오버가 없다 — 첫 터치를 감지하면 데모 루프로 되돌려 커서가 화면에 박혀 남지 않게 한다
area.addEventListener('pointerdown', function (e) {
if (e.pointerType === 'touch') { area.classList.add('is-demo'); cursor.style.transform = ''; }
});
})();import { useRef, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorShapeMorphLink.module.scss";
export type CursorShapeMorphLinkProps = {
href?: string;
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** pointermove 로 커서 점이 따라가고, 링크 위에서 화살표 든 원으로 커진다. 터치 기기는 CSS 로 커서를 숨긴다 */
export default function CursorShapeMorphLink({
href = "#",
label = "자세히 보기",
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ffffff",
}: CursorShapeMorphLinkProps) {
const areaRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (e: PointerEvent<HTMLDivElement>) => {
const area = areaRef.current, cursor = cursorRef.current;
if (!area || !cursor) return;
const r = area.getBoundingClientRect();
cursor.style.transform = `translate(${e.clientX - r.left}px,${e.clientY - r.top}px)`;
};
return (
<div ref={areaRef} className={styles.area} style={vars} onPointerMove={move}>
<a
className={styles.link}
href={href}
onPointerEnter={() => cursorRef.current?.classList.add(styles.isLink)}
onPointerLeave={() => cursorRef.current?.classList.remove(styles.isLink)}
>
{label}
</a>
<div ref={cursorRef} className={styles.cursor} aria-hidden="true">
<span className={styles.shape}>
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2" /><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z" /></g></svg>
</span>
</div>
</div>
);
}01 · カーソル形状モーフ — 計測 累積面積 3.019% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
pointermove で点の位置を動かし、pointerenter・pointerleave で形を切り替えます。
area.addEventListener('pointermove', function (e) {
var r = area.getBoundingClientRect();
cursor.style.transform =
'translate(' + (e.clientX - r.left) + 'px,' +
(e.clientY - r.top) + 'px)';
});
link.addEventListener('pointerenter', function () {
cursor.classList.add('is-link');
});
link.addEventListener('pointerleave', function () {
cursor.classList.remove('is-link');
});
02マグネティックプルボタン
画面で起きること
ボタン周辺にカーソルを近づけるとその方向へわずかに引き寄せられ、離れると元の位置に戻ります。

<main class="stage">
<div class="mg-zone is-demo">
<button class="mg-btn" type="button" tabindex="-1">
<span>はじめる</span>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2"/><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z"/></g></svg>
</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 자석에 끌려오는 시간
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-yellow);
.mg-zone {
width: min(230px, 76vw);
height: 140px;
display: grid;
place-items: center;
}
.mg-btn {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-6;
border: 0;
border-radius: $r-pill;
background: linear-gradient(135deg, $subject-pink, $subject-lilac);
color: $subject-ink; // 파스텔 면 위 흰 글자는 2.09:1 — 잉크로 8.73:1
font-size: 15px;
font-weight: 800;
box-shadow: $shadow-press;
cursor: pointer;
transition: transform $duration $easing;
will-change: transform;
}
.mg-btn svg { width: 18px; height: 18px; }
@media (hover: none), (pointer: coarse) {
.mg-btn { transition: none; } // 터치엔 hover 좌표가 없다 — 그냥 보통 버튼으로 동작
}
// 데모 강제 루프 — 가상의 포인터가 버튼 오른쪽 위에서 다가왔다 멀어지는 것을 흉내
.mg-zone.is-demo .mg-btn { animation: mg-pull $dur-loop $easing infinite; }
@keyframes mg-pull {
0%, 10% { transform: translate(0, 0) scale(1); }
32%, 55% { transform: translate(20px, -15px) scale(1.04); }
82%, 100% { transform: translate(0, 0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.mg-btn { animation: none !important; transition: none !important; }
}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);
}
.mg-zone {
width: min(230px, 76vw);
height: 140px;
display: grid;
place-items: center;
}
.mg-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: 0;
border-radius: 999px;
background: linear-gradient(135deg, #ff9ebb, #b9a0ff);
color: #17141a;
font-size: 15px;
font-weight: 800;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.mg-btn svg {
width: 18px;
height: 18px;
}
@media (hover: none), (pointer: coarse) {
.mg-btn {
transition: none;
}
}
.mg-zone.is-demo .mg-btn {
animation: mg-pull 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes mg-pull {
0%, 10% {
transform: translate(0, 0) scale(1);
}
32%, 55% {
transform: translate(20px, -15px) scale(1.04);
}
82%, 100% {
transform: translate(0, 0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.mg-btn {
animation: none !important;
transition: none !important;
}
}(function () {
var zone = document.querySelector('.mg-zone');
var btn = zone.querySelector('.mg-btn');
var STRENGTH = 0.4; // 자석 세기 — 0에 가까울수록 덜 끌려온다
zone.addEventListener('pointermove', function (e) {
if (zone.classList.contains('is-demo')) return;
var r = zone.getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2);
var dy = e.clientY - (r.top + r.height / 2);
btn.style.transform = 'translate(' + dx * STRENGTH + 'px,' + dy * STRENGTH + 'px)';
});
zone.addEventListener('pointerleave', function () { btn.style.transform = ''; });
})();import { useRef, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorMagneticPullButton.module.scss";
export type CursorMagneticPullButtonProps = {
label?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STRENGTH = 0.4;
/** 버튼 히트 존 안에서 pointermove 좌표로 자석처럼 끌려오는 translate 를 계산한다 */
export default function CursorMagneticPullButton({
label = "시작하기",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff9ebb",
}: CursorMagneticPullButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (e: PointerEvent<HTMLDivElement>) => {
const btn = btnRef.current;
if (!btn) return;
const r = e.currentTarget.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
btn.style.transform = `translate(${dx * STRENGTH}px,${dy * STRENGTH}px)`;
};
const leave = () => { if (btnRef.current) btnRef.current.style.transform = ""; };
return (
<div className={styles.zone} style={vars} onPointerMove={move} onPointerLeave={leave}>
<button ref={btnRef} className={styles.btn} type="button">
<span>{label}</span>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m216 128l-72 72V56Z" opacity=".2" /><path d="m221.66 122.34l-72-72A8 8 0 0 0 136 56v64H40a8 8 0 0 0 0 16h96v64a8 8 0 0 0 13.66 5.66l72-72a8 8 0 0 0 0-11.32M152 180.69V75.31L204.69 128Z" /></g></svg>
</button>
</div>
);
}02 · マグネティックプルボタン — 計測 累積面積 6.596% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
ヒット領域の中心からの距離に係数(0.4)を掛けて translate の値を作ります。
zone.addEventListener('pointermove', function (e) {
var r = zone.getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2);
var dy = e.clientY - (r.top + r.height / 2);
btn.style.transform =
'translate(' + dx * 0.4 + 'px,' + dy * 0.4 + 'px)';
});
zone.addEventListener('pointerleave', function () {
btn.style.transform = '';
});
03スポットライトカードリビール
どんな動きか
カード上でカーソルを動かすと丸い光がついてきて、光が通った場所の薄い文字が明るく浮かび上がります。

<main class="stage">
<div class="sp-card is-demo">
<span class="sp-card__spot" aria-hidden="true"></span>
<p class="sp-card__text">マウスを乗せると<br>隠れていた文字が<br>光を受けて浮かび上がる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-paper;
@include stage($stage-ink);
.sp-card {
position: relative;
width: min(240px, 78vw);
height: 150px;
overflow: hidden;
border-radius: $r-card;
background: #201c26;
border: 1px solid rgba(255, 255, 255, .08);
display: grid;
place-items: center;
}
.sp-card__spot {
position: absolute;
left: -90px;
top: -90px;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, .9) 0%, rgba(47, 109, 246, .5) 45%, transparent 72%);
mix-blend-mode: soft-light;
transform: translate(120px, 75px);
transition: transform $duration $easing;
pointer-events: none;
}
.sp-card__text {
position: relative;
z-index: 1;
margin: 0;
padding: $sp-4;
text-align: center;
font-size: 14px;
line-height: 1.5;
font-weight: 700;
color: rgba(255, 255, 255, .72); // .22 + screen 은 스포트라이트 밖에서 1.95:1 이었다 — AA 4.5
mix-blend-mode: screen;
}
@media (hover: none), (pointer: coarse) {
.sp-card__spot { opacity: 0; } // 터치엔 계속 따라다닐 커서가 없다 — 꺼서 항상 어두운 카드로 둔다
.sp-card__text { color: rgba(255, 255, 255, .85); } // 대신 글자 자체를 밝혀 내용은 읽히게 한다
}
// 데모 강제 루프 — 스포트라이트가 카드 위를 대각선으로 오간다
.sp-card.is-demo .sp-card__spot { animation: sp-sweep $dur-loop $easing infinite; transition: none; }
@keyframes sp-sweep {
0%, 6% { transform: translate(10px, 10px); }
50% { transform: translate(190px, 130px); }
94%, 100% { transform: translate(10px, 10px); }
}
@media (prefers-reduced-motion: reduce) {
.sp-card__spot { animation: none !important; transition: none !important; transform: translate(120px, 75px); }
.sp-card__text { color: rgba(255, 255, 255, .72); }
}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);
}
.sp-card {
position: relative;
width: min(240px, 78vw);
height: 150px;
overflow: hidden;
border-radius: 16px;
background: #201c26;
border: 1px solid rgba(255, 255, 255, 0.08);
display: grid;
place-items: center;
}
.sp-card__spot {
position: absolute;
left: -90px;
top: -90px;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, rgba(47, 109, 246, 0.5) 45%, transparent 72%);
mix-blend-mode: soft-light;
transform: translate(120px, 75px);
transition: transform 220ms ease-out;
pointer-events: none;
}
.sp-card__text {
position: relative;
z-index: 1;
margin: 0;
padding: 16px;
text-align: center;
font-size: 14px;
line-height: 1.5;
font-weight: 700;
color: rgba(255, 255, 255, 0.72);
mix-blend-mode: screen;
}
@media (hover: none), (pointer: coarse) {
.sp-card__spot {
opacity: 0;
}
.sp-card__text {
color: rgba(255, 255, 255, 0.85);
}
}
.sp-card.is-demo .sp-card__spot {
animation: sp-sweep 2s ease-out infinite;
transition: none;
}
@keyframes sp-sweep {
0%, 6% {
transform: translate(10px, 10px);
}
50% {
transform: translate(190px, 130px);
}
94%, 100% {
transform: translate(10px, 10px);
}
}
@media (prefers-reduced-motion: reduce) {
.sp-card__spot {
animation: none !important;
transition: none !important;
transform: translate(120px, 75px);
}
.sp-card__text {
color: rgba(255, 255, 255, 0.72);
}
}(function () {
var card = document.querySelector('.sp-card');
var spot = card.querySelector('.sp-card__spot');
// 실제 사용자: pointermove 좌표를 그대로 스포트라이트 위치에 반영
card.addEventListener('pointermove', function (e) {
if (card.classList.contains('is-demo')) return;
var r = card.getBoundingClientRect();
spot.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
card.addEventListener('pointerenter', function () { card.classList.remove('is-demo'); card.classList.add('is-live'); });
card.addEventListener('pointerleave', function () { card.classList.remove('is-live'); spot.style.transform = ''; });
})();import { useRef, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorSpotlightCardReveal.module.scss";
export type CursorSpotlightCardRevealProps = {
text?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** pointermove 좌표를 그대로 spotlight 엘리먼트의 translate 에 반영한다. mix-blend-mode 로 글자가 밝아진다 */
export default function CursorSpotlightCardReveal({
text = "마우스를 올리면 숨어 있던 글자가 빛을 받아 드러난다",
duration = 220,
easing = "ease-out",
color = "#ffffff",
}: CursorSpotlightCardRevealProps) {
const spotRef = useRef<HTMLSpanElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (e: PointerEvent<HTMLDivElement>) => {
const spot = spotRef.current;
if (!spot) return;
const r = e.currentTarget.getBoundingClientRect();
spot.style.transform = `translate(${e.clientX - r.left}px,${e.clientY - r.top}px)`;
};
const leave = () => { if (spotRef.current) spotRef.current.style.transform = ""; };
return (
<div className={styles.card} style={vars} onPointerMove={move} onPointerLeave={leave}>
<span ref={spotRef} className={styles.spot} aria-hidden="true" />
<p className={styles.text}>{text}</p>
</div>
);
}03 · スポットライトカードリビール — 計測 累積面積 20.166% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
mix-blend-mode: soft-light で光を背景に混ぜ、screen で文字を明るくします。
card.addEventListener('pointermove', function (e) {
var r = card.getBoundingClientRect();
spot.style.transform =
'translate(' + (e.clientX - r.left) + 'px,' +
(e.clientY - r.top) + 'px)';
});
card.addEventListener('pointerleave', function () {
spot.style.transform = '';
});
043Dチルトカード
何が見えるか
カード上のカーソル位置に応じてカードが立体的に傾き、斜めの光沢も一緒に流れます。

<main class="stage">
<div class="tilt-wrap">
<div class="tilt-card is-demo">
<span class="tilt-card__badge">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</span>
<p class="tilt-card__title">Proプラン</p>
<p class="tilt-card__desc">チーム全員で使う料金プラン</p>
<span class="tilt-card__sheen" aria-hidden="true"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant; // 기울기 반응 시간
$easing: $ease-out;
$color: $subject-blue;
@include stage(#eef3fb);
.tilt-wrap {
width: min(200px, 62vw);
height: 130px;
display: grid;
place-items: center;
}
.tilt-card {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
padding: $sp-6 $sp-5;
border-radius: $r-card;
background: $stage-paper;
border: 1px solid rgba(47, 109, 246, .14);
box-shadow: 0 10px 26px rgba(47, 109, 246, .16);
transform: perspective(600px) rotateX(0) rotateY(0);
transition: transform $duration $easing;
will-change: transform;
}
.tilt-card__badge {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .12);
color: $color;
margin-bottom: $sp-3;
}
.tilt-card__badge svg { width: 18px; height: 18px; }
.tilt-card__title { margin: 0 0 $sp-1; font-size: 16px; font-weight: 800; color: #16213a; }
.tilt-card__desc { margin: 0; font-size: 12.5px; color: #5b6b8c; }
.tilt-card__sheen {
position: absolute;
inset: -40% -60%;
background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, .55) 50%, transparent 65%);
background-size: 220% 220%;
background-position: 0% 50%;
pointer-events: none;
}
@media (hover: none), (pointer: coarse) {
.tilt-card { transition: none; } // 터치엔 기울기를 줄 좌표가 없다 — 평평한 카드로 둔다
}
// 데모 강제 루프 — 포인터가 카드 네 모서리를 도는 것을 흉내
.tilt-card.is-demo { animation: tilt-loop $dur-loop $easing infinite; transition: none; }
.tilt-card.is-demo .tilt-card__sheen { animation: tilt-sheen $dur-loop $easing infinite; }
@keyframes tilt-loop {
0%, 8% { transform: perspective(600px) rotateX(0) rotateY(0); }
30% { transform: perspective(600px) rotateX(6deg) rotateY(-6deg); }
55% { transform: perspective(600px) rotateX(-6deg) rotateY(6deg); }
80%, 100% { transform: perspective(600px) rotateX(0) rotateY(0); }
}
@keyframes tilt-sheen {
0%, 8% { background-position: 10% 50%; }
30% { background-position: 70% 20%; }
55% { background-position: 30% 80%; }
80%, 100% { background-position: 10% 50%; }
}
@media (prefers-reduced-motion: reduce) {
.tilt-card, .tilt-card__sheen { animation: none !important; transition: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #eef3fb;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tilt-wrap {
width: min(200px, 62vw);
height: 130px;
display: grid;
place-items: center;
}
.tilt-card {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
padding: 24px 20px;
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(47, 109, 246, 0.14);
box-shadow: 0 10px 26px rgba(47, 109, 246, 0.16);
transform: perspective(600px) rotateX(0) rotateY(0);
transition: transform 140ms ease-out;
will-change: transform;
}
.tilt-card__badge {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
margin-bottom: 12px;
}
.tilt-card__badge svg {
width: 18px;
height: 18px;
}
.tilt-card__title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 800;
color: #16213a;
}
.tilt-card__desc {
margin: 0;
font-size: 12.5px;
color: #5b6b8c;
}
.tilt-card__sheen {
position: absolute;
inset: -40% -60%;
background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, 0.55) 50%, transparent 65%);
background-size: 220% 220%;
background-position: 0% 50%;
pointer-events: none;
}
@media (hover: none), (pointer: coarse) {
.tilt-card {
transition: none;
}
}
.tilt-card.is-demo {
animation: tilt-loop 2s ease-out infinite;
transition: none;
}
.tilt-card.is-demo .tilt-card__sheen {
animation: tilt-sheen 2s ease-out infinite;
}
@keyframes tilt-loop {
0%, 8% {
transform: perspective(600px) rotateX(0) rotateY(0);
}
30% {
transform: perspective(600px) rotateX(6deg) rotateY(-6deg);
}
55% {
transform: perspective(600px) rotateX(-6deg) rotateY(6deg);
}
80%, 100% {
transform: perspective(600px) rotateX(0) rotateY(0);
}
}
@keyframes tilt-sheen {
0%, 8% {
background-position: 10% 50%;
}
30% {
background-position: 70% 20%;
}
55% {
background-position: 30% 80%;
}
80%, 100% {
background-position: 10% 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.tilt-card, .tilt-card__sheen {
animation: none !important;
transition: none !important;
transform: none;
}
}(function () {
var wrap = document.querySelector('.tilt-wrap');
var card = wrap.querySelector('.tilt-card');
var MAX = 6; // 최대 기울기(deg)
wrap.addEventListener('pointermove', function (e) {
if (card.classList.contains('is-demo')) return;
var r = wrap.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width; // 0~1
var py = (e.clientY - r.top) / r.height;
var rx = (0.5 - py) * MAX * 2;
var ry = (px - 0.5) * MAX * 2;
card.style.transform = 'perspective(600px) rotateX(' + rx + 'deg) rotateY(' + ry + 'deg)';
});
wrap.addEventListener('pointerleave', function () { card.style.transform = ''; });
})();import { useRef, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorTilt3dCard.module.scss";
export type CursorTilt3dCardProps = {
title?: string;
desc?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const MAX_DEG = 6;
/** offsetX/Y 비율로 rotateX/rotateY 를 계산해 perspective 카드에 적용한다 */
export default function CursorTilt3dCard({
title = "Pro 플랜",
desc = "팀 전체가 쓰는 요금제",
duration = 140,
easing = "ease-out",
color = "#2f6df6",
}: CursorTilt3dCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (e: PointerEvent<HTMLDivElement>) => {
const card = cardRef.current;
if (!card) return;
const r = e.currentTarget.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
const rx = (0.5 - py) * MAX_DEG * 2;
const ry = (px - 0.5) * MAX_DEG * 2;
card.style.transform = `perspective(600px) rotateX(${rx}deg) rotateY(${ry}deg)`;
};
const leave = () => { if (cardRef.current) cardRef.current.style.transform = ""; };
return (
<div className={styles.wrap} style={vars} onPointerMove={move} onPointerLeave={leave}>
<div ref={cardRef} className={styles.card}>
<span className={styles.badge}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2" /><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5" /></g></svg>
</span>
<p className={styles.title}>{title}</p>
<p className={styles.desc}>{desc}</p>
<span className={styles.sheen} aria-hidden="true" />
</div>
</div>
);
}04 · 3Dチルトカード — 計測 累積面積 3.769% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
カーソル座標を0〜1の比率に変換し、rotateX・rotateY の角度を計算します。
wrap.addEventListener('pointermove', function (e) {
var r = wrap.getBoundingClientRect();
var px = (e.clientX - r.left) / r.width;
var py = (e.clientY - r.top) / r.height;
var rx = (0.5 - py) * 6 * 2;
var ry = (px - 0.5) * 6 * 2;
card.style.transform =
'perspective(600px) rotateX(' + rx + 'deg) rotateY(' + ry + 'deg)';
});
05ドラッグスクロールギャラリー
画面で起きること
タイルを手でつかんで引くようにドラッグすると横にスクロールし、離した位置で止まります。

<main class="stage">
<div class="dg-wrap">
<div class="dg-viewport is-demo">
<div class="dg-track">
<span class="dg-tile" style="background:#ff9ebb"></span>
<span class="dg-tile" style="background:#d2c7a4"></span>
<span class="dg-tile" style="background:#c3d6e2"></span>
<span class="dg-tile" style="background:#90c8b5"></span>
<span class="dg-tile" style="background:#b9a0ff"></span>
</div>
</div>
<span class="dg-hint" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2"/><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z"/></g></svg>
ドラッグ
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-ink;
@include stage($stage-yellow);
.dg-wrap {
width: min(240px, 78vw);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.dg-viewport {
width: 100%;
height: 96px;
overflow-x: auto;
overflow-y: hidden;
border-radius: $r-card;
cursor: grab;
touch-action: pan-x; // 세로 스크롤은 페이지에 그대로 남긴다
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&.is-grabbing { cursor: grabbing; }
}
.dg-track {
display: flex;
gap: $sp-3;
width: max-content;
padding: $sp-1;
}
.dg-tile {
flex: 0 0 auto;
width: 78px;
height: 78px;
border-radius: $r-card;
box-shadow: 0 4px 10px rgba(23, 20, 26, .18);
}
.dg-hint {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $color;
font-size: 11px;
font-weight: 700;
}
.dg-hint svg { width: 14px; height: 14px; }
// 데모 강제 루프 — 실제 scrollLeft 대신 transform 으로 같은 드래그 동작을 흉내 (WAAPI 는 scrollLeft 를 못 잰다)
.dg-viewport.is-demo { overflow: hidden; }
.dg-viewport.is-demo .dg-track { animation: dg-drag $dur-loop $easing infinite; }
@keyframes dg-drag {
0%, 8% { transform: translateX(0); }
50% { transform: translateX(-172px); }
92%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.dg-track { animation: none !important; }
}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);
}
.dg-wrap {
width: min(240px, 78vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.dg-viewport {
width: 100%;
height: 96px;
overflow-x: auto;
overflow-y: hidden;
border-radius: 16px;
cursor: grab;
touch-action: pan-x;
scrollbar-width: none;
}
.dg-viewport::-webkit-scrollbar {
display: none;
}
.dg-viewport.is-grabbing {
cursor: grabbing;
}
.dg-track {
display: flex;
gap: 12px;
width: max-content;
padding: 4px;
}
.dg-tile {
flex: 0 0 auto;
width: 78px;
height: 78px;
border-radius: 16px;
box-shadow: 0 4px 10px rgba(23, 20, 26, 0.18);
}
.dg-hint {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.dg-hint svg {
width: 14px;
height: 14px;
}
.dg-viewport.is-demo {
overflow: hidden;
}
.dg-viewport.is-demo .dg-track {
animation: dg-drag 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dg-drag {
0%, 8% {
transform: translateX(0);
}
50% {
transform: translateX(-172px);
}
92%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.dg-track {
animation: none !important;
}
}(function () {
var vp = document.querySelector('.dg-viewport');
var dragging = false, startX = 0, startScroll = 0;
// 실제 사용자: pointerdown 후 움직인 거리만큼 scrollLeft 를 직접 옮긴다 (transform 이 아니라 진짜 스크롤)
vp.addEventListener('pointerdown', function (e) {
if (vp.classList.contains('is-demo')) return;
dragging = true; startX = e.clientX; startScroll = vp.scrollLeft;
vp.classList.add('is-grabbing'); vp.setPointerCapture(e.pointerId);
});
vp.addEventListener('pointermove', function (e) {
if (!dragging) return;
vp.scrollLeft = startScroll - (e.clientX - startX);
});
['pointerup', 'pointercancel'].forEach(function (ev) {
vp.addEventListener(ev, function () { dragging = false; vp.classList.remove('is-grabbing'); });
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorDragScrollGallery.module.scss";
export type CursorDragScrollGalleryProps = {
colors?: string[];
duration?: number;
easing?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string };
/** pointerdown 시작점 대비 이동 거리로 실제 scrollLeft 를 옮긴다 (transform 이 아니라 진짜 스크롤) */
export default function CursorDragScrollGallery({
colors = ["#ff9ebb", "#ffd23f", "#6ec8ff", "#4cd4a6", "#b9a0ff"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
}: CursorDragScrollGalleryProps) {
const vpRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState(false);
const start = useRef<{ x: number; scroll: number }>({ x: 0, scroll: 0 });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing };
const down = (e: PointerEvent<HTMLDivElement>) => {
const vp = vpRef.current;
if (!vp) return;
start.current = { x: e.clientX, scroll: vp.scrollLeft };
setDragging(true);
vp.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent<HTMLDivElement>) => {
if (!dragging || !vpRef.current) return;
const s = start.current ?? { x: 0, scroll: 0 };
vpRef.current.scrollLeft = s.scroll - (e.clientX - s.x);
};
return (
<div className={styles.wrap} style={vars}>
<div
ref={vpRef}
className={`${styles.viewport} ${dragging ? styles.grabbing : ""}`}
onPointerDown={down}
onPointerMove={move}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
>
<div className={styles.track}>
{colors.map((c, i) => (
<span key={i} className={styles.tile} style={{ background: c }} />
))}
</div>
</div>
<span className={styles.hint}>
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 80v96H48V80Z" opacity=".2" /><path d="m213.66 181.66l-32 32a8 8 0 0 1-11.32-11.32L188.69 184H48a8 8 0 0 1 0-16h140.69l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32m-139.32-64a8 8 0 0 0 11.32-11.32L67.31 88H208a8 8 0 0 0 0-16H67.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32Z" /></g></svg>
드래그
</span>
</div>
);
}05 · ドラッグスクロールギャラリー — 計測 累積面積 13.938% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
transform ではなく本物の scrollLeft を動かすので、タッチでもネイティブスクロールがそのまま続きます。
vp.addEventListener('pointerdown', function (e) {
dragging = true;
startX = e.clientX;
startScroll = vp.scrollLeft;
vp.setPointerCapture(e.pointerId);
});
vp.addEventListener('pointermove', function (e) {
if (!dragging) return;
vp.scrollLeft = startScroll - (e.clientX - startX);
});
06カスタム右クリックメニュー
どんな動きか
エリアを右クリックするとブラウザ標準メニューの代わりに、コピー・編集・削除アイコンメニューがクリック位置に弾むように現れます。

<main class="stage">
<div class="cx-pad is-demo">
<p class="cx-hint">ここを右クリック</p>
<div class="cx-menu" role="menu">
<button class="cx-menu__item" type="button" role="menuitem">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
コピー
</button>
<button class="cx-menu__item" type="button" role="menuitem">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
編集
</button>
<button class="cx-menu__item cx-menu__item--danger" type="button" role="menuitem">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
削除
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-paper;
@include stage($stage-ink);
.cx-pad {
position: relative;
width: min(240px, 78vw);
height: 150px;
border-radius: $r-card;
border: 1px dashed rgba(255, 255, 255, .22);
display: grid;
place-items: center;
user-select: none;
}
.cx-hint { margin: 0; color: rgba(255, 255, 255, .5); font-size: 13px; font-weight: 700; }
.cx-menu {
position: absolute;
left: 68px;
top: 52px;
min-width: 128px;
display: flex;
flex-direction: column;
padding: $sp-2;
border-radius: $r-control;
background: #26212e;
box-shadow: $shadow-raised;
border: 1px solid rgba(255, 255, 255, .08);
transform: scale(.7);
transform-origin: top left; // 규칙8: 클릭 지점에서 메뉴 펼침
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.cx-pad.is-open .cx-menu { transform: scale(1); opacity: 1; pointer-events: auto; }
.cx-menu__item {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2 $sp-2;
border: 0;
border-radius: $r-sm;
background: transparent;
color: $color;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.cx-menu__item svg { width: 15px; height: 15px; flex: 0 0 auto; }
.cx-menu__item:hover, .cx-menu__item:focus-visible { background: rgba(255, 255, 255, .08); }
.cx-menu__item--danger { color: #e2cac5; }
// 데모 강제 루프 — 우클릭 → 메뉴 등장 → 사라짐
.cx-pad.is-demo .cx-menu { animation: cx-loop $dur-loop $easing infinite; }
@keyframes cx-loop {
0%, 10% { transform: scale(.7); opacity: 0; }
32% { transform: scale(1.05); opacity: 1; }
42%, 68% { transform: scale(1); opacity: 1; }
88%, 100% { transform: scale(.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cx-menu { transition: none; }
}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);
}
.cx-pad {
position: relative;
width: min(240px, 78vw);
height: 150px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.22);
display: grid;
place-items: center;
user-select: none;
}
.cx-hint {
margin: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
font-weight: 700;
}
.cx-menu {
position: absolute;
left: 68px;
top: 52px;
min-width: 128px;
display: flex;
flex-direction: column;
padding: 8px;
border-radius: 12px;
background: #26212e;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
border: 1px solid rgba(255, 255, 255, 0.08);
transform: scale(0.7);
transform-origin: top left;
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cx-pad.is-open .cx-menu {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.cx-menu__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: #ffffff;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.cx-menu__item svg {
width: 15px;
height: 15px;
flex: 0 0 auto;
}
.cx-menu__item:hover, .cx-menu__item:focus-visible {
background: rgba(255, 255, 255, 0.08);
}
.cx-menu__item--danger {
color: #e2cac5;
}
.cx-pad.is-demo .cx-menu {
animation: cx-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cx-loop {
0%, 10% {
transform: scale(0.7);
opacity: 0;
}
32% {
transform: scale(1.05);
opacity: 1;
}
42%, 68% {
transform: scale(1);
opacity: 1;
}
88%, 100% {
transform: scale(0.7);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cx-menu {
transition: none;
}
}(function () {
var pad = document.querySelector('.cx-pad');
var menu = pad.querySelector('.cx-menu');
function close() { pad.classList.remove('is-open'); menu.style.left = menu.style.top = ''; }
pad.addEventListener('contextmenu', function (e) {
e.preventDefault();
if (pad.classList.contains('is-demo')) return;
var r = pad.getBoundingClientRect();
var x = Math.min(e.clientX - r.left, r.width - 132);
var y = Math.min(e.clientY - r.top, r.height - 112);
menu.style.left = x + 'px'; menu.style.top = y + 'px';
pad.classList.add('is-open');
});
document.addEventListener('click', function (e) { if (!menu.contains(e.target)) close(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
})();import { useEffect, useRef, useState, type CSSProperties, type MouseEvent } from "react";
import styles from "./CursorContextMenuCustom.module.scss";
export type CursorContextMenuCustomProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** contextmenu 를 막고 클릭 좌표(클램프 처리)에 커스텀 메뉴를 띄운다. 바깥 클릭·Escape 로 닫는다 */
export default function CursorContextMenuCustom({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffffff",
}: CursorContextMenuCustomProps) {
const padRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ x: 68, y: 52 });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const onContext = (e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
const r = e.currentTarget.getBoundingClientRect();
setPos({
x: Math.min(e.clientX - r.left, r.width - 132),
y: Math.min(e.clientY - r.top, r.height - 112),
});
setOpen(true);
};
useEffect(() => {
const close = () => setOpen(false);
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
document.addEventListener("click", close);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("click", close); document.removeEventListener("keydown", onKey); };
}, []);
return (
<div ref={padRef} className={styles.pad} style={vars} onContextMenu={onContext}>
<p className={styles.hint}>여기서 우클릭</p>
<div
className={`${styles.menu} ${open ? styles.open : ""}`}
role="menu"
style={{ left: pos.x, top: pos.y }}
onClick={(e: MouseEvent<HTMLDivElement>) => e.stopPropagation()}
>
<button className={styles.item} type="button" role="menuitem">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2" /><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" /></g></svg>
복사
</button>
<button className={styles.item} type="button" role="menuitem">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2" /><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z" /></g></svg>
수정
</button>
<button className={`${styles.item} ${styles.danger}`} type="button" role="menuitem">
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" /><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" /></g></svg>
삭제
</button>
</div>
</div>
);
}06 · カスタム右クリックメニュー — 計測 累積面積 13.451% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
座標はエリアの外に出ないようクランプしています。
pad.addEventListener('contextmenu', function (e) {
e.preventDefault();
var r = pad.getBoundingClientRect();
var x = Math.min(e.clientX - r.left, r.width - 132);
var y = Math.min(e.clientY - r.top, r.height - 112);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
pad.classList.add('is-open');
});
07テキスト選択のマーカー色
何が見えるか
文章をドラッグ選択すると、ブラウザの青色ではなく指定したマーカー色で塗られ、選択範囲の上にコピーバッジが現れます。

<main class="stage">
<div class="ts-card is-demo">
<p class="ts-text">
文章をドラッグで選択すると
<span class="ts-phrase">
<span class="ts-phrase__bar" aria-hidden="true"></span>
<span class="ts-phrase__word">マーカー色</span>
</span>
で塗られる。
</p>
<span class="ts-badge" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg>
コピー
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-paper);
::selection { background: $subject-pink; color: $stage-ink; } // 진짜 드래그 선택도 같은 형광펜 색
.ts-card {
position: relative;
width: min(250px, 80vw);
padding: $sp-6 $sp-5;
}
.ts-text {
margin: 0;
font-size: 14.5px;
line-height: 1.9;
color: #2b2733;
text-align: center;
}
.ts-phrase {
position: relative;
display: inline-block;
font-weight: 800;
}
.ts-phrase__bar {
position: absolute;
left: -2px;
right: -2px;
top: 4px;
bottom: 2px;
background: $subject-mint;
border-radius: $r-xs;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 형광펜 왼쪽 스윕
z-index: 0;
}
.ts-phrase__word { position: relative; z-index: 1; }
.ts-badge {
position: absolute;
left: 50%;
top: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $stage-ink;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
transform: translate(-50%, 4px);
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
pointer-events: none;
white-space: nowrap;
}
.ts-badge svg { width: 12px; height: 12px; }
.ts-badge.is-shown { opacity: 1; transform: translate(-50%, -4px); }
// 데모 강제 루프 — 실제 드래그 선택 없이도 하이라이터가 지나가고 배지가 뜬다
.ts-card.is-demo .ts-phrase__bar { animation: ts-sweep $dur-loop $easing infinite; }
.ts-card.is-demo .ts-badge { animation: ts-badge $dur-loop $easing infinite; }
@keyframes ts-sweep {
0%, 15% { transform: scaleX(0); }
45%, 85% { transform: scaleX(1); }
100% { transform: scaleX(0); }
}
@keyframes ts-badge {
0%, 40% { opacity: 0; transform: translate(-50%, 4px); }
55%, 82% { opacity: 1; transform: translate(-50%, -4px); }
95%, 100% { opacity: 0; transform: translate(-50%, 4px); }
}
@media (prefers-reduced-motion: reduce) {
.ts-phrase__bar, .ts-badge { animation: none !important; transition: none !important; }
}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);
}
::selection {
background: #ff9ebb;
color: #17141a;
}
.ts-card {
position: relative;
width: min(250px, 80vw);
padding: 24px 20px;
}
.ts-text {
margin: 0;
font-size: 14.5px;
line-height: 1.9;
color: #2b2733;
text-align: center;
}
.ts-phrase {
position: relative;
display: inline-block;
font-weight: 800;
}
.ts-phrase__bar {
position: absolute;
left: -2px;
right: -2px;
top: 4px;
bottom: 2px;
background: #4cd4a6;
border-radius: 4px;
transform: scaleX(0);
transform-origin: left center;
z-index: 0;
}
.ts-phrase__word {
position: relative;
z-index: 1;
}
.ts-badge {
position: absolute;
left: 50%;
top: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #ffffff;
font-size: 11px;
font-weight: 700;
transform: translate(-50%, 4px);
opacity: 0;
transition: opacity 300ms ease-out, transform 300ms ease-out;
pointer-events: none;
white-space: nowrap;
}
.ts-badge svg {
width: 12px;
height: 12px;
}
.ts-badge.is-shown {
opacity: 1;
transform: translate(-50%, -4px);
}
.ts-card.is-demo .ts-phrase__bar {
animation: ts-sweep 2s ease-out infinite;
}
.ts-card.is-demo .ts-badge {
animation: ts-badge 2s ease-out infinite;
}
@keyframes ts-sweep {
0%, 15% {
transform: scaleX(0);
}
45%, 85% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
@keyframes ts-badge {
0%, 40% {
opacity: 0;
transform: translate(-50%, 4px);
}
55%, 82% {
opacity: 1;
transform: translate(-50%, -4px);
}
95%, 100% {
opacity: 0;
transform: translate(-50%, 4px);
}
}
@media (prefers-reduced-motion: reduce) {
.ts-phrase__bar, .ts-badge {
animation: none !important;
transition: none !important;
}
}(function () {
var card = document.querySelector('.ts-card');
var badge = card.querySelector('.ts-badge');
// 실제 사용자: 정말로 문장을 드래그해 선택하면 selectionchange 가 뜨고, 배지가 선택 영역 위에 뜬다
document.addEventListener('selectionchange', function () {
var sel = window.getSelection();
if (!card.contains(sel.anchorNode) || sel.isCollapsed) { badge.classList.remove('is-shown'); return; }
var r = sel.getRangeAt(0).getBoundingClientRect();
var cr = card.getBoundingClientRect();
badge.style.left = (r.left - cr.left + r.width / 2) + 'px';
badge.style.top = (r.top - cr.top - 28) + 'px';
badge.classList.add('is-shown');
card.classList.remove('is-demo');
});
})();import { useEffect, useRef, useState, type CSSProperties } from "react";
import styles from "./CursorTextSelectionHighlight.module.scss";
export type CursorTextSelectionHighlightProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** selectionchange 로 실제 텍스트 선택을 감지해 복사 배지를 선택 영역 위에 띄운다. 색은 ::selection 으로 지정 */
export default function CursorTextSelectionHighlight({
duration = 300,
easing = "ease-out",
color = "#ff9ebb",
}: CursorTextSelectionHighlightProps) {
const cardRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState<{ x: number; y: number; show: boolean }>({ x: 0, y: 0, show: false });
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const onChange = () => {
const card = cardRef.current;
const sel = window.getSelection();
if (!card || !sel || sel.isCollapsed || !card.contains(sel.anchorNode)) {
setBadge((b) => ({ ...b, show: false }));
return;
}
const r = sel.getRangeAt(0).getBoundingClientRect();
const cr = card.getBoundingClientRect();
setBadge({ x: r.left - cr.left + r.width / 2, y: r.top - cr.top - 28, show: true });
};
document.addEventListener("selectionchange", onChange);
return () => document.removeEventListener("selectionchange", onChange);
}, []);
return (
<div ref={cardRef} className={styles.card} style={vars}>
<p className={styles.text}>
드래그로 문장을 선택하면 <mark className={styles.mark}>형광펜 색</mark>으로 칠해진다.
</p>
<span
className={`${styles.badge} ${badge.show ? styles.shown : ""}`}
style={{ left: badge.x, top: badge.y }}
aria-hidden="true"
>
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2" /><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" /></g></svg>
복사
</span>
</div>
);
}07 · テキスト選択のマーカー色 — 計測 累積面積 2.017% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
色は ::selection 一つで決め、バッジの位置は selectionchange で取得します。
::selection {
background: #ff9ebb;
color: #17141a;
}
document.addEventListener('selectionchange', function () {
var sel = window.getSelection();
if (sel.isCollapsed) return;
var r = sel.getRangeAt(0).getBoundingClientRect();
badge.style.left = r.left + r.width / 2 + 'px';
badge.style.top = r.top - 28 + 'px';
badge.classList.add('is-shown');
});
08スクロール進捗カーソルリング
画面で起きること
内部をスクロールする間、カーソルに追従する小さなリングが進捗分だけ満ちていき、最後まで読むとチェックに変わります。

<main class="stage">
<div class="pr-box is-demo">
<div class="pr-content">
<p>この中でスクロールすると</p>
<p>カーソル横のリングが</p>
<p>進捗分だけ満ちていく</p>
<p>最後まで行くとチェックに変わる</p>
</div>
<div class="pr-ring" aria-hidden="true">
<svg viewBox="0 0 36 36">
<circle class="pr-ring__track" cx="18" cy="18" r="15"/>
<circle class="pr-ring__fill" cx="18" cy="18" r="15"/>
</svg>
<svg class="pr-ring__check" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.pr-box {
position: relative;
width: min(230px, 76vw);
height: 150px;
overflow-y: auto;
border-radius: $r-card;
background: #1c1822;
cursor: none;
}
.pr-content {
padding: $sp-4;
height: 340px;
p { margin: 0 0 $sp-6; font-size: 13px; font-weight: 700; color: rgba(255, 255, 255, .55); }
}
.pr-ring {
position: absolute;
left: 0;
top: 0;
width: 34px;
height: 34px;
margin: -17px 0 0 -17px;
pointer-events: none;
transform: translate(24px, 112px);
transition: transform $duration $easing;
}
.pr-ring svg:first-child { transform: rotate(-90deg); }
.pr-ring__track { fill: none; stroke: rgba(255, 255, 255, .18); stroke-width: 3; }
.pr-ring__fill {
fill: none;
stroke: $color;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 94.2;
stroke-dashoffset: var(--dash, 94.2);
}
.pr-ring__check {
position: absolute;
inset: 8px;
color: $subject-mint;
opacity: 0;
}
.pr-box.is-done .pr-ring__check { opacity: 1; }
.pr-box.is-done .pr-ring svg:first-child { opacity: 0; }
// 데모 강제 루프 — 실제 스크롤 없이도 링이 채워졌다가 체크로 바뀐다
.pr-box.is-demo { overflow: hidden; }
.pr-box.is-demo .pr-ring { animation: pr-move $dur-loop $easing infinite; transition: none; }
.pr-box.is-demo .pr-ring__fill { animation: pr-fill $dur-loop $easing infinite; }
.pr-box.is-demo .pr-ring__check { animation: pr-check $dur-loop $easing infinite; }
@keyframes pr-move {
0%, 8% { transform: translate(24px, 112px); }
50% { transform: translate(150px, 40px); }
92%, 100% { transform: translate(24px, 112px); }
}
@keyframes pr-fill {
0%, 8% { stroke-dashoffset: 94.2; }
50%, 62% { stroke-dashoffset: 0; }
92%, 100% { stroke-dashoffset: 94.2; }
}
@keyframes pr-check {
0%, 46% { opacity: 0; }
52%, 60% { opacity: 1; }
68%, 100% { opacity: 0; }
}
@media (hover: none), (pointer: coarse) {
.pr-box { cursor: auto; } // 터치엔 커서가 없다 — 링은 스크롤 위치에만 반응하는 진행 표시로 남는다
}
@media (prefers-reduced-motion: reduce) {
.pr-ring, .pr-ring__fill, .pr-ring__check { animation: none !important; transition: none !important; }
}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);
}
.pr-box {
position: relative;
width: min(230px, 76vw);
height: 150px;
overflow-y: auto;
border-radius: 16px;
background: #1c1822;
cursor: none;
}
.pr-content {
padding: 16px;
height: 340px;
}
.pr-content p {
margin: 0 0 24px;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.55);
}
.pr-ring {
position: absolute;
left: 0;
top: 0;
width: 34px;
height: 34px;
margin: -17px 0 0 -17px;
pointer-events: none;
transform: translate(24px, 112px);
transition: transform 220ms ease-out;
}
.pr-ring svg:first-child {
transform: rotate(-90deg);
}
.pr-ring__track {
fill: none;
stroke: rgba(255, 255, 255, 0.18);
stroke-width: 3;
}
.pr-ring__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 94.2;
stroke-dashoffset: var(--dash, 94.2);
}
.pr-ring__check {
position: absolute;
inset: 8px;
color: #4cd4a6;
opacity: 0;
}
.pr-box.is-done .pr-ring__check {
opacity: 1;
}
.pr-box.is-done .pr-ring svg:first-child {
opacity: 0;
}
.pr-box.is-demo {
overflow: hidden;
}
.pr-box.is-demo .pr-ring {
animation: pr-move 2s ease-out infinite;
transition: none;
}
.pr-box.is-demo .pr-ring__fill {
animation: pr-fill 2s ease-out infinite;
}
.pr-box.is-demo .pr-ring__check {
animation: pr-check 2s ease-out infinite;
}
@keyframes pr-move {
0%, 8% {
transform: translate(24px, 112px);
}
50% {
transform: translate(150px, 40px);
}
92%, 100% {
transform: translate(24px, 112px);
}
}
@keyframes pr-fill {
0%, 8% {
stroke-dashoffset: 94.2;
}
50%, 62% {
stroke-dashoffset: 0;
}
92%, 100% {
stroke-dashoffset: 94.2;
}
}
@keyframes pr-check {
0%, 46% {
opacity: 0;
}
52%, 60% {
opacity: 1;
}
68%, 100% {
opacity: 0;
}
}
@media (hover: none), (pointer: coarse) {
.pr-box {
cursor: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.pr-ring, .pr-ring__fill, .pr-ring__check {
animation: none !important;
transition: none !important;
}
}(function () {
var box = document.querySelector('.pr-box');
var ring = box.querySelector('.pr-ring');
var CIRC = 2 * Math.PI * 15;
// 실제 사용자: 포인터를 따라가고, 스크롤 진행률만큼 링 테두리가 채워진다
box.addEventListener('pointermove', function (e) {
if (box.classList.contains('is-demo')) return;
var r = box.getBoundingClientRect();
ring.style.transform = 'translate(' + (e.clientX - r.left) + 'px,' + (e.clientY - r.top) + 'px)';
});
box.addEventListener('scroll', function () {
if (box.classList.contains('is-demo')) return;
var max = box.scrollHeight - box.clientHeight;
var pct = max > 0 ? box.scrollTop / max : 0;
ring.style.setProperty('--dash', (CIRC * (1 - pct)).toFixed(1));
box.classList.toggle('is-done', pct > 0.98);
});
})();import { useRef, useState, type CSSProperties, type PointerEvent } from "react";
import styles from "./CursorScrollProgressRing.module.scss";
export type CursorScrollProgressRingProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CIRC = 2 * Math.PI * 15;
/** pointermove 로 커서를 따라가고, 내부 스크롤 비율만큼 stroke-dashoffset 을 채운다 */
export default function CursorScrollProgressRing({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: CursorScrollProgressRingProps) {
const ringRef = useRef<HTMLDivElement>(null);
const [done, setDone] = useState(false);
const [dash, setDash] = useState(CIRC);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const move = (e: PointerEvent<HTMLDivElement>) => {
const ring = ringRef.current;
if (!ring) return;
const r = e.currentTarget.getBoundingClientRect();
ring.style.transform = `translate(${e.clientX - r.left}px,${e.clientY - r.top}px)`;
};
const scroll = (e: { currentTarget: HTMLDivElement }) => {
const box = e.currentTarget;
const max = box.scrollHeight - box.clientHeight;
const pct = max > 0 ? box.scrollTop / max : 0;
setDash(CIRC * (1 - pct));
setDone(pct > 0.98);
};
return (
<div className={`${styles.box} ${done ? styles.isDone : ""}`} style={vars} onPointerMove={move} onScroll={scroll}>
<div className={styles.content}>
<p>이 안에서 스크롤하면</p>
<p>커서 옆 링이</p>
<p>진행률만큼 채워진다</p>
<p>끝까지 가면 체크로 바뀐다</p>
</div>
<div ref={ringRef} className={styles.ring} aria-hidden="true">
<svg viewBox="0 0 36 36">
<circle className={styles.track} cx="18" cy="18" r="15" />
<circle className={styles.fill} cx="18" cy="18" r="15" style={{ strokeDashoffset: dash }} />
</svg>
<svg className={styles.check} viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" /><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" /></g></svg>
</div>
</div>
);
}08 · スクロール進捗カーソルリング — 計測 累積面積 3.108% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
リングの stroke-dashoffset をスクロール比率だけ減らします。
box.addEventListener('scroll', function () {
var max = box.scrollHeight - box.clientHeight;
var pct = max > 0 ? box.scrollTop / max : 0;
ring.style.setProperty('--dash', (94.2 * (1 - pct)).toFixed(1));
box.classList.toggle('is-done', pct > 0.98);
});
09ホバーキャプション画像グリッド
どんな動きか
グリッドのタイルにマウスを乗せると下から白いキャプション帯がせり上がってラベルが現れ、キーボードフォーカスでも同じく反応します。

<main class="stage">
<div class="hg-grid is-demo">
<div class="hg-tile" style="background:#dbe9ff" tabindex="0">
<span class="hg-tile__cap"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg>ホームページリニューアル</span>
</div>
<div class="hg-tile" style="background:#dcf5e8" tabindex="0">
<span class="hg-tile__cap"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg>ブランドロゴ</span>
</div>
<div class="hg-tile" style="background:#fff0d9" tabindex="0">
<span class="hg-tile__cap"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg>モバイルアプリ</span>
</div>
<div class="hg-tile" style="background:#f4e2ff" tabindex="0">
<span class="hg-tile__cap"><svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg>ダッシュボード</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage(#f4f8ff);
.hg-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-3;
width: min(220px, 74vw);
}
.hg-tile {
position: relative;
aspect-ratio: 3 / 2;
border-radius: $r-control;
overflow: hidden;
cursor: pointer;
box-shadow: 0 6px 16px rgba(23, 20, 26, .08);
}
.hg-tile:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.hg-tile__cap {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
gap: $sp-1;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .94);
color: #16213a;
font-size: 11px;
font-weight: 700;
transform: translateY(100%);
transition: transform $duration $easing;
}
.hg-tile__cap svg { width: 14px; height: 14px; flex: 0 0 auto; color: $color; }
.hg-tile:hover .hg-tile__cap,
.hg-tile:focus-visible .hg-tile__cap { transform: translateY(0); }
// 데모 강제 루프 — 네 칸이 순서대로 캡션을 올렸다 내린다
.hg-grid.is-demo .hg-tile__cap { animation: hg-reveal $dur-loop $easing infinite; }
.hg-grid.is-demo .hg-tile:nth-child(2) .hg-tile__cap { animation-delay: .12s; }
.hg-grid.is-demo .hg-tile:nth-child(3) .hg-tile__cap { animation-delay: .24s; }
.hg-grid.is-demo .hg-tile:nth-child(4) .hg-tile__cap { animation-delay: .36s; }
@keyframes hg-reveal {
0%, 6% { transform: translateY(100%); }
30%, 55% { transform: translateY(0); }
80%, 100% { transform: translateY(100%); }
}
@media (prefers-reduced-motion: reduce) {
.hg-tile__cap { animation: none !important; transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f4f8ff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hg-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: min(220px, 74vw);
}
.hg-tile {
position: relative;
aspect-ratio: 3/2;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 6px 16px rgba(23, 20, 26, 0.08);
}
.hg-tile:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.hg-tile__cap {
position: absolute;
inset: auto 0 0 0;
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.94);
color: #16213a;
font-size: 11px;
font-weight: 700;
transform: translateY(100%);
transition: transform 300ms ease-out;
}
.hg-tile__cap svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: #2f6df6;
}
.hg-tile:hover .hg-tile__cap,
.hg-tile:focus-visible .hg-tile__cap {
transform: translateY(0);
}
.hg-grid.is-demo .hg-tile__cap {
animation: hg-reveal 2s ease-out infinite;
}
.hg-grid.is-demo .hg-tile:nth-child(2) .hg-tile__cap {
animation-delay: 0.12s;
}
.hg-grid.is-demo .hg-tile:nth-child(3) .hg-tile__cap {
animation-delay: 0.24s;
}
.hg-grid.is-demo .hg-tile:nth-child(4) .hg-tile__cap {
animation-delay: 0.36s;
}
@keyframes hg-reveal {
0%, 6% {
transform: translateY(100%);
}
30%, 55% {
transform: translateY(0);
}
80%, 100% {
transform: translateY(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.hg-tile__cap {
animation: none !important;
transition: none !important;
}
}import type { CSSProperties } from "react";
import styles from "./CursorHoverCaptionGrid.module.scss";
export type CursorHoverCaptionGridProps = {
items?: { bg: string; label: string }[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const EYE = (
<svg viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" /><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32" /></g></svg>
);
/** :hover 와 :focus-visible 로 캡션을 올린다 — JS 없이도 실제 사이트에서 그대로 동작한다 */
export default function CursorHoverCaptionGrid({
items = [
{ bg: "#dbe9ff", label: "홈페이지 리뉴얼" },
{ bg: "#dcf5e8", label: "브랜드 로고" },
{ bg: "#fff0d9", label: "모바일 앱" },
{ bg: "#f4e2ff", label: "대시보드" },
],
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: CursorHoverCaptionGridProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.grid} style={vars}>
{items.map((it, i) => (
<div key={i} className={styles.tile} style={{ background: it.bg }} tabIndex={0}>
<span className={styles.cap}>{EYE}{it.label}</span>
</div>
))}
</div>
);
}09 · ホバーキャプション画像グリッド — 計測 累積面積 10.226% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
JSは一切使わず、:hover と :focus-visible の二つだけで動きます。
.hg-tile__cap {
transform: translateY(100%);
transition: transform .3s ease-out;
}
.hg-tile:hover .hg-tile__cap,
.hg-tile:focus-visible .hg-tile__cap { transform: translateY(0); }
マウス カーソル エフェクトはどこで壊れるか
最初に出会った落とし穴は04番の3Dチルトでした。rotateX・rotateY は見た目だけ傾いてレイアウトの再計算は起こさないはずなのに、320px幅では傾いたカードの見かけの境界ボックスがスクロール領域にそのまま含まれ、画面が横に溢れてしまいました。最初の9度では溢れが残り、6度まで減らしてカード自体も小さくしてから、320pxレンダーでの溢れが消えたことを確認しました。二つ目は05番のドラッグギャラリーです。指でドラッグする動きを transform で再現すると実際のスクロール位置とずれてしまうため、本物のドラッグ操作では必ず scrollLeft を直接動かす必要があります。三つ目は06番の右クリックメニューで、座標をクランプしないと画面の右端・下端でメニューがエリアの外にはみ出してしまいます。
アクセシビリティ — カスタムカーソルが逆に邪魔になるとき
01・03・08番のようにカーソル自体を追いかける要素は、prefers-reduced-motion: reduce で動きが止まるよう transition・animation をすべて切ります。ですが本当の問題は動きよりもカーソル自体です。ロービジョンの方や、トラックパッドの代わりにスイッチ・ジョイスティックのような支援機器を使う方にとって、小さなカスタムカーソルは標準カーソルより見つけにくいことがあります。そのため01番は @media (hover: none), (pointer: coarse) でタッチ端末ではカーソルの点自体を完全に消し、08番は同じ条件で標準カーソルに戻します。02・04番はそもそも隠すべきカーソル要素がなく、ポインタ駆動のトランジションだけが切れます。03番はスポットライトを隠す代わりに文字の色を最初から明るくして内容が常に読めるようにしました。05番のドラッグギャラリーはむしろタッチのほうがよく動きます — ネイティブスクロールがそのまま生きているためです。06・07・09番はマウス操作(右クリック・ドラッグ選択・ホバー)それぞれにキーボード・フォーカスの代替手段(Escape・Tab・:focus-visible)を付け、マウスがなくても同じ機能を使えるようにしました。ポインタ座標を値に変えて画面を描き直す骨組みは、css 背景 パターン ジェネレーターでシード一つから繰り返されます。9つのファイルの圧縮パスワードはc5gmgmdfで、解凍するとvanilla・reactフォルダで同じ9つの動きを並べて比較できます。
FAQ
マウス クリック エフェクトまでCSSだけで作れますか?
一部はそうです。07番の選択色と09番のホバーキャプショングリッドは ::selection と :hover だけで動くCSS専用エフェクトです。残りはカーソル座標を読む必要があるため、pointermove を使う短いバニラJSが必要です。カーソルに合わせて場面全体が傾く演出はマウス 追従 アニメーション cssで角度計算まで解説しています。
カスタムカーソルを使うとサイトが遅くなりますか?
pointermove ごとに transform だけを変えればレイアウトの再計算が発生せず、ほとんどの場合なめらかに動きます。ただしサイズが変わらない要素なら、getBoundingClientRect を毎回測り直さず一度キャッシュしておくとよいです。
これらのエフェクトはモバイルでも動きますか?
ポインタを前提とする01・02・03・04・08番はタッチでは意味がないため、CSSで隠すか別の状態を用意しています。05番のドラッグギャラリーと06・07・09番は、タッチでも自然に動くよう別途対応しています。
出典: MDN Pointer events、MDN prefers-reduced-motion · 関連: 紹介、ホバーインタラクション一覧、LPインタラクション









