css ボタン ホバー エフェクト 9選 — 距離の値で変わる
もしカーソルがまだボタンから少し離れていれば、距離の値はまだ大きいままだ。その値が縮むにつれて1番は後光の半径を広げ、6番は枠線を描き進め、9番は周囲の粒子密度を満たす。css ボタン ホバー エフェクト を探すと、この9つのボタンは同じ距離の値だけで反応が変わります。
01 btn-proximity-glow-radius-grow
02 btn-proximity-scale-lift-hover
03 btn-proximity-tilt-angle-follow
04 btn-proximity-blur-focus-sharpen
05 btn-proximity-hue-shift-approach
06 btn-proximity-border-trace-draw
07 btn-proximity-shadow-lift-depth
08 btn-proximity-letter-spacing-expand
09 btn-proximity-particle-density-swarm
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 距離値の分だけ後光が広がるボタン
- 02 距離値が縮むほど徐々に大きくなるボタン
- 03 距離値が角度値に変わる傾きボタン
- 04 距離値が遠いほどぼやける周囲のボタン
- 05 距離値が色の補間地点になるボタン
- 06 距離値の分だけ枠線が描かれるボタン
- 07 距離値の分だけ浮き上がる影のボタン
- 08 距離値が文字間隔値として広がるボタン
- 09 距離値の分だけ粒子が集まるボタン
距離の値ひとつがなぜ9つのボタンを同時に動かすのか
このページの9つのデモはすべて同じ小さな関数から始まります。ポインターとボタン中心とのピクセル距離を測り、その数値を0から1の比率に変え、見た目のあらゆる値がその一つの比率だけを読み取ります。:hover の境界でクラスを切り替えるだけの実装では、カーソルがまだ近づいている途中の変化を再現できません。css ボタン マウス オーバー で検索して来た人が期待するのはこの連続した変化のほうです。9つの違いは、どのプロパティがその比率を読むか、そして比率とのあいだにもう一段の計算(スプリング・ベクトル・順位・実測幅)が挟まっているかどうかだけです。
近づくボタンが実際にどの画面で役立つか
LPのCTAボタン(01・09)は、クリックの前段階でも後光や粒子が反応することで、近づくだけで価値があるように見せられます。カード型のボタン一覧(02・04・07)は、並んだ候補の中から一つだけを目立たせたいときにスケール・ぼかしのパターンが向いています。商品カードの先行反応(03)や強調ボタンの色変化(05)は、一つだけの主役ボタンに向いています。css ボタン デザイン を探している人の多くは、こうした9つをそのまま流用したいはずで、このページ下部の zip にはその実装がそのまま入っています。
パターン別コード — index.htmlからそのまま切り出しました
下のコードはすべて各項目の index.html から改変なしで切り出したものです。画面上のバッジ文字だけを実際にこのページが表示する日本語に置き換えています。デモの中で自動的に動いて見えるカーソルは syntheticOffset という関数が描く仮想の軌道で、実際の誰かのポインター操作を記録したものではありません。
01距離値の分だけ後光が広がるボタン
カーソルとボタンの間の距離値が縮む間、ボタンを包む後光の半径が広がり、明るさも同時に上がります。一つの近接比率(p)がぼかし半径・広がり・brightness() という3つのCSS値を同じ呼び出しの中で動かすため、光の広がりと明るさがずれることなく揃って変化します。

