05角度がポインターの方へゆっくり向くグラデーション
ボタン上でポインターが動くとグラデーションの角度がポインターの方向へ慣性補間で追従し、外へ出ると最初の角度へゆっくり戻る。
使いどころポインターの位置で色の向きが変わるボタン
FOLLOW_RATEREST_ANGLEORBIT_SPANORBIT_MS

<main class="stage">
<div class="bc is-demo" id="bcRoot">
<button class="bc__btn" id="bcBtn" type="button">向きを追う</button>
<p class="bc__meta"><span class="bc__tag">ANGLE</span> <b id="bcAng">135</b><span>deg</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 그라데이션 각도(--ang)는 포인터 방향을 향해 일정 비율씩 도는 숫자다. 0/360 경계는 차이를 접어서 가까운 쪽으로 돈다.
$angle-a: $subject-sky;
$angle-b: $subject-lilac;
$angle-rest: 135;
@include stage($stage-ink);
.bc {
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
--ang: #{$angle-rest};
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: linear-gradient(calc(var(--ang) * 1deg), $angle-a, $angle-b);
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.bc__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-1;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.bc__tag { margin-right: $sp-1; font-weight: 700; letter-spacing: .08em; }
.bc__meta b { min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; color: $stage-paper; }
@media (prefers-reduced-motion: reduce) {
.bc__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);
}
.bc {
width: min(100%, 320px);
display: grid;
gap: 12px;
justify-items: center;
}
.bc__btn {
--ang: 135;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: linear-gradient(calc(var(--ang) * 1deg), #6ec8ff, #b9a0ff);
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.bc__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.bc__tag {
margin-right: 4px;
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__meta b {
min-width: 3ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
// 기법: 포인터가 버튼 중심에서 향한 방향을 각도로 바꿔 목표로 삼고, 현재 각도가 그쪽으로 일정 비율씩 돈다.
// 각도는 0/360 경계가 있어 차이를 -180~180 으로 접어 가까운 쪽으로 돌린다. 포인터가 나가면 REST_ANGLE 로 돌아온다.
var FOLLOW_RATE = 6;
var REST_ANGLE = 135;
var START_MS = 200;
var ORBIT_MS = 1300;
var CYCLE_MS = 2000;
var ORBIT_SPAN = 300;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var angEl = document.getElementById('bcAng');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var angle = REST_ANGLE;
var goalAngle = REST_ANGLE;
var pointerIn = false;
var t0 = null;
var lastTs = null;
function wrap(a) { return ((a % 360) + 360) % 360; }
function fromPoint(x, y) {
var r = btn.getBoundingClientRect();
var dx = x - (r.left + r.width / 2);
var dy = y - (r.top + r.height / 2);
return wrap(Math.atan2(dx, -dy) * 180 / Math.PI);
}
function paint() {
btn.style.setProperty('--ang', angle.toFixed(2));
angEl.textContent = String(Math.round(wrap(angle)));
}
function stopDemo() { root.classList.remove('is-demo'); }
btn.addEventListener('pointermove', function (e) {
stopDemo();
pointerIn = true;
goalAngle = fromPoint(e.clientX, e.clientY);
if (reduce) { angle = goalAngle; paint(); }
});
btn.addEventListener('pointerleave', function () {
pointerIn = false;
goalAngle = REST_ANGLE;
if (reduce) { angle = goalAngle; paint(); }
});
function demoGoal(now) {
if (now < START_MS) return REST_ANGLE;
var c = (now - START_MS) % CYCLE_MS;
if (c >= ORBIT_MS) return REST_ANGLE;
return wrap(60 + (c / ORBIT_MS) * ORBIT_SPAN);
}
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
var g = root.classList.contains('is-demo') ? demoGoal(now) : goalAngle;
var diff = ((g - angle + 540) % 360) - 180;
angle = wrap(angle + diff * (1 - Math.exp(-dt * FOLLOW_RATE)));
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// AngleFollowButton — vanilla 판(05-btn-gradient-angle-pointer-lerp) 의 React 포트.
// 포인터가 버튼 중심에서 향한 방향을 각도로 바꿔 목표로 삼고, 현재 각도가 일정 비율씩 따라간다.
import { useEffect, useRef } from "react";
import styles from "./AngleFollowButton.module.scss";
type Box<T> = { current: T };
type PointLike = { clientX: number; clientY: number };
const FOLLOW_RATE = 6;
const REST_ANGLE = 135;
const START_MS = 200;
const ORBIT_MS = 1300;
const CYCLE_MS = 2000;
const ORBIT_SPAN = 300;
function wrap(a: number): number {
return ((a % 360) + 360) % 360;
}
export default function AngleFollowButton() {
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const angRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const angEl = angRef.current;
if (!btn || !angEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let angle = REST_ANGLE;
let goalAngle = REST_ANGLE;
let demo = !reduce;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const fromPoint = (x: number, y: number): number => {
const r = btn.getBoundingClientRect();
const dx = x - (r.left + r.width / 2);
const dy = y - (r.top + r.height / 2);
return wrap((Math.atan2(dx, -dy) * 180) / Math.PI);
};
const paint = (): void => {
btn.style.setProperty("--ang", angle.toFixed(2));
angEl.textContent = String(Math.round(wrap(angle)));
};
const stopDemo = (): void => { demo = false; };
const onMove = (e: Event): void => {
const p = e as unknown as PointLike;
stopDemo();
goalAngle = fromPoint(p.clientX, p.clientY);
if (reduce) { angle = goalAngle; paint(); }
};
const onLeave = (): void => {
goalAngle = REST_ANGLE;
if (reduce) { angle = goalAngle; paint(); }
};
btn.addEventListener("pointermove", onMove);
btn.addEventListener("pointerleave", onLeave);
const demoGoal = (now: number): number => {
if (now < START_MS) return REST_ANGLE;
const c = (now - START_MS) % CYCLE_MS;
if (c >= ORBIT_MS) return REST_ANGLE;
return wrap(60 + (c / ORBIT_MS) * ORBIT_SPAN);
};
function tick(ts: number): void {
if (t0 === null) t0 = ts;
const now = ts - t0;
const dt = lastTs === null ? 0.016 : Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
const g = demo ? demoGoal(now) : goalAngle;
const diff = ((g - angle + 540) % 360) - 180;
angle = wrap(angle + diff * (1 - Math.exp(-dt * FOLLOW_RATE)));
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) paint();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("pointermove", onMove);
btn.removeEventListener("pointerleave", onLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.bc}>
<button className={styles.bc__btn} type="button" ref={btnRef}>방향 따라가기</button>
<p className={styles.bc__meta}><span className={styles.bc__tag}>ANGLE</span> <b ref={angRef}>135</b><span>deg</span></p>
</div>
</main>
);
}05 角度がポインターの方へゆっくり向くグラデーション — 計測 累積面積 14.651% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面
このデモが属する記事
css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面ボタンの色をホバーで変えるのは一行で済むが、その一行は途中の色を濁らせ、グラデーションを動かせず、文字を途中で読めなくする — 02番・04番・07番がそれを一つずつ塞ぐ。css ボタン 色 変更は、ホバーや押下に合わせて背景色やグラデーシ この記事のデモをすべて見る


