03색 경계가 스프링으로 밀려가는 그라데이션
hover 하면 그라데이션의 색 경계 위치가 스프링 계산으로 목표를 지나쳤다 되돌아와 안착하고, 떼면 반대로 흔들리며 돌아온다.
쓰임그라데이션 버튼 hover 를 끊김 없이 움직이는 방법
STIFFNESSDAMPINGSTOP_RESTSTOP_HOVER

<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">STOP</span> <b id="bcPos">20</b><span>%</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 그라데이션의 색 경계(--stop)는 스프링으로 움직이는 숫자다. CSS transition 으로는 목표를 지나쳤다 돌아오는 값을 못 만든다.
$stop-a: $subject-lilac;
$stop-b: $subject-pink;
$stop-soft: 10%;
@include stage($stage-ink);
.bc {
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
--stop: 20;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: linear-gradient(100deg, $stop-a calc(var(--stop) * 1% - #{$stop-soft}), $stop-b calc(var(--stop) * 1% + #{$stop-soft}));
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 {
--stop: 20;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: linear-gradient(100deg, #b9a0ff calc(var(--stop) * 1% - 10%), #ff9ebb calc(var(--stop) * 1% + 10%));
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 () {
// 기법: 그라데이션 색 경계의 위치(--stop, 퍼센트)를 스프링 방정식으로 푼다.
// 가속도 = 강성 x (목표 - 위치) - 감쇠 x 속도. 고정 간격으로 쪼개 적분하므로 프레임 속도와 상관없이 같은 흔들림이 나온다.
var STIFFNESS = 170;
var DAMPING = 11;
var STOP_REST = 20;
var STOP_HOVER = 70;
var STEP = 1 / 120;
var START_MS = 200;
var ON_MS = 800;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var posEl = document.getElementById('bcPos');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pos = STOP_REST;
var vel = 0;
var acc = 0;
var pointerIn = false;
var kbFocus = false;
var t0 = null;
var lastTs = null;
function demoOn(now) {
return root.classList.contains('is-demo') && now >= START_MS && (now - START_MS) % CYCLE_MS < ON_MS;
}
function goal(now) { return demoOn(now) || pointerIn || kbFocus ? STOP_HOVER : STOP_REST; }
function paint() {
btn.style.setProperty('--stop', pos.toFixed(2));
posEl.textContent = String(Math.round(pos));
}
function stopDemo() { root.classList.remove('is-demo'); }
function jump() { pos = pointerIn || kbFocus ? STOP_HOVER : STOP_REST; vel = 0; paint(); }
btn.addEventListener('pointerenter', function () { stopDemo(); pointerIn = true; if (reduce) jump(); });
btn.addEventListener('pointerleave', function () { pointerIn = false; if (reduce) jump(); });
btn.addEventListener('focus', function () { kbFocus = btn.matches(':focus-visible'); if (kbFocus) stopDemo(); if (reduce) jump(); });
btn.addEventListener('blur', function () { kbFocus = false; if (reduce) jump(); });
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 = goal(now);
acc += dt;
while (acc >= STEP) {
vel += (STIFFNESS * (g - pos) - DAMPING * vel) * STEP;
pos += vel * STEP;
acc -= STEP;
}
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// SpringStopButton — vanilla 판(03-btn-gradient-stop-spring-overshoot) 의 React 포트.
// 그라데이션 색 경계 위치(--stop)를 스프링 방정식으로 푼다. 목표를 지나쳤다가 돌아와 안착한다.
import { useEffect, useRef } from "react";
import styles from "./SpringStopButton.module.scss";
type Box<T> = { current: T };
const STIFFNESS = 170;
const DAMPING = 11;
const STOP_REST = 20;
const STOP_HOVER = 70;
const STEP = 1 / 120;
const START_MS = 200;
const ON_MS = 800;
const CYCLE_MS = 2000;
export default function SpringStopButton() {
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const posRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const posEl = posRef.current;
if (!btn || !posEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let pos = STOP_REST;
let vel = 0;
let acc = 0;
let demo = !reduce;
let pointerIn = false;
let kbFocus = false;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const demoOn = (now: number): boolean => demo && now >= START_MS && (now - START_MS) % CYCLE_MS < ON_MS;
const paint = (): void => {
btn.style.setProperty("--stop", pos.toFixed(2));
posEl.textContent = String(Math.round(pos));
};
const stopDemo = (): void => { demo = false; };
const jump = (): void => { pos = pointerIn || kbFocus ? STOP_HOVER : STOP_REST; vel = 0; paint(); };
const onEnter = (): void => { stopDemo(); pointerIn = true; if (reduce) jump(); };
const onLeave = (): void => { pointerIn = false; if (reduce) jump(); };
const onFocus = (): void => { kbFocus = btn.matches(":focus-visible"); if (kbFocus) stopDemo(); if (reduce) jump(); };
const onBlur = (): void => { kbFocus = false; if (reduce) jump(); };
btn.addEventListener("pointerenter", onEnter);
btn.addEventListener("pointerleave", onLeave);
btn.addEventListener("focus", onFocus);
btn.addEventListener("blur", onBlur);
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 = demoOn(now) || pointerIn || kbFocus ? STOP_HOVER : STOP_REST;
acc += dt;
while (acc >= STEP) {
vel += (STIFFNESS * (g - pos) - DAMPING * vel) * STEP;
pos += vel * STEP;
acc -= STEP;
}
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) paint();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("pointerenter", onEnter);
btn.removeEventListener("pointerleave", onLeave);
btn.removeEventListener("focus", onFocus);
btn.removeEventListener("blur", onBlur);
};
}, []);
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}>STOP</span> <b ref={posRef}>20</b><span>%</span></p>
</div>
</main>
);
}03 색 경계가 스프링으로 밀려가는 그라데이션 — 측정 누적면적 13.651% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 버튼 색상 변경 9가지 — hover 중간 색이 탁해지지 않게 계산한 장면
이 데모가 속한 글
css 버튼 색상 변경 9가지 — hover 중간 색이 탁해지지 않게 계산한 장면버튼 색을 hover 에서 바꾸는 건 한 줄이면 되지만, 그 줄은 중간 색을 탁하게 만들고 그라데이션은 아예 움직이지 못하게 하고 글자는 중간에 안 읽히게 둔다 — 02번·04번·07번이 하나씩 막는다. css 버튼 이 글의 데모 전부 보기

