08距離値が文字間隔値として広がるボタン
距離値を文字間隔の値に変え、カーソルが遠いときは狭く詰まった状態から近づくほどラベルの文字間隔が広がっていく。
使いどころテキストボタンラベルの強調
LS_MAX_EMletterSpacingsyntheticOffset

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


