04距離値が遠いほどぼやける周囲のボタン
カーソルから遠いボタンほどぼかし値が大きくなり、最も近いボタンだけぼかし値が0のまま鮮明に残る。
使いどころボタングループの中の一つへ視線誘導
updateTargetscentersOfBF_BLUR_MAX_PX

<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 に同梱 · css ボタン ホバー エフェクト 9選 — 距離の値で変わる
このデモが属する記事
css ボタン ホバー エフェクト 9選 — 距離の値で変わるもしカーソルがまだボタンから少し離れていれば、距離の値はまだ大きいままだ。その値が縮むにつれて1番は後光の半径を広げ、6番は枠線を描き進め、9番は周囲の粒子密度を満たす。css ボタン ホバー エフェクト を探すと、この9つのボタンは同じ距 この記事のデモをすべて見る


