06距離値の分だけ枠線が描かれるボタン
距離値が縮む分だけボタンを囲む枠線の長さ値が伸び、一周を描いていく。
使いどころアウトラインボタンのホバー強調
getTotalLengthstrokeDashoffsettotalLength

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