<main class="stage">
<div class="pg is-demo" id="pg">
<p class="pg__badge" id="pgBadge">距離値 100%</p>
<div class="pg__scene" id="pgScene">
<button class="pg__btn" id="pgBtn" type="button">今すぐ始める</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: pointermove 로 커서–버튼 중심 거리(px)를 재고 1 - clamp(d / PROX_RADIUS_PX) 로
// 거리 값(0~1)을 만든다. 그 값이 후광의 blur-radius·spread·밝기를 함께 키운다.
// 계산 한 겹: 매 프레임 lerp 로 목표값을 관성 있게 뒤쫓는다(속성 트윈이 아니라 계산).
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-ink);
.pg {
display: grid;
gap: $sp-4;
justify-items: center;
}
.pg__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pg__scene {
padding: $sp-10;
}
.pg__btn {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
will-change: box-shadow, filter;
}
.pg__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
@media (max-width: 340px) {
.pg__scene { padding: $sp-6; }
.pg__btn { padding: $sp-3 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.pg__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pg {
display: grid;
gap: 16px;
justify-items: center;
}
.pg__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pg__scene {
padding: 40px;
}
.pg__btn {
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
will-change: box-shadow, filter;
}
.pg__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
@media (max-width: 340px) {
.pg__scene {
padding: 24px;
}
.pg__btn {
padding: 12px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg__btn {
transition: none;
}
}(function () {
// PROX_GLOW_RADIUS_MAX_PX — 변형 1: 40 → 80 로 늘리면 후광이 버튼 전체를 덮을 만큼 크게 번진다.
var PROX_GLOW_RADIUS_MAX_PX = 40;
var PROX_RADIUS_PX = 170;
var LERP = 0.18;
var root = document.getElementById('pg');
var scene = document.getElementById('pgScene');
var btn = document.getElementById('pgBtn');
var badge = document.getElementById('pgBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetP = 0;
var curP = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
function render(p) {
var blurRadius = p * PROX_GLOW_RADIUS_MAX_PX;
var spread = p * PROX_GLOW_RADIUS_MAX_PX * 0.3;
var alpha = (0.18 + p * 0.55).toFixed(2);
btn.style.boxShadow = '0 0 ' + blurRadius.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(255,247,230,' + alpha + ')';
btn.style.filter = 'brightness(' + (1 + p * 0.22).toFixed(3) + ')';
badge.textContent = '距離値 ' + Math.round((1 - p) * 100) + '%';
}
// 자동 시연: 가상 커서가 궤도(반지름이 2초 동안 0 → 최대 → 0)를 돌며 접근·이탈을 만든다.
function syntheticOffset(t) {
var ang = t * 1.7;
var rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
void cx; void cy;
}
curP += (targetP - curP) * LERP;
render(curP);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curP = targetP; render(curP); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; render(1); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
if (reduce) {
render(0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityGlowButton — vanilla 판(run/408/demo/01-btn-proximity-glow-radius-grow/index.html) 의 React 포트.
// 버튼까지 거리 값(0~1)을 lerp 로 관성 있게 따라가며 후광(box-shadow blur·spread)과 밝기(filter)를 계산한다.
// 포인터가 없을 때(자동 시연)는 궤도 반지름이 2초 동안 0 → 최대 → 0 으로 도는 가상 커서 거리로 같은 계산을 돌린다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityGlowButton.module.scss";
type Box<T> = { current: T };
// PROX_GLOW_RADIUS_MAX_PX — 변형 1: 40 → 80 로 늘리면 후광이 버튼 전체를 덮을 만큼 크게 번진다.
const PROX_GLOW_RADIUS_MAX_PX = 40;
const PROX_RADIUS_PX = 170;
const LERP = 0.18;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.7;
const rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityGlowButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const btn = btnRef.current;
const badge = badgeRef.current;
if (!btn || !badge) return;
const blurRadius = p * PROX_GLOW_RADIUS_MAX_PX;
const spread = p * PROX_GLOW_RADIUS_MAX_PX * 0.3;
const alpha = (0.18 + p * 0.55).toFixed(2);
btn.style.boxShadow = "0 0 " + blurRadius.toFixed(1) + "px " + spread.toFixed(1) + "px rgba(255,247,230," + alpha + ")";
btn.style.filter = "brightness(" + (1 + p * 0.22).toFixed(3) + ")";
badge.textContent = "거리 값 " + Math.round((1 - p) * 100) + "%";
}
function distanceToButton(x: number, y: number): number {
const btn = btnRef.current;
if (!btn) return PROX_RADIUS_PX;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
render(curPRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curPRef.current = targetPRef.current; render(curPRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
const cls = [styles.pg, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>거리 값 100%</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>지금 시작하기</button>
</div>
</div>
</main>
);
}01 · 距離値の分だけ後光が広がるボタン — 計測 累積面積 20.583% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
function render(p) {
var blurRadius = p * PROX_GLOW_RADIUS_MAX_PX;
var spread = p * PROX_GLOW_RADIUS_MAX_PX * 0.3;
var alpha = (0.18 + p * 0.55).toFixed(2);
btn.style.boxShadow = '0 0 ' + blurRadius.toFixed(1) + 'px ' + spread.toFixed(1) + 'px rgba(255,247,230,' + alpha + ')';
btn.style.filter = 'brightness(' + (1 + p * 0.22).toFixed(3) + ')';
badge.textContent = '距離値 ' + Math.round((1 - p) * 100) + '%';
}
計測値: 累積面積 20.6%、平均強度 52.6、動いたフレーム 22/23。
02距離値が縮むほど徐々に大きくなるボタン
カーソルの距離値はボタンの大きさを二値的に切り替えません。スプリング(STIFFNESS・DAMPING)が目標比率を1フレームずつ追いかけるため、カーソルが止まった後も大きさはしばらく動き続けます。同じスプリングの位置は --tone-p というカスタムプロパティにも書き込まれ、背景トーンもスケールと一緒にオーバーシュートします。

<main class="stage">
<div class="sl is-demo" id="sl">
<p class="sl__badge" id="slBadge">距離値 100%</p>
<div class="sl__scene" id="slScene">
<button class="sl__btn" id="slBtn" type="button">カートに入れる</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거리 값을 스프링 방정식(강성·감쇠)에 흘려 버튼 크기·들림을 연속으로 바꾼다.
// 이진적 켜짐·꺼짐이 아니라 목표를 살짝 넘겼다가 정착하는 계산 한 겹(오버슛)을 더했다.
$duration: $dur-loop;
$easing: $ease-lerp-settle;
$color: $subject-ink;
@include stage($stage-yellow);
.sl {
display: grid;
gap: $sp-4;
justify-items: center;
}
.sl__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sl__scene {
position: relative;
padding: $sp-10;
--tone-p: 0;
}
.sl__scene::before {
content: '';
position: absolute;
inset: 0;
margin: auto;
width: 220px;
height: 140px;
border-radius: $r-control;
background: radial-gradient(circle, rgba($subject-blue-deep, .4), transparent 70%);
transform: scale(calc(0.55 + var(--tone-p) * 0.7));
opacity: var(--tone-p);
pointer-events: none;
z-index: 0;
}
.sl__btn {
position: relative;
z-index: 1;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
transform-origin: center;
will-change: transform, box-shadow;
}
.sl__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
@media (max-width: 340px) {
.sl__scene { padding: $sp-6; }
.sl__btn { padding: $sp-3 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.sl__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sl {
display: grid;
gap: 16px;
justify-items: center;
}
.sl__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sl__scene {
position: relative;
padding: 40px;
--tone-p: 0;
}
.sl__scene::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 220px;
height: 140px;
border-radius: 12px;
background: radial-gradient(circle, rgba(10, 79, 232, 0.4), transparent 70%);
transform: scale(calc(0.55 + var(--tone-p) * 0.7));
opacity: var(--tone-p);
pointer-events: none;
z-index: 0;
}
.sl__btn {
position: relative;
z-index: 1;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
transform-origin: center;
will-change: transform, box-shadow;
}
.sl__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
@media (max-width: 340px) {
.sl__scene {
padding: 24px;
}
.sl__btn {
padding: 12px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.sl__btn {
transition: none;
}
}(function () {
var PROX_RADIUS_PX = 170;
var SL_SCALE_MAX = 0.08;
var SL_LIFT_MAX_PX = 6;
// 스프링 방정식(강성·감쇠) — lerp 보다 한 겹 더한 관성: 목표를 살짝 넘겼다가 정착한다.
var STIFFNESS = 0.22;
var DAMPING = 0.72;
var root = document.getElementById('sl');
var scene = document.getElementById('slScene');
var btn = document.getElementById('slBtn');
var badge = document.getElementById('slBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetP = 0;
var pos = 0;
var vel = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
function render(p) {
var scale = 1 + p * SL_SCALE_MAX;
var lift = p * SL_LIFT_MAX_PX;
var depth = 8 + p * 18;
btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px) scale(' + scale.toFixed(4) + ')';
btn.style.boxShadow = '0 ' + depth.toFixed(1) + 'px ' + (depth * 1.6).toFixed(1) + 'px rgba(23,20,26,' + (0.18 + p * 0.18).toFixed(2) + ')';
// 계산 레이어: 스프링 오버슛(p) 을 배경 톤(그림자 뒤 글로우)에도 같은 값으로 전파한다.
var tone = clamp(p, 0, 1.15);
scene.style.setProperty('--tone-p', tone.toFixed(3));
badge.textContent = '距離値 ' + Math.round((1 - clamp(p, 0, 1)) * 100) + '%';
}
function springStep(target) {
var force = (target - pos) * STIFFNESS;
vel = (vel + force) * DAMPING;
pos += vel;
}
function syntheticOffset(t) {
var ang = t * 1.4 + 0.6;
var rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
springStep(targetP);
render(clamp(pos, -0.2, 1.2));
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { pos = targetP; render(pos); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { pos = 0; render(0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { pos = 1; render(1); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { pos = 0; render(0); }
});
if (reduce) {
render(0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityScaleLiftButton — vanilla 판(run/408/demo/02-btn-proximity-scale-lift-hover/index.html) 의 React 포트.
// 거리 값을 스프링 방정식(강성 STIFFNESS·감쇠 DAMPING)에 흘려 목표를 살짝 넘겼다가 정착하는
// 오버슛 한 겹을 더한다 — 단순 lerp 보다 한 계산 층이 깊다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityScaleLiftButton.module.scss";
type Box<T> = { current: T };
const PROX_RADIUS_PX = 170;
const SL_SCALE_MAX = 0.08;
const SL_LIFT_MAX_PX = 6;
const STIFFNESS = 0.22;
const DAMPING = 0.72;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.4 + 0.6;
const rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityScaleLiftButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const targetPRef = useRef<number>(0) as Box<number>;
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const btn = btnRef.current;
const badge = badgeRef.current;
const scene = sceneRef.current;
if (!btn || !badge || !scene) return;
const scale = 1 + p * SL_SCALE_MAX;
const lift = p * SL_LIFT_MAX_PX;
const depth = 8 + p * 18;
btn.style.transform = "translateY(" + (-lift).toFixed(2) + "px) scale(" + scale.toFixed(4) + ")";
btn.style.boxShadow = "0 " + depth.toFixed(1) + "px " + (depth * 1.6).toFixed(1) + "px rgba(23,20,26," + (0.18 + p * 0.18).toFixed(2) + ")";
// 계산 레이어: 스프링 오버슛 값(p, 1 을 넘나든다)을 장면 배경 글로우 크기·불투명도에도 전파한다.
const tone = clamp(p, 0, 1.15);
scene.style.setProperty("--tone-p", tone.toFixed(3));
badge.textContent = "거리 값 " + Math.round((1 - clamp(p, 0, 1)) * 100) + "%";
}
function springStep(target: number): void {
const force = (target - posRef.current) * STIFFNESS;
velRef.current = (velRef.current + force) * DAMPING;
posRef.current += velRef.current;
}
function distanceToButton(x: number, y: number): number {
const btn = btnRef.current;
if (!btn) return PROX_RADIUS_PX;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
springStep(targetPRef.current);
render(clamp(posRef.current, -0.2, 1.2));
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { posRef.current = targetPRef.current; render(posRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { posRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { posRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { posRef.current = 0; render(0); }
}
const cls = [styles.sl, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>거리 값 100%</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>담기</button>
</div>
</div>
</main>
);
}02 · 距離値が縮むほど徐々に大きくなるボタン — 計測 累積面積 18.788% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(p) {
var scale = 1 + p * SL_SCALE_MAX;
var lift = p * SL_LIFT_MAX_PX;
var depth = 8 + p * 18;
btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px) scale(' + scale.toFixed(4) + ')';
btn.style.boxShadow = '0 ' + depth.toFixed(1) + 'px ' + (depth * 1.6).toFixed(1) + 'px rgba(23,20,26,' + (0.18 + p * 0.18).toFixed(2) + ')';
// 計算レイヤー: 同じスプリングオーバーシュート値(p)を背景トーン(影の奥のグロー)にも伝える。
var tone = clamp(p, 0, 1.15);
scene.style.setProperty('--tone-p', tone.toFixed(3));
badge.textContent = '距離値 ' + Math.round((1 - clamp(p, 0, 1)) * 100) + '%';
}
function springStep(target) {
var force = (target - pos) * STIFFNESS;
vel = (vel + force) * DAMPING;
pos += vel;
}
計測値: 累積面積 18.8%、平均強度 47.4、動いたフレーム 18/23。
03距離値が角度値に変わる傾きボタン
ボタン中心からカーソルへの向きは、単なる距離ではなくベクトルとして保持されたうえで回転角に変換されるため、カーソルが触れる前からその方向へボタンが傾きます。角度の計算に使った ux・uy・p はそのまま縁のハイライト位置と奥行きの影にも使い回されます。

<main class="stage">
<div class="ta is-demo" id="ta">
<p class="ta__badge" id="taBadge">角度値 0°</p>
<div class="ta__scene" id="taScene">
<button class="ta__btn" id="taBtn" type="button">
<span class="ta__label">購入する</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 커서–버튼 중심 방향 벡터를 단위 벡터로 정규화하고 atan2 로 각도를 구한 뒤,
// 거리 값(가까울수록 1)을 그 각도의 크기로 곱한다 — 닿기 전부터 커서 쪽으로 미리 기운다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
.ta {
display: grid;
gap: $sp-4;
justify-items: center;
}
.ta__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ta__scene {
perspective: 600px;
padding: $sp-10;
}
.ta__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: $shadow-raised;
will-change: transform, box-shadow;
--hl-x: 50%;
--hl-y: 50%;
--hl-o: 0;
}
.ta__btn::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at var(--hl-x) var(--hl-y), rgba(23, 20, 26, .55), transparent 62%);
opacity: var(--hl-o);
pointer-events: none;
}
.ta__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
@media (max-width: 340px) {
.ta__scene { padding: $sp-6; }
.ta__btn { padding: $sp-3 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.ta__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);
}
.ta {
display: grid;
gap: 16px;
justify-items: center;
}
.ta__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ta__scene {
perspective: 600px;
padding: 40px;
}
.ta__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-size: 16px;
font-weight: 700;
transform-style: preserve-3d;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform, box-shadow;
--hl-x: 50%;
--hl-y: 50%;
--hl-o: 0;
}
.ta__btn::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at var(--hl-x) var(--hl-y), rgba(23, 20, 26, 0.55), transparent 62%);
opacity: var(--hl-o);
pointer-events: none;
}
.ta__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@media (max-width: 340px) {
.ta__scene {
padding: 24px;
}
.ta__btn {
padding: 12px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.ta__btn {
transition: none;
}
}(function () {
var PROX_RADIUS_PX = 190;
var TA_MAX_DEG = 12;
var LERP = 0.16;
var root = document.getElementById('ta');
var scene = document.getElementById('taScene');
var btn = document.getElementById('taBtn');
var badge = document.getElementById('taBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetDx = 0, targetDy = 0;
var curDx = 0, curDy = 0;
var hasPointer = false;
function vectorToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return [x - cx, y - cy];
}
function render(dx, dy) {
// 방향 벡터 → 각도(atan2) 변환 — 距離値(가까울수록 1)을 각도 크기로 쓴다.
var d = Math.hypot(dx, dy);
var p = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
var ux = d > 0.001 ? dx / d : 0;
var uy = d > 0.001 ? dy / d : 0;
var rotY = ux * p * TA_MAX_DEG;
var rotX = -uy * p * TA_MAX_DEG;
var lean = p * 6;
btn.style.transform = 'translate(' + (ux * lean).toFixed(2) + 'px,' + (uy * lean).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
// 계산 레이어: 같은 기울기 각도(ux·uy·p) 를 테두리 하이라이트 위치·깊이 그림자에도 전파한다.
var shadowX = ux * p * 16;
var shadowY = 6 + uy * p * 12;
var shadowBlur = 14 + p * 22;
btn.style.boxShadow = shadowX.toFixed(1) + 'px ' + shadowY.toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px rgba(23,20,26,' + (0.2 + p * 0.3).toFixed(2) + ')';
btn.style.setProperty('--hl-x', (50 + ux * 38).toFixed(1) + '%');
btn.style.setProperty('--hl-y', (50 + uy * 38).toFixed(1) + '%');
btn.style.setProperty('--hl-o', (0.12 + p * 0.5).toFixed(2));
var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
badge.textContent = '角度値 ' + Math.round(rotY) + '°';
void angleDeg;
}
function syntheticOffset(t) {
var ang = t * 1.9;
var rad = 130 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad * 0.7];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
targetDx = off[0]; targetDy = off[1];
}
curDx += (targetDx - curDx) * LERP;
curDy += (targetDy - curDy) * LERP;
render(curDx, curDy);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var v = vectorToButton(e.clientX, e.clientY);
targetDx = v[0]; targetDy = v[1];
if (reduce) { curDx = v[0]; curDy = v[1]; render(curDx, curDy); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetDx = PROX_RADIUS_PX * 2; targetDy = 0;
if (reduce) { curDx = targetDx; curDy = 0; render(curDx, 0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetDx = 0; targetDy = 0;
if (reduce) { curDx = 0; curDy = 0; render(0, 0); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetDx = PROX_RADIUS_PX * 2; targetDy = 0;
if (reduce) { curDx = targetDx; curDy = 0; render(curDx, 0); }
});
if (reduce) {
curDx = PROX_RADIUS_PX * 2; curDy = 0;
render(curDx, curDy);
} else {
requestAnimationFrame(loop);
}
})();// ProximityTiltButton — vanilla 판(run/408/demo/03-btn-proximity-tilt-angle-follow/index.html) 의 React 포트.
// 커서–버튼 중심 방향 벡터를 단위 벡터로 정규화하고, 거리 값(가까울수록 1)을 그 방향의 각도 크기로
// 곱한다 — atan2 로 각도를 구하는 계산 한 겹이 단순 좌표 매핑과 다르다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityTiltButton.module.scss";
type Box<T> = { current: T };
const PROX_RADIUS_PX = 190;
const TA_MAX_DEG = 12;
const LERP = 0.16;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.9;
const rad = 130 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad * 0.7];
}
export default function ProximityTiltButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const targetDxRef = useRef<number>(0) as Box<number>;
const targetDyRef = useRef<number>(0) as Box<number>;
const curDxRef = useRef<number>(0) as Box<number>;
const curDyRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(dx: number, dy: number): void {
const btn = btnRef.current;
const badge = badgeRef.current;
if (!btn || !badge) return;
const d = Math.hypot(dx, dy);
const p = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
const ux = d > 0.001 ? dx / d : 0;
const uy = d > 0.001 ? dy / d : 0;
const rotY = ux * p * TA_MAX_DEG;
const rotX = -uy * p * TA_MAX_DEG;
const lean = p * 6;
btn.style.transform = "translate(" + (ux * lean).toFixed(2) + "px," + (uy * lean).toFixed(2) + "px) rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + rotY.toFixed(2) + "deg)";
// 계산 레이어: 같은 기울기 각도(ux·uy·p) 를 테두리 하이라이트 위치·깊이 그림자에도 전파한다.
const shadowX = ux * p * 16;
const shadowY = 6 + uy * p * 12;
const shadowBlur = 14 + p * 22;
btn.style.boxShadow = shadowX.toFixed(1) + "px " + shadowY.toFixed(1) + "px " + shadowBlur.toFixed(1) + "px rgba(23,20,26," + (0.2 + p * 0.3).toFixed(2) + ")";
btn.style.setProperty("--hl-x", (50 + ux * 38).toFixed(1) + "%");
btn.style.setProperty("--hl-y", (50 + uy * 38).toFixed(1) + "%");
btn.style.setProperty("--hl-o", (0.12 + p * 0.5).toFixed(2));
badge.textContent = "각도 값 " + Math.round(rotY) + "°";
}
function vectorToButton(x: number, y: number): [number, number] {
const btn = btnRef.current;
if (!btn) return [PROX_RADIUS_PX * 2, 0];
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return [x - cx, y - cy];
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
curDxRef.current = PROX_RADIUS_PX * 2;
curDyRef.current = 0;
render(curDxRef.current, curDyRef.current);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
targetDxRef.current = off[0];
targetDyRef.current = off[1];
}
curDxRef.current += (targetDxRef.current - curDxRef.current) * LERP;
curDyRef.current += (targetDyRef.current - curDyRef.current) * LERP;
render(curDxRef.current, curDyRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const v = vectorToButton(event.clientX, event.clientY);
targetDxRef.current = v[0];
targetDyRef.current = v[1];
if (reduce) { curDxRef.current = v[0]; curDyRef.current = v[1]; render(curDxRef.current, curDyRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetDxRef.current = PROX_RADIUS_PX * 2;
targetDyRef.current = 0;
if (reduce) { curDxRef.current = targetDxRef.current; curDyRef.current = 0; render(curDxRef.current, 0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetDxRef.current = 0;
targetDyRef.current = 0;
if (reduce) { curDxRef.current = 0; curDyRef.current = 0; render(0, 0); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetDxRef.current = PROX_RADIUS_PX * 2;
targetDyRef.current = 0;
if (reduce) { curDxRef.current = targetDxRef.current; curDyRef.current = 0; render(curDxRef.current, 0); }
}
const cls = [styles.ta, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>각도 값 0°</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>
<span>구매하기</span>
</button>
</div>
</div>
</main>
);
}03 · 距離値が角度値に変わる傾きボタン — 計測 累積面積 13.244% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function render(dx, dy) {
// 方向ベクトル→角度(atan2)変換 — 距離値(近いほど1)を角度の大きさとして使う。
var d = Math.hypot(dx, dy);
var p = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
var ux = d > 0.001 ? dx / d : 0;
var uy = d > 0.001 ? dy / d : 0;
var rotY = ux * p * TA_MAX_DEG;
var rotX = -uy * p * TA_MAX_DEG;
var lean = p * 6;
btn.style.transform = 'translate(' + (ux * lean).toFixed(2) + 'px,' + (uy * lean).toFixed(2) + 'px) rotateX(' + rotX.toFixed(2) + 'deg) rotateY(' + rotY.toFixed(2) + 'deg)';
// 計算レイヤー: 同じ傾き角度(ux・uy・p)を縁のハイライト位置・奥行きの影にも伝える。
var shadowX = ux * p * 16;
var shadowY = 6 + uy * p * 12;
var shadowBlur = 14 + p * 22;
btn.style.boxShadow = shadowX.toFixed(1) + 'px ' + shadowY.toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px rgba(23,20,26,' + (0.2 + p * 0.3).toFixed(2) + ')';
btn.style.setProperty('--hl-x', (50 + ux * 38).toFixed(1) + '%');
btn.style.setProperty('--hl-y', (50 + uy * 38).toFixed(1) + '%');
btn.style.setProperty('--hl-o', (0.12 + p * 0.5).toFixed(2));
var angleDeg = Math.atan2(dy, dx) * 180 / Math.PI;
badge.textContent = '角度値 ' + Math.round(rotY) + '°';
void angleDeg;
}
計測値: 累積面積 13.2%、平均強度 52.4、動いたフレーム 18/23。
04距離値が遠いほどぼやける周囲のボタン
それぞれのボタンを固定半径だけで測るのではなく、まず全ボタン同士の距離を順位付けします。最も近い1つは必ず順位0になり、ぼかしは「生の近さ」と「順位」のうち不利なほうで決まるため、2つが接近していても最も近い1つだけが必ず鮮明に残ります。

<main class="stage">
<div class="bf is-demo" id="bf">
<div class="bf__row" id="bfRow">
<button class="bf__btn" type="button" data-i="0">新着</button>
<button class="bf__btn" type="button" data-i="1">人気</button>
<button class="bf__btn" type="button" data-i="2">セール</button>
<button class="bf__btn" type="button" data-i="3">おすすめ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 버튼 여러 개의 중심 좌표를 각각 재고, 커서까지 거리로 정렬(sort)해 순위를 구한다.
// 가장 가까운(순위 0) 버튼만 흐림 값이 0 으로 남고 나머지는 순위·거리 값을 섞어 흐려진다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-paper);
.bf {
display: grid;
gap: $sp-4;
justify-items: center;
}
.bf__row {
display: flex;
gap: $sp-3;
padding: $sp-8;
flex-wrap: wrap;
justify-content: center;
}
.bf__btn {
border: 0;
cursor: pointer;
padding: $sp-3 $sp-5;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-size: 14px;
font-weight: 700;
will-change: filter;
}
.bf__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
@media (max-width: 340px) {
.bf__row { padding: $sp-4; gap: $sp-2; }
.bf__btn { padding: $sp-2 $sp-3; font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.bf__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bf {
display: grid;
gap: 16px;
justify-items: center;
}
.bf__row {
display: flex;
gap: 12px;
padding: 32px;
flex-wrap: wrap;
justify-content: center;
}
.bf__btn {
border: 0;
cursor: pointer;
padding: 12px 20px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 14px;
font-weight: 700;
will-change: filter;
}
.bf__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@media (max-width: 340px) {
.bf__row {
padding: 16px;
gap: 8px;
}
.bf__btn {
padding: 8px 12px;
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.bf__btn {
transition: none;
}
}(function () {
var PROX_RADIUS_PX = 220;
var BF_BLUR_MAX_PX = 6;
var LERP = 0.2;
var root = document.getElementById('bf');
var row = document.getElementById('bfRow');
var btns = Array.prototype.slice.call(row.querySelectorAll('.bf__btn'));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetBlur = btns.map(function () { return BF_BLUR_MAX_PX; });
var curBlur = btns.map(function () { return BF_BLUR_MAX_PX; });
var hasPointer = false;
function centersOf() {
return btns.map(function (b) {
var r = b.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2];
});
}
function updateTargets(px, py) {
var centers = centersOf();
var dists = centers.map(function (c) { return Math.hypot(px - c[0], py - c[1]); });
// 거리 정렬: 가장 가까운 버튼의 순위(rank)를 구해 순위가 낮을수록(가까울수록) 흐림을 줄인다.
var order = dists.map(function (d, i) { return i; }).sort(function (a, b) { return dists[a] - dists[b]; });
order.forEach(function (idx, rank) {
var near = clamp(1 - dists[idx] / PROX_RADIUS_PX, 0, 1);
var byRank = rank === 0 ? 0 : Math.min(1, rank / (btns.length - 1));
var mix = Math.max(byRank, 1 - near);
targetBlur[idx] = mix * BF_BLUR_MAX_PX;
});
}
function render() {
btns.forEach(function (b, i) {
curBlur[i] += (targetBlur[i] - curBlur[i]) * LERP;
// 계산 레이어: 같은 흐림 값(curBlur)을 채도·명도에도 전파한다 — 멀수록 흐리고 탁하게.
var t = clamp(curBlur[i] / BF_BLUR_MAX_PX, 0, 1);
var sat = (1 - t * 0.55).toFixed(2);
var bri = (1 - t * 0.24).toFixed(2);
b.style.filter = 'blur(' + curBlur[i].toFixed(2) + 'px) saturate(' + sat + ') brightness(' + bri + ')';
// 계산 레이어: 흐림이 풀릴수록(근접) 같은 값을 후광 그림자 반경·불투명도에도 전파한다.
var near = 1 - t;
var spread = (near * 22).toFixed(1);
var glowA = (near * 0.5).toFixed(2);
b.style.boxShadow = '0 0 ' + spread + 'px ' + spread + 'px rgba(10,79,232,' + glowA + ')';
});
}
function syntheticPoint(t) {
var centers = centersOf();
var rowRect = row.getBoundingClientRect();
var idx = Math.floor((t * 0.6) % centers.length);
var target = centers[idx] || [rowRect.left + rowRect.width / 2, rowRect.top + rowRect.height / 2];
var wobble = Math.sin(t * Math.PI) * 30;
return [target[0] + wobble, target[1]];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var p = syntheticPoint(t);
updateTargets(p[0], p[1]);
}
render();
requestAnimationFrame(loop);
}
row.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
updateTargets(e.clientX, e.clientY);
if (reduce) { curBlur = targetBlur.slice(); render(); }
});
row.addEventListener('pointerleave', function () {
hasPointer = false;
targetBlur = btns.map(function () { return BF_BLUR_MAX_PX * 0.4; });
if (reduce) { curBlur = targetBlur.slice(); render(); }
});
btns.forEach(function (b, i) {
b.addEventListener('focus', function () {
hasPointer = true;
targetBlur = btns.map(function (_, j) { return j === i ? 0 : BF_BLUR_MAX_PX; });
if (reduce) { curBlur = targetBlur.slice(); render(); }
});
b.addEventListener('blur', function () {
hasPointer = false;
targetBlur = btns.map(function () { return BF_BLUR_MAX_PX * 0.4; });
if (reduce) { curBlur = targetBlur.slice(); render(); }
});
});
if (reduce) {
targetBlur = btns.map(function () { return BF_BLUR_MAX_PX * 0.4; });
curBlur = targetBlur.slice();
render();
} else {
requestAnimationFrame(loop);
}
})();// ProximityBlurFocusButtons — vanilla 판(run/408/demo/04-btn-proximity-blur-focus-sharpen/index.html) 의 React 포트.
// 버튼 4개의 중심 좌표를 각각 재고 커서까지 거리로 정렬(sort)해 순위를 구한다 —
// 가장 가까운(순위 0) 버튼만 흐림 값이 0 으로 남고 나머지는 순위·거리 값을 섞어 흐려진다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityBlurFocusButtons.module.scss";
type Box<T> = { current: T };
const PROX_RADIUS_PX = 220;
const BF_BLUR_MAX_PX = 6;
const LERP = 0.2;
const LABELS = ["신규", "인기", "할인", "추천"];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function ProximityBlurFocusButtons() {
const rowRef = useRef<HTMLDivElement | null>(null);
const btnRefs = useRef<(HTMLButtonElement | null)[]>([]) as Box<(HTMLButtonElement | null)[]>;
const targetBlurRef = useRef<number[]>(LABELS.map(() => BF_BLUR_MAX_PX)) as Box<number[]>;
const curBlurRef = useRef<number[]>(LABELS.map(() => BF_BLUR_MAX_PX)) as Box<number[]>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function centersOf(): [number, number][] {
return btnRefs.current.map((b) => {
if (!b) return [0, 0];
const r = b.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2];
});
}
function updateTargets(px: number, py: number): void {
const centers = centersOf();
const dists = centers.map((c) => Math.hypot(px - c[0], py - c[1]));
const order = dists.map((_, i) => i).sort((a, b) => dists[a] - dists[b]);
order.forEach((idx, rank) => {
const near = clamp(1 - dists[idx] / PROX_RADIUS_PX, 0, 1);
const byRank = rank === 0 ? 0 : Math.min(1, rank / (LABELS.length - 1));
const mix = Math.max(byRank, 1 - near);
targetBlurRef.current[idx] = mix * BF_BLUR_MAX_PX;
});
}
function render(): void {
LABELS.forEach((_, i) => {
const b = btnRefs.current[i];
if (!b) return;
curBlurRef.current[i] += (targetBlurRef.current[i] - curBlurRef.current[i]) * LERP;
// 계산 레이어: 같은 흐림 값(curBlur)을 채도·명도·후광 그림자에도 전파한다 — 멀수록 흐리고 탁하게.
const t = clamp(curBlurRef.current[i] / BF_BLUR_MAX_PX, 0, 1);
const sat = (1 - t * 0.55).toFixed(2);
const bri = (1 - t * 0.24).toFixed(2);
b.style.filter = "blur(" + curBlurRef.current[i].toFixed(2) + "px) saturate(" + sat + ") brightness(" + bri + ")";
const near = 1 - t;
const spread = (near * 22).toFixed(1);
const glowA = (near * 0.5).toFixed(2);
b.style.boxShadow = "0 0 " + spread + "px " + spread + "px rgba(10,79,232," + glowA + ")";
});
}
function syntheticPoint(t: number): [number, number] {
const centers = centersOf();
const row = rowRef.current;
const rowRect = row ? row.getBoundingClientRect() : { left: 0, width: 0, top: 0 };
const idx = Math.floor((t * 0.6) % centers.length);
const target = centers[idx] || [rowRect.left + rowRect.width / 2, rowRect.top];
const wobble = Math.sin(t * Math.PI) * 30;
return [target[0] + wobble, target[1]];
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
targetBlurRef.current = LABELS.map(() => BF_BLUR_MAX_PX * 0.4);
curBlurRef.current = targetBlurRef.current.slice();
render();
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const p = syntheticPoint(t);
updateTargets(p[0], p[1]);
}
render();
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
updateTargets(event.clientX, event.clientY);
if (reduce) { curBlurRef.current = targetBlurRef.current.slice(); render(); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetBlurRef.current = LABELS.map(() => BF_BLUR_MAX_PX * 0.4);
if (reduce) { curBlurRef.current = targetBlurRef.current.slice(); render(); }
}
function onFocusIdx(i: number): void {
hasPointerRef.current = true;
targetBlurRef.current = LABELS.map((_, j) => (j === i ? 0 : BF_BLUR_MAX_PX));
if (reduce) { curBlurRef.current = targetBlurRef.current.slice(); render(); }
}
function onBlurIdx(): void {
hasPointerRef.current = false;
targetBlurRef.current = LABELS.map(() => BF_BLUR_MAX_PX * 0.4);
if (reduce) { curBlurRef.current = targetBlurRef.current.slice(); render(); }
}
const cls = [styles.bf, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<div className={styles.row} ref={rowRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
{LABELS.map((label, i) => (
<button
key={label}
className={styles.btn}
type="button"
ref={(el: HTMLButtonElement | null) => { btnRefs.current[i] = el; }}
onFocus={() => onFocusIdx(i)}
onBlur={onBlurIdx}
>
{label}
</button>
))}
</div>
</div>
</main>
);
}04 · 距離値が遠いほどぼやける周囲のボタン — 計測 累積面積 18.241% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function centersOf() {
return btns.map(function (b) {
var r = b.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2];
});
}
function updateTargets(px, py) {
var centers = centersOf();
var dists = centers.map(function (c) { return Math.hypot(px - c[0], py - c[1]); });
// 距離ソート: 最も近いボタンの順位(rank)を求め、順位が低い(近い)ほどぼかしを減らす。
var order = dists.map(function (d, i) { return i; }).sort(function (a, b) { return dists[a] - dists[b]; });
order.forEach(function (idx, rank) {
var near = clamp(1 - dists[idx] / PROX_RADIUS_PX, 0, 1);
var byRank = rank === 0 ? 0 : Math.min(1, rank / (btns.length - 1));
var mix = Math.max(byRank, 1 - near);
targetBlur[idx] = mix * BF_BLUR_MAX_PX;
});
}
計測値: 累積面積 18.2%、平均強度 19.6、動いたフレーム 16/23。
05距離値が色の補間地点になるボタン
ここでは距離比率をサイズや角度ではなく、二つの固定RGB値を混ぜる補間点 t として使っています。ボタン自身の色は background-color の transition ではなく線形補間の結果で、同じ補間色がそのままボタン周囲のリングとカード全体の背景にも使い回されます。

<main class="stage">
<div class="hu is-demo" id="hu">
<p class="hu__badge" id="huBadge">補間地点 0%</p>
<div class="hu__scene" id="huScene">
<button class="hu__btn" id="huBtn" type="button">購読する</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거리 값을 두 색상 값 사이의 보간 지점(0~1)으로 쓴다. 채널(R·G·B)마다
// 선형 보간해 커서가 가까워질수록 버튼 색이 강조 색 쪽으로 옮겨간다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-ink);
.hu {
display: grid;
gap: $sp-4;
justify-items: center;
}
.hu__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.hu__scene {
padding: $sp-10;
will-change: background;
}
.hu__btn {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
will-change: background, box-shadow;
}
.hu__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
@media (max-width: 340px) {
.hu__scene { padding: $sp-6; }
.hu__btn { padding: $sp-3 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.hu__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hu {
display: grid;
gap: 16px;
justify-items: center;
}
.hu__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.hu__scene {
padding: 40px;
will-change: background;
}
.hu__btn {
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
will-change: background, box-shadow;
}
.hu__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
@media (max-width: 340px) {
.hu__scene {
padding: 24px;
}
.hu__btn {
padding: 12px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.hu__btn {
transition: none;
}
}(function () {
var PROX_RADIUS_PX = 170;
var LERP = 0.17;
// 두 색상 값 — 거리 값이 이 사이를 보간하는 지점이 된다.
var COLOR_FAR = [10, 79, 232]; // $subject-blue-deep
var COLOR_NEAR = [255, 122, 26]; // 강조 색
var root = document.getElementById('hu');
var scene = document.getElementById('huScene');
var btn = document.getElementById('huBtn');
var badge = document.getElementById('huBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetP = 0;
var curP = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
// 두 색상 값 보간 — 채널마다 선형 보간(계산 한 겹, 속성 트윈이 아니다)
function lerpColor(c1, c2, t) {
return [
Math.round(c1[0] + (c2[0] - c1[0]) * t),
Math.round(c1[1] + (c2[1] - c1[1]) * t),
Math.round(c1[2] + (c2[2] - c1[2]) * t)
];
}
function render(p) {
var c = lerpColor(COLOR_FAR, COLOR_NEAR, p);
btn.style.background = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
// 계산 레이어: 같은 보간 색을 테두리(퍼지는 링)·카드 전체 배경 톤에도 전파한다.
var ringPx = (6 + p * 20).toFixed(1);
var ringA = (0.14 + p * 0.26).toFixed(2);
btn.style.boxShadow = '0 0 0 ' + ringPx + 'px rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + ringA + ')';
scene.style.background = 'radial-gradient(circle, rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + (0.1 + p * 0.22).toFixed(2) + '), transparent 72%)';
badge.textContent = '補間地点 ' + Math.round(p * 100) + '%';
}
function syntheticOffset(t) {
var ang = t * 1.5 + 0.3;
var rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curP += (targetP - curP) * LERP;
render(curP);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curP = targetP; render(curP); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; render(1); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
if (reduce) {
render(0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityHueShiftButton — vanilla 판(run/408/demo/05-btn-proximity-hue-shift-approach/index.html) 의 React 포트.
// 거리 값을 두 색상 값 사이의 보간 지점(0~1)으로 쓴다. 채널(R·G·B)마다 선형 보간해
// 커서가 가까워질수록 버튼 색이 강조 색 쪽으로 옮겨간다 — 계산 한 겹, 속성 트윈이 아니다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityHueShiftButton.module.scss";
type Box<T> = { current: T };
type RGB = [number, number, number];
const PROX_RADIUS_PX = 170;
const LERP = 0.17;
const COLOR_FAR: RGB = [10, 79, 232];
const COLOR_NEAR: RGB = [255, 122, 26];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
return [
Math.round(c1[0] + (c2[0] - c1[0]) * t),
Math.round(c1[1] + (c2[1] - c1[1]) * t),
Math.round(c1[2] + (c2[2] - c1[2]) * t),
];
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.5 + 0.3;
const rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityHueShiftButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const btn = btnRef.current;
const badge = badgeRef.current;
const scene = sceneRef.current;
if (!btn || !badge || !scene) return;
const c = lerpColor(COLOR_FAR, COLOR_NEAR, p);
btn.style.background = "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
// 계산 레이어: 같은 보간 색(c)을 버튼 링·장면 배경 전체에도 전파한다.
const ringPx = (6 + p * 20).toFixed(1);
const ringA = (0.14 + p * 0.26).toFixed(2);
btn.style.boxShadow = "0 0 0 " + ringPx + "px rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + ringA + ")";
scene.style.background = "radial-gradient(circle, rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + (0.1 + p * 0.22).toFixed(2) + "), transparent 72%)";
badge.textContent = "보간 지점 " + Math.round(p * 100) + "%";
}
function distanceToButton(x: number, y: number): number {
const btn = btnRef.current;
if (!btn) return PROX_RADIUS_PX;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
render(curPRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curPRef.current = targetPRef.current; render(curPRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
const cls = [styles.hu, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>보간 지점 0%</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>구독하기</button>
</div>
</div>
</main>
);
}05 · 距離値が色の補間地点になるボタン — 計測 累積面積 14.462% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function lerpColor(c1, c2, t) {
return [
Math.round(c1[0] + (c2[0] - c1[0]) * t),
Math.round(c1[1] + (c2[1] - c1[1]) * t),
Math.round(c1[2] + (c2[2] - c1[2]) * t)
];
}
function render(p) {
var c = lerpColor(COLOR_FAR, COLOR_NEAR, p);
btn.style.background = 'rgb(' + c[0] + ',' + c[1] + ',' + c[2] + ')';
// 計算レイヤー: 同じ補間色を縁(広がるリング)・カード全体の背景トーンにも伝える。
var ringPx = (6 + p * 20).toFixed(1);
var ringA = (0.14 + p * 0.26).toFixed(2);
btn.style.boxShadow = '0 0 0 ' + ringPx + 'px rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + ringA + ')';
scene.style.background = 'radial-gradient(circle, rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + (0.1 + p * 0.22).toFixed(2) + '), transparent 72%)';
badge.textContent = '補間地点 ' + Math.round(p * 100) + '%';
}
計測値: 累積面積 14.5%、平均強度 46.7、動いたフレーム 20/23。
06距離値の分だけ枠線が描かれるボタン
枠線は border ではなくSVGの rect で、実際の周囲長を getTotalLength() で一度だけ測っています。だから描画に使う長さは推測値ではなく実測値です。strokeDashoffset が同じ距離比率を読み取り、カーソルが近づくにつれてその実測の長さ分だけ線を描き進めます。

<main class="stage">
<div class="bt is-demo" id="bt">
<p class="bt__badge" id="btBadge">縁の値 0%</p>
<div class="bt__scene" id="btScene">
<div class="bt__wrap" id="btWrap">
<svg class="bt__ring" id="btRing" viewBox="0 0 200 64" preserveAspectRatio="none" aria-hidden="true">
<rect id="btRect" x="2" y="2" width="196" height="60" rx="12" ry="12"></rect>
</svg>
<button class="bt__btn" id="btBtn" type="button">もっと見る</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: SVG rect 의 실제 둘레 길이(getTotalLength)를 재서 stroke-dasharray 에 쓰고,
// 거리 값이 줄어드는 만큼 stroke-dashoffset 을 그 길이만큼 되돌려 테두리가 한 바퀴 그려지게 한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.bt {
display: grid;
gap: $sp-4;
justify-items: center;
}
.bt__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.bt__scene {
padding: $sp-10;
}
.bt__wrap {
position: relative;
width: 200px;
height: 64px;
--p: 0;
}
.bt__wrap::before {
content: '';
position: absolute;
inset: -16px;
border-radius: calc($r-control + 16px);
background: radial-gradient(circle, rgba($subject-blue-deep, .5), transparent 70%);
filter: blur(calc(4px + var(--p) * 18px));
opacity: var(--p);
pointer-events: none;
z-index: 0;
}
.bt__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
z-index: 1;
rect {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 3;
will-change: stroke-dashoffset;
}
}
.bt__btn {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
border: 0;
cursor: pointer;
border-radius: $r-control;
background: linear-gradient(calc(var(--p) * 360deg), $color 0%, $subject-blue-deep calc(15% + var(--p) * 55%), $color 100%);
color: $subject-cream;
font-size: 15px;
font-weight: 700;
}
.bt__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
@media (max-width: 340px) {
.bt__scene { padding: $sp-6; }
.bt__wrap { width: 168px; height: 52px; }
}
@media (prefers-reduced-motion: reduce) {
.bt__ring rect { 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);
}
.bt {
display: grid;
gap: 16px;
justify-items: center;
}
.bt__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.bt__scene {
padding: 40px;
}
.bt__wrap {
position: relative;
width: 200px;
height: 64px;
--p: 0;
}
.bt__wrap::before {
content: "";
position: absolute;
inset: -16px;
border-radius: 28px;
background: radial-gradient(circle, rgba(10, 79, 232, 0.5), transparent 70%);
filter: blur(calc(4px + var(--p) * 18px));
opacity: var(--p);
pointer-events: none;
z-index: 0;
}
.bt__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
z-index: 1;
}
.bt__ring rect {
fill: none;
stroke: #0a4fe8;
stroke-width: 3;
will-change: stroke-dashoffset;
}
.bt__btn {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
border: 0;
cursor: pointer;
border-radius: 12px;
background: linear-gradient(calc(var(--p) * 360deg), #17141a 0%, #0a4fe8 calc(15% + var(--p) * 55%), #17141a 100%);
color: #fff7e6;
font-size: 15px;
font-weight: 700;
}
.bt__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
@media (max-width: 340px) {
.bt__scene {
padding: 24px;
}
.bt__wrap {
width: 168px;
height: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
.bt__ring rect {
transition: none;
}
}(function () {
var PROX_RADIUS_PX = 170;
var LERP = 0.18;
var root = document.getElementById('bt');
var scene = document.getElementById('btScene');
var wrap = document.getElementById('btWrap');
var btn = document.getElementById('btBtn');
var rect = document.getElementById('btRect');
var badge = document.getElementById('btBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 실제 도형 둘레 길이(px) — 짐작값이 아니라 SVG 가 직접 잰 값이다.
var totalLength = rect.getTotalLength();
rect.style.strokeDasharray = String(totalLength);
var targetP = 0;
var curP = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = wrap.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
function render(p) {
var offset = (1 - p) * totalLength;
rect.style.strokeDashoffset = String(offset);
rect.style.opacity = String(0.2 + p * 0.8);
badge.textContent = '縁の値 ' + Math.round(p * 100) + '%';
// 계산 레이어: 같은 진행률(p)을 링 주변 글로우·버튼 배경 하이라이트 각(그라디언트 각)에도 전파한다.
wrap.style.setProperty('--p', p.toFixed(3));
}
function syntheticOffset(t) {
var ang = t * 1.6;
var rad = 120 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curP += (targetP - curP) * LERP;
render(curP);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curP = targetP; render(curP); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; render(1); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
if (reduce) {
render(0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityBorderTraceButton — vanilla 판(run/408/demo/06-btn-proximity-border-trace-draw/index.html) 의 React 포트.
// SVG rect 의 실제 둘레 길이(getTotalLength)를 재서 stroke-dasharray 에 쓰고, 거리 값이 줄어드는
// 만큼 stroke-dashoffset 을 그 길이만큼 되돌려 테두리가 한 바퀴 그려지게 한다 — 짐작값이 아니라 실측값이다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityBorderTraceButton.module.scss";
type Box<T> = { current: T };
const PROX_RADIUS_PX = 170;
const LERP = 0.18;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.6;
const rad = 120 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityBorderTraceButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const rectRef = useRef<SVGRectElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const totalLengthRef = useRef<number>(0) as Box<number>;
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const rect = rectRef.current;
const badge = badgeRef.current;
const wrap = wrapRef.current;
if (!rect || !badge || !wrap) return;
const offset = (1 - p) * totalLengthRef.current;
rect.style.strokeDashoffset = String(offset);
rect.style.opacity = String(0.2 + p * 0.8);
// 계산 레이어: 같은 진행률(p)을 링 글로우·버튼 배경 그라디언트 각도에도 전파한다.
wrap.style.setProperty("--p", p.toFixed(3));
badge.textContent = "테두리 값 " + Math.round(p * 100) + "%";
}
function distanceToButton(x: number, y: number): number {
const wrap = wrapRef.current;
if (!wrap) return PROX_RADIUS_PX;
const r = wrap.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
const rect = rectRef.current;
if (!rect) return;
// 실제 도형 둘레 길이(px) — 짐작값이 아니라 SVG 가 직접 잰 값이다.
totalLengthRef.current = rect.getTotalLength();
rect.style.strokeDasharray = String(totalLengthRef.current);
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
render(curPRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curPRef.current = targetPRef.current; render(curPRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
const cls = [styles.bt, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>테두리 값 0%</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<div className={styles.wrap} ref={wrapRef}>
<svg className={styles.ring} viewBox="0 0 200 64" preserveAspectRatio="none" aria-hidden="true">
<rect ref={rectRef} x={2} y={2} width={196} height={60} rx={12} ry={12} />
</svg>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>더 보기</button>
</div>
</div>
</div>
</main>
);
}06 · 距離値の分だけ枠線が描かれるボタン — 計測 累積面積 14.921% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 実際の図形の周囲の長さ(px) — 推測値ではなく、SVGが直接測った値。
var totalLength = rect.getTotalLength();
rect.style.strokeDasharray = String(totalLength);
var targetP = 0;
var curP = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = wrap.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
function render(p) {
var offset = (1 - p) * totalLength;
rect.style.strokeDashoffset = String(offset);
rect.style.opacity = String(0.2 + p * 0.8);
badge.textContent = '縁の値 ' + Math.round(p * 100) + '%';
// 計算レイヤー: 同じ進行率(p)をリング周囲のグロー・ボタン背景のハイライト角(グラデーション角)にも伝える。
wrap.style.setProperty('--p', p.toFixed(3));
}
計測値: 累積面積 14.9%、平均強度 46.5、動いたフレーム 21/23。
07距離値の分だけ浮き上がる影のボタン
オフセットとぼかしは同じ距離値を読みますが独立していません。ぼかしはオフセットに固定倍率を掛けた値として定義されているため、カーソルがどれだけ近づいても影の見た目の関係は常に同じに保たれます。

<main class="stage">
<div class="sd is-demo" id="sd">
<p class="sd__badge" id="sdBadge">距離値 100%</p>
<div class="sd__scene" id="sdScene">
<button class="sd__btn" id="sdBtn" type="button">予約する</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거리 값이 줄어들수록 그림자의 오프셋(y)·번짐(blur)을 같은 값에서 함께 키운다.
// 두 속성이 한 값의 함수라서 그림자만 따로 흔들리지 않고 버튼이 통째로 떠오른 것처럼 보인다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-orange);
.sd {
display: grid;
gap: $sp-4;
justify-items: center;
}
.sd__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sd__scene {
padding: $sp-10;
}
.sd__btn {
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-ink;
font-size: 16px;
font-weight: 700;
will-change: transform, box-shadow;
}
.sd__btn:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 3px;
}
@media (max-width: 340px) {
.sd__scene { padding: $sp-6; }
.sd__btn { padding: $sp-3 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.sd__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);
}
.sd {
display: grid;
gap: 16px;
justify-items: center;
}
.sd__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sd__scene {
padding: 40px;
}
.sd__btn {
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
font-size: 16px;
font-weight: 700;
will-change: transform, box-shadow;
}
.sd__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
@media (max-width: 340px) {
.sd__scene {
padding: 24px;
}
.sd__btn {
padding: 12px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.sd__btn {
transition: none;
}
}(function () {
// PROX_SHADOW_LIFT_MAX_PX — 변형 2: 24 → 48 로 늘리면 그림자가 멀리 번져 버튼이 훨씬 높이 떠오른 것처럼 보인다.
var PROX_SHADOW_LIFT_MAX_PX = 24;
var PROX_RADIUS_PX = 170;
var LERP = 0.18;
var root = document.getElementById('sd');
var scene = document.getElementById('sdScene');
var btn = document.getElementById('sdBtn');
var badge = document.getElementById('sdBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetP = 0;
var curP = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
// 오프셋·번짐을 거리 함수로 — 둘 다 같은 값(p)에서 갈라져 나온다
function render(p) {
var offsetY = p * PROX_SHADOW_LIFT_MAX_PX;
var blur = offsetY * 1.7;
var lift = p * 8;
btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)';
btn.style.boxShadow = '0 ' + offsetY.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(23,20,26,' + (0.22 + p * 0.24).toFixed(2) + ')';
badge.textContent = '距離値 ' + Math.round((1 - p) * 100) + '%';
}
function syntheticOffset(t) {
var ang = t * 1.3 + 1.1;
var rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curP += (targetP - curP) * LERP;
render(curP);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curP = targetP; render(curP); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; render(1); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
if (reduce) {
render(0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityShadowLiftButton — vanilla 판(run/408/demo/07-btn-proximity-shadow-lift-depth/index.html) 의 React 포트.
// 그림자 오프셋(y)과 번짐(blur)을 같은 거리 값(p)의 함수로 갈라내 그림자만 따로 흔들리지 않고
// 버튼이 통째로 떠오른 것처럼 보이게 한다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityShadowLiftButton.module.scss";
type Box<T> = { current: T };
// PROX_SHADOW_LIFT_MAX_PX — 변형 2: 24 → 48 로 늘리면 그림자가 멀리 번져 버튼이 훨씬 높이 떠오른 것처럼 보인다.
const PROX_SHADOW_LIFT_MAX_PX = 24;
const PROX_RADIUS_PX = 170;
const LERP = 0.18;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.3 + 1.1;
const rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityShadowLiftButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const btn = btnRef.current;
const badge = badgeRef.current;
if (!btn || !badge) return;
const offsetY = p * PROX_SHADOW_LIFT_MAX_PX;
const blur = offsetY * 1.7;
const lift = p * 8;
btn.style.transform = "translateY(" + (-lift).toFixed(2) + "px)";
btn.style.boxShadow = "0 " + offsetY.toFixed(1) + "px " + blur.toFixed(1) + "px rgba(23,20,26," + (0.22 + p * 0.24).toFixed(2) + ")";
badge.textContent = "거리 값 " + Math.round((1 - p) * 100) + "%";
}
function distanceToButton(x: number, y: number): number {
const btn = btnRef.current;
if (!btn) return PROX_RADIUS_PX;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
render(curPRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curPRef.current = targetPRef.current; render(curPRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
const cls = [styles.sd, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>거리 값 100%</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>예약하기</button>
</div>
</div>
</main>
);
}07 · 距離値の分だけ浮き上がる影のボタン — 計測 累積面積 7.634% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
// オフセット・にじみを距離の関数として — どちらも同じ値(p)から分岐する
function render(p) {
var offsetY = p * PROX_SHADOW_LIFT_MAX_PX;
var blur = offsetY * 1.7;
var lift = p * 8;
btn.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)';
btn.style.boxShadow = '0 ' + offsetY.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(23,20,26,' + (0.22 + p * 0.24).toFixed(2) + ')';
badge.textContent = '距離値 ' + Math.round((1 - p) * 100) + '%';
}
計測値: 累積面積 7.6%、平均強度 62.1、動いたフレーム 21/23。
08距離値が文字間隔値として広がるボタン
距離比率はそのまま letterSpacing の em 値に書き込まれますが、重要なのはその後です。実際に文字が広がった後のラベルの描画幅を getBoundingClientRect() で測り直し、その実測値を下線とグロー(発光)のサイズに使っています。想定の式で幅を仮定してはいません。

<main class="stage">
<div class="ls is-demo" id="ls">
<p class="ls__badge" id="lsBadge">間隔値 0em</p>
<div class="ls__scene" id="lsScene">
<button class="ls__btn" id="lsBtn" type="button">
<span class="ls__label" id="lsLabel">詳しく見る</span>
<span class="ls__underline" id="lsUnderline" aria-hidden="true"></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거리 값(0~1)을 letter-spacing 값(em)으로 바로 매핑한다. 커서가 멀 때는
// 글자가 좁게 뭉쳐 있다가 가까워질수록 라벨 글자 사이가 넓게 벌어진다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-paper);
.ls {
display: grid;
gap: $sp-4;
justify-items: center;
}
.ls__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .06);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ls__scene {
padding: $sp-10;
}
.ls__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-size: 15px;
font-weight: 700;
will-change: filter;
--label-w: 0px;
--label-o: 0;
--underline-o: 0;
}
.ls__btn::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--label-w) + #{$sp-4});
height: 28px;
transform: translate(-50%, -50%);
border-radius: $r-pill;
background: rgba($subject-cream, .22);
opacity: var(--label-o);
pointer-events: none;
z-index: 0;
}
.ls__label {
position: relative;
z-index: 1;
display: inline-block;
will-change: letter-spacing;
}
.ls__underline {
position: absolute;
left: 50%;
bottom: $sp-2;
transform: translateX(-50%);
height: 2px;
width: 0;
background: $subject-cream;
opacity: var(--underline-o);
border-radius: $r-pill;
pointer-events: none;
z-index: 1;
}
.ls__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
@media (max-width: 340px) {
.ls__scene { padding: $sp-6; }
.ls__btn { padding: $sp-3 $sp-6; font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.ls__label { transition: none; }
}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);
}
.ls {
display: grid;
gap: 16px;
justify-items: center;
}
.ls__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ls__scene {
padding: 40px;
}
.ls__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 15px;
font-weight: 700;
will-change: filter;
--label-w: 0px;
--label-o: 0;
--underline-o: 0;
}
.ls__btn::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--label-w) + 16px);
height: 28px;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(255, 247, 230, 0.22);
opacity: var(--label-o);
pointer-events: none;
z-index: 0;
}
.ls__label {
position: relative;
z-index: 1;
display: inline-block;
will-change: letter-spacing;
}
.ls__underline {
position: absolute;
left: 50%;
bottom: 8px;
transform: translateX(-50%);
height: 2px;
width: 0;
background: #fff7e6;
opacity: var(--underline-o);
border-radius: 999px;
pointer-events: none;
z-index: 1;
}
.ls__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
@media (max-width: 340px) {
.ls__scene {
padding: 24px;
}
.ls__btn {
padding: 12px 24px;
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls__label {
transition: none;
}
}(function () {
var PROX_RADIUS_PX = 170;
var LS_MAX_EM = 0.22;
var LERP = 0.17;
var root = document.getElementById('ls');
var scene = document.getElementById('lsScene');
var btn = document.getElementById('lsBtn');
var label = document.getElementById('lsLabel');
var underline = document.getElementById('lsUnderline');
var badge = document.getElementById('lsBadge');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var targetP = 0;
var curP = 0;
var hasPointer = false;
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
// 거리 값을 글자 사이 간격 값으로 바꾼다 — 멀 때는 0, 가까워질수록 벌어진다
function render(p) {
var spacing = p * LS_MAX_EM;
label.style.letterSpacing = spacing.toFixed(3) + 'em';
btn.style.filter = 'brightness(' + (1 + p * 0.12).toFixed(3) + ')';
// 계산 레이어: 벌어진 실제 글자 폭(getBoundingClientRect)을 라벨 배경·밑줄 길이에 전파한다.
var w = label.getBoundingClientRect().width;
underline.style.width = w.toFixed(1) + 'px';
btn.style.setProperty('--underline-o', (0.2 + p * 0.6).toFixed(2));
btn.style.setProperty('--label-w', w.toFixed(1) + 'px');
btn.style.setProperty('--label-o', (0.12 + p * 0.4).toFixed(2));
// 계산 레이어: 같은 진행률(p)을 버튼 후광 그림자 반경·불투명도에도 전파한다.
var spread = (6 + p * 26).toFixed(1);
btn.style.boxShadow = '0 0 ' + spread + 'px ' + spread + 'px rgba(10,79,232,' + (0.16 + p * 0.42).toFixed(2) + ')';
badge.textContent = '間隔値 ' + spacing.toFixed(2) + 'em';
}
function syntheticOffset(t) {
var ang = t * 1.6 + 2.1;
var rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curP += (targetP - curP) * LERP;
render(curP);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curP = targetP; render(curP); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; render(1); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; render(0); }
});
if (reduce) {
render(0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityLetterSpacingButton — vanilla 판(run/408/demo/08-btn-proximity-letter-spacing-expand/index.html) 의 React 포트.
// 거리 값(0~1)을 글자 사이 간격 값(em)으로 바로 매핑한다. 커서가 멀 때는 글자가 좁게
// 뭉쳐 있다가 가까워질수록 라벨 글자 사이가 넓게 벌어진다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityLetterSpacingButton.module.scss";
type Box<T> = { current: T };
const PROX_RADIUS_PX = 170;
const LS_MAX_EM = 0.22;
const LERP = 0.17;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.6 + 2.1;
const rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityLetterSpacingButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const underlineRef = useRef<HTMLSpanElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function render(p: number): void {
const btn = btnRef.current;
const label = labelRef.current;
const underline = underlineRef.current;
const badge = badgeRef.current;
if (!btn || !label || !underline || !badge) return;
const spacing = p * LS_MAX_EM;
label.style.letterSpacing = spacing.toFixed(3) + "em";
btn.style.filter = "brightness(" + (1 + p * 0.12).toFixed(3) + ")";
// 계산 레이어: 벌어진 실제 글자 폭(getBoundingClientRect)을 라벨 배경·밑줄 길이·후광 그림자에 전파한다.
const w = label.getBoundingClientRect().width;
underline.style.width = w.toFixed(1) + "px";
btn.style.setProperty("--underline-o", (0.2 + p * 0.6).toFixed(2));
btn.style.setProperty("--label-w", w.toFixed(1) + "px");
btn.style.setProperty("--label-o", (0.12 + p * 0.4).toFixed(2));
const spread = (6 + p * 26).toFixed(1);
btn.style.boxShadow = "0 0 " + spread + "px " + spread + "px rgba(10,79,232," + (0.16 + p * 0.42).toFixed(2) + ")";
badge.textContent = "간격 값 " + spacing.toFixed(2) + "em";
}
function distanceToButton(x: number, y: number): number {
const btn = btnRef.current;
if (!btn) return PROX_RADIUS_PX;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
if (reduce) {
render(0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
render(curPRef.current);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curPRef.current = targetPRef.current; render(curPRef.current); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; render(1); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; render(0); }
}
const cls = [styles.ls, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>간격 값 0em</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>
<span className={styles.label} ref={labelRef}>더 알아보기</span>
<span className={styles.underline} ref={underlineRef} aria-hidden="true" />
</button>
</div>
</div>
</main>
);
}08 · 距離値が文字間隔値として広がるボタン — 計測 累積面積 26.463% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
// 距離値を文字間隔値に変換する — 遠いときは0、近づくほど広がる
function render(p) {
var spacing = p * LS_MAX_EM;
label.style.letterSpacing = spacing.toFixed(3) + 'em';
btn.style.filter = 'brightness(' + (1 + p * 0.12).toFixed(3) + ')';
// 計算レイヤー: 広がった実際の文字幅(getBoundingClientRect)をラベル背景・下線の長さに伝える。
var w = label.getBoundingClientRect().width;
underline.style.width = w.toFixed(1) + 'px';
btn.style.setProperty('--underline-o', (0.2 + p * 0.6).toFixed(2));
btn.style.setProperty('--label-w', w.toFixed(1) + 'px');
btn.style.setProperty('--label-o', (0.12 + p * 0.4).toFixed(2));
// 計算レイヤー: 同じ進行率(p)をボタンの後光の影の半径・不透明度にも伝える。
var spread = (6 + p * 26).toFixed(1);
btn.style.boxShadow = '0 0 ' + spread + 'px ' + spread + 'px rgba(10,79,232,' + (0.16 + p * 0.42).toFixed(2) + ')';
badge.textContent = '間隔値 ' + spacing.toFixed(2) + 'em';
}
計測値: 累積面積 26.5%、平均強度 37.6、動いたフレーム 21/23。
09距離値の分だけ粒子が集まるボタン
見えている粒子の数は整数で一つずつ切り替わるのではなく、p * PROX_PARTICLE_MAX_COUNT という連続した比率です。各粒子は自分の番号がその比率を下回ったときにだけ描かれるため、群れは点滅せず輪郭が連続的に埋まっていきます。角度にも半径にも粒子ごとのノイズと位相のずれがあり、同じ距離値でも二つとして同じ軌道を描く粒子はありません。

<main class="stage">
<div class="pd is-demo" id="pd">
<p class="pd__badge" id="pdBadge">粒子値 0/6</p>
<div class="pd__scene" id="pdScene">
<div class="pd__wrap" id="pdWrap">
<canvas class="pd__canvas" id="pdCanvas" width="280" height="150" aria-hidden="true"></canvas>
<button class="pd__btn" id="pdBtn" type="button">登録する</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 거리 값이 줄어드는 동안 캔버스에 그리는 입자 개수 값(count)과 궤도 반경(density) 값이
// 함께 바뀐다. 입자마다 고정된 노이즈 오프셋(각도·주기 지터)을 심어 기계적으로 안 보이게 한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-ink);
.pd {
display: grid;
gap: $sp-2;
justify-items: center;
}
.pd__badge {
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .1);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pd__scene {
padding: $sp-3;
}
.pd__wrap {
position: relative;
width: 340px;
height: 182px;
display: grid;
place-items: center;
--sw-p: 0;
}
.pd__wrap::before {
content: '';
position: absolute;
inset: -8px;
border-radius: $r-control;
background: radial-gradient(circle, rgba($subject-blue-deep, .5), transparent 60%);
transform: scale(calc(0.65 + var(--sw-p) * 0.35));
opacity: calc(var(--sw-p) * 0.4);
pointer-events: none;
z-index: 0;
}
.pd__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.pd__btn {
position: relative;
border: 0;
cursor: pointer;
padding: $sp-4 $sp-8;
border-radius: $r-control;
background: $color;
color: $subject-cream;
font-size: 16px;
font-weight: 700;
}
.pd__btn:focus-visible {
outline: 2px solid $subject-cream;
outline-offset: 3px;
}
@media (max-width: 340px) {
.pd__scene { padding: $sp-2; }
.pd__wrap { width: 100%; height: 112px; }
.pd__btn { padding: $sp-3 $sp-6; font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.pd__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pd {
display: grid;
gap: 8px;
justify-items: center;
}
.pd__badge {
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.pd__scene {
padding: 12px;
}
.pd__wrap {
position: relative;
width: 340px;
height: 182px;
display: grid;
place-items: center;
--sw-p: 0;
}
.pd__wrap::before {
content: "";
position: absolute;
inset: -8px;
border-radius: 12px;
background: radial-gradient(circle, rgba(10, 79, 232, 0.5), transparent 60%);
transform: scale(calc(0.65 + var(--sw-p) * 0.35));
opacity: calc(var(--sw-p) * 0.4);
pointer-events: none;
z-index: 0;
}
.pd__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.pd__btn {
position: relative;
border: 0;
cursor: pointer;
padding: 16px 32px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 16px;
font-weight: 700;
}
.pd__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
@media (max-width: 340px) {
.pd__scene {
padding: 8px;
}
.pd__wrap {
width: 100%;
height: 112px;
}
.pd__btn {
padding: 12px 24px;
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.pd__btn {
transition: none;
}
}(function () {
// PROX_PARTICLE_MAX_COUNT — 변형 3: 6 → 14 로 늘리면 입자가 촘촘히 모여 붐비듯 화려해진다.
var PROX_PARTICLE_MAX_COUNT = 6;
var PROX_RADIUS_PX = 170;
var LERP = 0.16;
var RADIUS_FAR_PX = 92;
var RADIUS_NEAR_PX = 46;
var root = document.getElementById('pd');
var scene = document.getElementById('pdScene');
var wrap = document.getElementById('pdWrap');
var btn = document.getElementById('pdBtn');
var canvas = document.getElementById('pdCanvas');
var badge = document.getElementById('pdBadge');
var ctx = canvas.getContext('2d');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 캔버스 표시 크기 = 스테이지가 실제로 내준 크기(getBoundingClientRect) — 고정 px 캡을 두지 않는다.
var radiusScale = 1;
function resizeCanvas() {
var r = wrap.getBoundingClientRect();
var w = Math.max(1, Math.round(r.width));
var h = Math.max(1, Math.round(r.height));
if (canvas.width !== w) { canvas.width = w; }
if (canvas.height !== h) { canvas.height = h; }
radiusScale = Math.min(w / 280, h / 150);
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// 입자마다 글자별 노이즈 오프셋과 같은 계열 — 각도·주기에 고정 지터를 심어 기계적으로 안 보이게 한다
var particles = [];
for (var i = 0; i < PROX_PARTICLE_MAX_COUNT; i += 1) {
particles.push({
baseAngle: (i / PROX_PARTICLE_MAX_COUNT) * Math.PI * 2,
noise: ((i * 137) % 23) / 23 - 0.5,
phase: (i * 71) % 100 / 100 * Math.PI * 2,
size: 2.4 + ((i * 53) % 7) / 7 * 1.6
});
}
var targetP = 0;
var curP = 0;
var hasPointer = false;
var TRAIL_STEPS = 7;
var trailHistory = [];
function distanceToButton(x, y) {
var r = btn.getBoundingClientRect();
var cx = r.left + r.width / 2;
var cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
function draw(p, t) {
var w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
var cx = w / 2, cy = h / 2;
var count = p * PROX_PARTICLE_MAX_COUNT;
var radius = (RADIUS_FAR_PX + (RADIUS_NEAR_PX - RADIUS_FAR_PX) * p) * radiusScale;
var positions = [];
particles.forEach(function (pt, i) {
var visible = clamp(count - i, 0, 1);
if (visible <= 0) { return; }
var ang = pt.baseAngle + t * 0.5 + pt.noise * Math.sin(t * 1.3 + pt.phase) * 0.6;
var rad = radius + pt.noise * 10 * radiusScale * Math.sin(t * 0.9 + pt.phase);
var x = cx + Math.cos(ang) * rad;
var y = cy + Math.sin(ang) * rad * 0.72;
positions.push({ x: x, y: y, size: pt.size * radiusScale * 1.3, alpha: (0.4 + p * 0.6) * visible });
});
// 계산 레이어: 최근 몇 프레임의 입자 위치를 옅은 잔상으로 같이 그려 궤적이 남게 한다(전체 캔버스를 덮지 않는다).
trailHistory.push(positions);
if (trailHistory.length > TRAIL_STEPS) { trailHistory.shift(); }
trailHistory.forEach(function (frame, hi) {
var age = trailHistory.length - hi;
var fade = 1 - age / (TRAIL_STEPS + 1);
frame.forEach(function (pos) {
ctx.globalAlpha = pos.alpha * fade;
ctx.beginPath();
ctx.arc(pos.x, pos.y, pos.size * (0.8 + fade * 0.9), 0, Math.PI * 2);
ctx.fillStyle = '#0a4fe8';
ctx.fill();
});
});
positions.forEach(function (pos) {
ctx.globalAlpha = pos.alpha;
ctx.beginPath();
ctx.arc(pos.x, pos.y, pos.size, 0, Math.PI * 2);
ctx.fillStyle = '#fff7e6';
ctx.fill();
});
ctx.globalAlpha = 1;
// 계산 레이어: 같은 진행률(p)을 입자 뒤 글로우 크기·불투명도에도 전파한다 — 배경과 겹치지 않는 색.
wrap.style.setProperty('--sw-p', p.toFixed(3));
badge.textContent = '粒子値 ' + Math.round(count) + '/' + PROX_PARTICLE_MAX_COUNT;
}
function syntheticOffset(t) {
var ang = t * 1.5;
var rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
var startTime = null;
function loop(now) {
if (startTime === null) { startTime = now; }
var t = (now - startTime) / 1000;
if (!hasPointer && !reduce) {
var off = syntheticOffset(t);
var d = Math.hypot(off[0], off[1]);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curP += (targetP - curP) * LERP;
draw(curP, t);
requestAnimationFrame(loop);
}
scene.addEventListener('pointermove', function (e) {
hasPointer = true;
root.classList.remove('is-demo');
var d = distanceToButton(e.clientX, e.clientY);
targetP = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curP = targetP; draw(curP, 0); }
});
scene.addEventListener('pointerleave', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; draw(0, 0); }
});
btn.addEventListener('focus', function () {
hasPointer = true;
targetP = 1;
if (reduce) { curP = 1; draw(1, 0); }
});
btn.addEventListener('blur', function () {
hasPointer = false;
targetP = 0;
if (reduce) { curP = 0; draw(0, 0); }
});
if (reduce) {
draw(0, 0);
} else {
requestAnimationFrame(loop);
}
})();// ProximityParticleSwarmButton — vanilla 판(run/408/demo/09-btn-proximity-particle-density-swarm/index.html) 의 React 포트.
// 거리 값이 줄어드는 동안 캔버스에 그리는 입자 개수 값(count)과 궤도 반경(radius) 값이 함께
// 바뀐다. 입자마다 고정된 노이즈 오프셋(각도·주기 지터)을 심어 기계적으로 안 보이게 한다.
import { useEffect, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./ProximityParticleSwarmButton.module.scss";
type Box<T> = { current: T };
type Particle = {
baseAngle: number;
noise: number;
phase: number;
size: number;
};
type TrailPoint = {
x: number;
y: number;
size: number;
alpha: number;
};
// PROX_PARTICLE_MAX_COUNT — 변형 3: 6 → 14 로 늘리면 입자가 촘촘히 모여 붐비듯 화려해진다.
const PROX_PARTICLE_MAX_COUNT = 6;
const PROX_RADIUS_PX = 170;
const LERP = 0.16;
const RADIUS_FAR_PX = 92;
const RADIUS_NEAR_PX = 46;
const TRAIL_STEPS = 7;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function makeParticles(): Particle[] {
const list: Particle[] = [];
for (let i = 0; i < PROX_PARTICLE_MAX_COUNT; i += 1) {
list.push({
baseAngle: (i / PROX_PARTICLE_MAX_COUNT) * Math.PI * 2,
noise: ((i * 137) % 23) / 23 - 0.5,
phase: ((i * 71) % 100) / 100 * Math.PI * 2,
size: 2.4 + ((i * 53) % 7) / 7 * 1.6,
});
}
return list;
}
function syntheticOffset(t: number): [number, number] {
const ang = t * 1.5;
const rad = 110 * (1 + Math.cos(t * Math.PI));
return [Math.cos(ang) * rad, Math.sin(ang) * rad];
}
export default function ProximityParticleSwarmButton() {
const sceneRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const badgeRef = useRef<HTMLParagraphElement | null>(null);
const particlesRef = useRef<Particle[]>(makeParticles()) as Box<Particle[]>;
const targetPRef = useRef<number>(0) as Box<number>;
const curPRef = useRef<number>(0) as Box<number>;
const hasPointerRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const t0Ref = useRef<number>(0) as Box<number>;
const radiusScaleRef = useRef<number>(1) as Box<number>;
const trailHistoryRef = useRef<TrailPoint[][]>([]) as Box<TrailPoint[][]>;
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function resizeCanvas(): void {
const wrap = wrapRef.current;
const canvas = canvasRef.current;
if (!wrap || !canvas) return;
const r = wrap.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width));
const h = Math.max(1, Math.round(r.height));
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
radiusScaleRef.current = Math.min(w / 280, h / 150);
}
function draw(p: number, t: number): void {
const canvas = canvasRef.current;
const badge = badgeRef.current;
const wrap = wrapRef.current;
if (!canvas || !badge || !wrap) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
const cx = w / 2;
const cy = h / 2;
const radiusScale = radiusScaleRef.current;
const count = p * PROX_PARTICLE_MAX_COUNT;
const radius = (RADIUS_FAR_PX + (RADIUS_NEAR_PX - RADIUS_FAR_PX) * p) * radiusScale;
const positions: TrailPoint[] = [];
particlesRef.current.forEach((pt, i) => {
const visible = clamp(count - i, 0, 1);
if (visible <= 0) return;
const ang = pt.baseAngle + t * 0.5 + pt.noise * Math.sin(t * 1.3 + pt.phase) * 0.6;
const rad = radius + pt.noise * 10 * radiusScale * Math.sin(t * 0.9 + pt.phase);
const x = cx + Math.cos(ang) * rad;
const y = cy + Math.sin(ang) * rad * 0.72;
positions.push({ x, y, size: pt.size * radiusScale, alpha: (0.4 + p * 0.6) * visible });
});
// 계산 레이어: 최근 몇 프레임의 입자 위치를 옅은 잔상으로 같이 그려 궤적이 남게 한다(전체 캔버스를 덮지 않는다).
const trailHistory = trailHistoryRef.current;
trailHistory.push(positions);
if (trailHistory.length > TRAIL_STEPS) trailHistory.shift();
trailHistory.forEach((frame, hi) => {
const age = trailHistory.length - hi;
const fade = 1 - age / (TRAIL_STEPS + 1);
frame.forEach((pos) => {
ctx.globalAlpha = pos.alpha * fade;
ctx.beginPath();
ctx.arc(pos.x, pos.y, pos.size * (0.8 + fade * 0.9), 0, Math.PI * 2);
ctx.fillStyle = "#0a4fe8";
ctx.fill();
});
});
positions.forEach((pos) => {
ctx.globalAlpha = pos.alpha;
ctx.beginPath();
ctx.arc(pos.x, pos.y, pos.size, 0, Math.PI * 2);
ctx.fillStyle = "#fff7e6";
ctx.fill();
});
ctx.globalAlpha = 1;
// 계산 레이어: 같은 진행률(p)을 입자 뒤 글로우 크기·불투명도에도 전파한다 — 배경과 겹치지 않는 색.
wrap.style.setProperty("--sw-p", p.toFixed(3));
badge.textContent = "입자 값 " + Math.round(count) + "/" + PROX_PARTICLE_MAX_COUNT;
}
function distanceToButton(x: number, y: number): number {
const btn = btnRef.current;
if (!btn) return PROX_RADIUS_PX;
const r = btn.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return Math.hypot(x - cx, y - cy);
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
return () => window.removeEventListener("resize", resizeCanvas);
}, []);
useEffect(() => {
if (reduce) {
draw(0, 0);
return;
}
function loop(now: number): void {
if (t0Ref.current === 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
if (!hasPointerRef.current) {
const off = syntheticOffset(t);
const d = Math.hypot(off[0], off[1]);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
}
curPRef.current += (targetPRef.current - curPRef.current) * LERP;
draw(curPRef.current, t);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
hasPointerRef.current = true;
setDemo(false);
const d = distanceToButton(event.clientX, event.clientY);
targetPRef.current = clamp(1 - d / PROX_RADIUS_PX, 0, 1);
if (reduce) { curPRef.current = targetPRef.current; draw(curPRef.current, 0); }
}
function onPointerLeave(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; draw(0, 0); }
}
function onFocus(): void {
hasPointerRef.current = true;
targetPRef.current = 1;
if (reduce) { curPRef.current = 1; draw(1, 0); }
}
function onBlur(): void {
hasPointerRef.current = false;
targetPRef.current = 0;
if (reduce) { curPRef.current = 0; draw(0, 0); }
}
const cls = [styles.pd, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls}>
<p className={styles.badge} ref={badgeRef}>입자 값 0/6</p>
<div className={styles.scene} ref={sceneRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<div className={styles.wrap} ref={wrapRef}>
<canvas className={styles.canvas} ref={canvasRef} width={280} height={150} aria-hidden="true" />
<button className={styles.btn} ref={btnRef} type="button" onFocus={onFocus} onBlur={onBlur}>가입하기</button>
</div>
</div>
</div>
</main>
);
}09 · 距離値の分だけ粒子が集まるボタン — 計測 累積面積 11.006% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function draw(p, t) {
var w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
var cx = w / 2, cy = h / 2;
var count = p * PROX_PARTICLE_MAX_COUNT;
var radius = (RADIUS_FAR_PX + (RADIUS_NEAR_PX - RADIUS_FAR_PX) * p) * radiusScale;
var positions = [];
particles.forEach(function (pt, i) {
var visible = clamp(count - i, 0, 1);
if (visible <= 0) { return; }
var ang = pt.baseAngle + t * 0.5 + pt.noise * Math.sin(t * 1.3 + pt.phase) * 0.6;
var rad = radius + pt.noise * 10 * radiusScale * Math.sin(t * 0.9 + pt.phase);
var x = cx + Math.cos(ang) * rad;
var y = cy + Math.sin(ang) * rad * 0.72;
positions.push({ x: x, y: y, size: pt.size * radiusScale * 1.3, alpha: (0.4 + p * 0.6) * visible });
});
計測値: 累積面積 11.0%、平均強度 102.3、動いたフレーム 20/23。
9つの近接パターンを、動いた面積と強度で比べます
各デモは仮想カーソルがボタンの周りを周回する様子をキャプチャとして記録し、連続するキャプチャ間の23区間の差分を480×300のキャンバス面積に対する割合として測っています。
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 後光の半径が広がる | 20.6% | 52.6 | 22/23 |
| 02 | 拡大と浮き上がり | 18.8% | 47.4 | 18/23 |
| 03 | 傾きが角度を追う | 13.2% | 52.4 | 18/23 |
| 04 | 最近接だけ鮮明化 | 18.2% | 19.6 | 16/23 |
| 05 | 近づくと色相が変わる | 14.5% | 46.7 | 20/23 |
| 06 | 枠線が描き進む | 14.9% | 46.5 | 21/23 |
| 07 | 影が奥行きで浮く | 7.6% | 62.1 | 21/23 |
| 08 | 文字間隔が広がる | 26.5% | 37.6 | 21/23 |
| 09 | 粒子密度が群れる | 11.0% | 102.3 | 20/23 |
3つのバリエーションと、値を動かすと何が変わるか
| バリエーション | 値 | 何が変わるべきか |
|---|---|---|
| 後光半径の最大値を調整する | 01 PROX_GLOW_RADIUS_MAX_PX 40 → 80 |
値を短くするとカーソルが最も近づいても後光は小さく控えめに残り、値を伸ばすと後光が大きく広がりボタン全体を覆うほどはっきり見えるはずです。 |
| 影の浮き上がり最大値を調整する | 07 PROX_SHADOW_LIFT_MAX_PX 24 → 48 |
値を短くすると影はボタンの下に近く留まり低く浮いた印象になり、値を伸ばすと影が遠くまで広がりボタンがずっと高く浮き上がったように見えるはずです。 |
| 粒子の最大個数値を調整する | 09 PROX_PARTICLE_MAX_COUNT 6 → 14 |
値を短くすると最も近づいても粒子はまばらに浮いて控えめに見え、値を伸ばすと粒子が密に集まりボタン周辺が賑やかに華やかに見えるはずです。 |
タッチ端末や非力な端末で壊れる落とし穴
この9つはどれも実在するユーザーのポインター位置を読み取っていません。動いて見えるカーソルは syntheticOffset が描く仮想の軌道で、誰かのマウス操作を記録したものではありません。タッチ端末では接触前に連続した pointermove が発生しないため、指は途中の近づく過程なしにいきなり p = 1 へ着地します。css ボタン アニメーション おしゃれ 系の実装をマウス前提だけで作ると、タッチ端末では間のフレームが抜けているように見えますが、実際には補間すべきホバー状態自体が存在しません。非力な端末でまず注意すべきは09番で、ctx.clearRect と最大6個分の三角関数計算を毎フレーム行う、この9つの中で唯一キャンバスを使うパターンだからです。実際のHTML・CSS・JSファイルをzipから取り出すには、このページの少し下にある zfbz3x5g が必要です。
アクセシビリティ — prefers-reduced-motionが実際に止めるもの
9つすべてが読み込み時に一度だけ window.matchMedia('(prefers-reduced-motion: reduce)') を確認します。設定が入っている場合、自動デモ用のカーソルを動かす requestAnimationFrame ループはそもそも起動しません。通常のブラウザで見える syntheticOffset の周回運動自体が動かなくなります。実際の操作は生きたままで、本物の pointermove・focus・blur がイベントハンドラの中で目標値を直接 render() に渡すため、ボタンは徐々に近づくのではなく最終状態へ一気に切り替わります。通常なら springStep を経る2番も、この設定下では同じく直接呼び出しに切り替わります。仕組みの詳細はMDNのprefers-reduced-motionを参照してください。
css ボタン ホバー エフェクト は css ボタン アニメーション と何が違うか?
そうした一般的な実装で検索すると、多くは :hover の境界で一度だけ発火するキーフレームやトランジションが返ってきます。ボタンは休止状態かホバー状態のどちらかで、その間を一定時間のトゥイーンがつなぐだけです。このページの9つはカーソルがまだ近づいている途中から連続した距離値を読み取るため、ボタンに触れる前から反応が半分終わっています。ここでの9つは境界で切り替わるのではなく、近さそのものに反応するという意味でのホバーエフェクトです。
導入前によくある質問を三つ片づけます
キーボードだけの操作でも近接ボタンは動きますか?
はい。すべての項目が pointermove・pointerleave と同じように focus・blur を監視していて、フォーカス時には目標値を1に、外れると0に設定します。タブキーで移動しても、マウスで近づいたときと同じ最終状態に到達します。
なぜ09番だけ平均強度がこれほど高いのですか?
09番は <canvas> を clearRect で消してから毎フレーム粒子を描き直しています。CSSのプロパティが近い2つの値の間をなめらかに動くのに比べ、ピクセルが変わるときの変化量自体が大きくなりやすいためです。
css ボタン マウス オーバー の実例はこの9つ以外にどこで見られますか?
同じボタンでも違う物理演算を扱った記事が2本あります。押し込みバネを扱うボタン 押し込み UI 9選 — 押し込みの値が戻る計算の9通りと、ポインターで傾く3Dボタンのcss ボタン 立体感 9種 — クリックまで再現です。
ほかの近接ボタン記事をもっと探すなら
上の2本に加えて、同じボタンの一族をキャンバスのピクセル計算で扱った記事がcss ボタン クリック アニメーション 9選 — キャンバス計算にあります。このページはクリックの瞬間を意図的に扱わず近接だけに絞っているので、クリック後の反応はそちらで補えます。









