css 버튼 색상 변경 9가지 — hover 중간 색이 탁해지지 않게 계산한 장면
버튼 색을 hover 에서 바꾸는 건 한 줄이면 되지만, 그 줄은 중간 색을 탁하게 만들고 그라데이션은 아예 움직이지 못하게 하고 글자는 중간에 안 읽히게 둔다 — 02번·04번·07번이 하나씩 막는다.
01 btn-color-exp-ease-fill
02 btn-color-linear-light-mix-pair
03 btn-gradient-stop-spring-overshoot
04 btn-gradient-two-layer-crossfade
05 btn-gradient-angle-pointer-lerp
06 btn-hue-hold-inertia-ramp
07 btn-color-text-contrast-flip
08 btn-gradient-sheen-sweep-skew
09 btn-color-shadow-follow-glow
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 색을 목표로 끌어당기는 기본 hover
- 02 밝기 보정 섞기와 그냥 섞기를 나란히
- 03 색 경계가 스프링으로 밀려가는 그라데이션
- 04 두 겹을 포개 그라데이션을 바꾸는 크로스페이드
- 05 각도가 포인터 쪽으로 천천히 돌아서는 그라데이션
- 06 누르고 있는 동안 색상이 가속하며 도는 버튼
- 07 바탕이 밝아지면 글자색이 뒤집히는 버튼
- 08 기울어진 빛 띠가 색 위를 훑는 광택
- 09 바탕색을 따라 그림자 색이 번지는 버튼
css 버튼 색상 변경 9가지 — 색은 계산해서 옮겨야 중간이 맑다
아홉 장면은 색이 도착하는 방식이 단순한 것부터 놓았다. 01 은 남은 거리의 일정 비율씩 목표로 다가가는 기본 hover 이고, 02 는 같은 두 색을 그냥 섞은 버튼과 밝기를 보정해 섞은 버튼의 나란히 비교다. 03 은 그라데이션 색 경계가 스프링으로 밀려가는 장면, 04 는 두 겹을 포개 그라데이션을 바꾸는 크로스페이드, 05 는 각도가 포인터 쪽으로 따라 도는 장면이다. 06 은 누르는 동안 색상이 가속하는 버튼, 07 은 바탕이 밝아지면 글자색이 뒤집히는 버튼, 08 은 기울어진 빛 띠가 한 번 훑는 광택, 09 는 바탕색을 따라 그림자 색이 번지는 버튼이다.
이런 화면에서 쓴다
주요 행동 버튼이 마우스를 올리면 색이 바뀌는 화면 어디서나 쓴다. 가격표의 구매 버튼처럼 눈에 띄어야 하는 자리는 03·08 의 그라데이션이, 색을 고르는 도구 화면은 06 의 눌러서 돌리는 입력이, 바탕색이 여러 갈래로 바뀌는 테마 버튼은 07 의 글자 뒤집힘이 먼저 필요하다. 어느 쪽이든 색 한 쌍을 정해 두면 계산은 그 두 값 사이의 진행도 하나로 정리된다.
이웃 편과 무엇이 다른가
css 버튼 마우스 오버 효과 9가지 — 거리 값 하나로 갈라진다는 포인터가 얼마나 가까운지에 따라 버튼의 반응이 갈라지는 편이고, 버튼 눌림 효과 UI 9종 — 눌림 값이 되돌아오는 아홉 가지 방식은 눌렸다가 돌아오는 움직임을 다룬다. 이 편은 움직이는 대상이 모양이 아니라 색이어서, 중간 색을 어떻게 계산하느냐 하나에 아홉 장면을 걸었다.
기법별 코드로 보는 색 옮기기
아홉 장면 모두 네이티브 <button> 으로 만들었고(02·04 는 비교용으로 두 개), 색 계산은 전부 JS 가 프레임마다 한다. 01·02·07·09 는 색을 --c-from·--c-to 두 CSS 변수에 두고 getComputedStyle 로 읽어 쓴다. 아래에는 그 계산의 핵심 구간만 소스에서 그대로 발췌했으며, 입력이 없을 때의 자동 시연은 첫 포인터·키 입력이 오면 꺼지고 같은 계산이 입력을 받는다. 라이브러리와 CDN 은 쓰지 않았고 마크업·스타일 전체는 아래 압축 파일에 들어 있다.
01색을 목표로 끌어당기는 기본 hover
매 프레임 1 - exp(-dt * PULL_RATE) 비율을 세 채널의 남은 거리에 곱해 현재 색이 목표로 다가가고, 남은 거리 합이 SETTLE_GAP 미만이면 목표로 스냅한다. 시간 곡선이 없어 hover 를 도중에 놓아도 지금 색에서 바로 되돌아가기 시작한다. 01 항목은 누적면적 16.2%·강도 평균 114.6·움직인 프레임 22/23 이다.

<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">GAP</span> <b id="bcGap">0</b> <span class="bc__hex" id="bcHex">#b9a0ff</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 색은 CSS transition 이 아니라 JS 가 매 프레임 계산한다.
// 현재 색이 목표 색까지 남은 거리의 일정 비율(PULL_RATE)만큼 다가가므로 끝에서 저절로 느려진다.
$pull-from: $subject-lilac;
$pull-to: $subject-pink;
@include stage($stage-ink);
.bc {
--c-from: #{$pull-from};
--c-to: #{$pull-to};
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: $pull-from;
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-2;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.bc__tag { font-weight: 700; letter-spacing: .08em; }
.bc__meta b { min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; color: $stage-paper; }
.bc__hex { font-variant-numeric: tabular-nums; }
@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 {
--c-from: #b9a0ff;
--c-to: #ff9ebb;
width: min(100%, 320px);
display: grid;
gap: 12px;
justify-items: center;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: #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: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.bc__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__meta b {
min-width: 3ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #ffffff;
}
.bc__hex {
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
// 기법: 매 프레임 현재 색이 목표 색까지 남은 거리의 일정 비율만큼 다가간다 (시간 곡선 없음).
// 비율은 프레임 간격(dt)으로 보정해 60Hz 든 120Hz 든 같은 속도로 도착한다.
var PULL_RATE = 7;
var SETTLE_GAP = 0.5;
var START_MS = 200;
var ON_MS = 900;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var gapEl = document.getElementById('bcGap');
var hexEl = document.getElementById('bcHex');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function readRgb(name) {
var v = getComputedStyle(btn).getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.split('').map(function (c) { return c + c; }).join('');
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
function toHex(rgb) {
return '#' + rgb.map(function (c) { return ('0' + Math.round(c).toString(16)).slice(-2); }).join('');
}
var from = readRgb('--c-from');
var to = readRgb('--c-to');
var cur = from.slice();
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 target(now) { return demoOn(now) || pointerIn || kbFocus ? to : from; }
function gapOf(goal) {
return Math.abs(goal[0] - cur[0]) + Math.abs(goal[1] - cur[1]) + Math.abs(goal[2] - cur[2]);
}
function paint(goal) {
btn.style.background = toHex(cur);
gapEl.textContent = String(Math.round(gapOf(goal)));
hexEl.textContent = toHex(cur);
}
function stopDemo() { root.classList.remove('is-demo'); }
function jump() { cur = (pointerIn || kbFocus ? to : from).slice(); paint(pointerIn || kbFocus ? to : from); }
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 goal = target(now);
var k = 1 - Math.exp(-dt * PULL_RATE);
for (var i = 0; i < 3; i += 1) cur[i] += (goal[i] - cur[i]) * k;
if (gapOf(goal) < SETTLE_GAP) cur = goal.slice();
paint(goal);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint(from);
} else {
requestAnimationFrame(tick);
}
})();// PullColorButton — vanilla 판(01-btn-color-exp-ease-fill) 의 React 포트.
// 현재 색이 목표 색까지 남은 거리의 일정 비율(PULL_RATE)만큼 매 프레임 다가간다. CSS transition 은 쓰지 않는다.
import { useEffect, useRef } from "react";
import styles from "./PullColorButton.module.scss";
type Box<T> = { current: T };
type Rgb = [number, number, number];
const PULL_RATE = 7;
const SETTLE_GAP = 0.5;
const START_MS = 200;
const ON_MS = 900;
const CYCLE_MS = 2000;
function toHex(rgb: Rgb): string {
return "#" + rgb.map((c) => ("0" + Math.round(c).toString(16)).slice(-2)).join("");
}
export default function PullColorButton() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const gapRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const hexRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
useEffect(() => {
const btn = btnRef.current;
const gapEl = gapRef.current;
const hexEl = hexRef.current;
if (!btn || !gapEl || !hexEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const readRgb = (name: string): Rgb => {
let v = getComputedStyle(btn).getPropertyValue(name).trim().replace("#", "");
if (v.length === 3) v = v.split("").map((c) => c + c).join("");
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
const from = readRgb("--c-from");
const to = readRgb("--c-to");
let cur: Rgb = [from[0], from[1], from[2]];
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 gapOf = (goal: Rgb): number =>
Math.abs(goal[0] - cur[0]) + Math.abs(goal[1] - cur[1]) + Math.abs(goal[2] - cur[2]);
const paint = (goal: Rgb): void => {
btn.style.background = toHex(cur);
gapEl.textContent = String(Math.round(gapOf(goal)));
hexEl.textContent = toHex(cur);
};
const stopDemo = (): void => { demo = false; };
const jump = (): void => {
const goal = pointerIn || kbFocus ? to : from;
cur = [goal[0], goal[1], goal[2]];
paint(goal);
};
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 goal = demoOn(now) || pointerIn || kbFocus ? to : from;
const k = 1 - Math.exp(-dt * PULL_RATE);
cur = [cur[0] + (goal[0] - cur[0]) * k, cur[1] + (goal[1] - cur[1]) * k, cur[2] + (goal[2] - cur[2]) * k];
if (gapOf(goal) < SETTLE_GAP) cur = [goal[0], goal[1], goal[2]];
paint(goal);
raf = requestAnimationFrame(tick);
}
if (reduce) paint(from);
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} ref={rootRef}>
<button className={styles.bc__btn} type="button" ref={btnRef}>저장하기</button>
<p className={styles.bc__meta}>
<span className={styles.bc__tag}>GAP</span> <b ref={gapRef}>0</b> <span className={styles.bc__hex} ref={hexRef}>#b9a0ff</span>
</p>
</div>
</main>
);
}01 · 색을 목표로 끌어당기는 기본 hover — 측정 누적면적 16.203% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 goal = target(now);
var k = 1 - Math.exp(-dt * PULL_RATE);
for (var i = 0; i < 3; i += 1) cur[i] += (goal[i] - cur[i]) * k;
if (gapOf(goal) < SETTLE_GAP) cur = goal.slice();
paint(goal);
requestAnimationFrame(tick);
}
02밝기 보정 섞기와 그냥 섞기를 나란히
왼쪽 버튼은 sRGB 값을 그대로 보간하고 오른쪽 버튼은 toLinear 로 밝기를 선형으로 되돌려 섞은 뒤 toGamma 로 다시 올린다. 진행도 50% 에서 그냥 섞은 색(#a6b9b1)의 상대 밝기는 약 0.459 로 양 끝(분홍 0.493·민트 0.514)보다 낮고, 보정한 색(#c1bcb1)은 약 0.505 로 두 끝 사이에 머문다. 02 항목은 누적면적 18.1%·강도 평균 48.3·움직인 프레임 21/23 이다.

<main class="stage">
<div class="bc is-demo" id="bcRoot">
<div class="bc__pair" id="bcPair">
<div class="bc__cell">
<button class="bc__btn" id="bcRaw" type="button">구독하기</button>
<p class="bc__meta"><span class="bc__tag">SRGB</span> <span class="bc__hex" id="bcRawHex">#ff9ebb</span></p>
<p class="bc__meta"><span class="bc__tag">50%</span> <span class="bc__swatch" id="bcRawMid"></span> <span class="bc__hex" id="bcRawMidHex">#a6b9b0</span></p>
</div>
<div class="bc__cell">
<button class="bc__btn" id="bcLin" type="button">구독하기</button>
<p class="bc__meta"><span class="bc__tag">LINEAR</span> <span class="bc__hex" id="bcLinHex">#ff9ebb</span></p>
<p class="bc__meta"><span class="bc__tag">50%</span> <span class="bc__swatch" id="bcLinMid"></span> <span class="bc__hex" id="bcLinMidHex">#c1bcb1</span></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 같은 두 색, 같은 진행도. 왼쪽은 값 그대로 섞고(SRGB) 오른쪽은 선형 밝기에서 섞어(LINEAR) 중간 색을 비교한다.
$mix-from: $subject-pink;
$mix-to: $subject-mint;
@include stage($stage-ink);
.bc {
--c-from: #{$mix-from};
--c-to: #{$mix-to};
width: min(100%, 360px);
}
.bc__pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $sp-3;
}
.bc__cell { display: grid; gap: $sp-3; justify-items: center; min-width: 0; }
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: $mix-from;
color: $subject-ink;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.bc__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.bc__meta {
margin: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
gap: $sp-1 $sp-2;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.bc__tag { font-weight: 700; letter-spacing: .08em; color: $stage-paper; }
.bc__hex { font-variant-numeric: tabular-nums; }
.bc__swatch { display: inline-block; width: 40px; height: 12px; border-radius: $r-xs; align-self: center; }
@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 {
--c-from: #ff9ebb;
--c-to: #4cd4a6;
width: min(100%, 360px);
}
.bc__pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.bc__cell {
display: grid;
gap: 12px;
justify-items: center;
min-width: 0;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: #ff9ebb;
color: #17141a;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.bc__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.bc__meta {
margin: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
gap: 4px 8px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.bc__tag {
font-weight: 700;
letter-spacing: 0.08em;
color: #ffffff;
}
.bc__hex {
font-variant-numeric: tabular-nums;
}
.bc__swatch {
display: inline-block;
width: 40px;
height: 12px;
border-radius: 4px;
align-self: center;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
// 기법: 같은 두 색을 같은 진행도 t 로 섞는다. 왼쪽은 sRGB 값을 그대로, 오른쪽은 선형 밝기로 되돌려 섞은 뒤 다시 감마를 씌운다.
// 진행도 t 는 두 버튼이 함께 쓰고 매 프레임 목표로 일정 비율 끌린다. 두 중간 색의 차이만 눈에 남는다.
var MIX_RATE = 5;
var START_MS = 200;
var ON_MS = 1000;
var RAMP_MS = 800;
var HOLD_MS = 100;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var rawBtn = document.getElementById('bcRaw');
var linBtn = document.getElementById('bcLin');
var rawHex = document.getElementById('bcRawHex');
var linHex = document.getElementById('bcLinHex');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function readRgb(name) {
var v = getComputedStyle(rawBtn).getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.split('').map(function (c) { return c + c; }).join('');
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
function toHex(rgb) {
return '#' + rgb.map(function (c) { return ('0' + Math.round(c).toString(16)).slice(-2); }).join('');
}
function toLinear(c) {
var s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function toGamma(l) {
var s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, s * 255));
}
function mixRaw(a, b, t) { return a.map(function (v, i) { return v + (b[i] - v) * t; }); }
function mixLinear(a, b, t) {
return a.map(function (v, i) {
var la = toLinear(v);
return toGamma(la + (toLinear(b[i]) - la) * t);
});
}
var from = readRgb('--c-from');
var to = readRgb('--c-to');
var mix = 0;
var rawMid = toHex(mixRaw(from, to, 0.5));
var linMid = toHex(mixLinear(from, to, 0.5));
document.getElementById('bcRawMid').style.background = rawMid;
document.getElementById('bcLinMid').style.background = linMid;
document.getElementById('bcRawMidHex').textContent = rawMid;
document.getElementById('bcLinMidHex').textContent = linMid;
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;
}
// 시연 전용: 진행도를 smoothstep 으로 오르내려 변화가 가장 큰 순간이 두 중간 색이 벌어진 한가운데에 오게 한다.
function demoMix(now) {
if (now < START_MS) return 0;
var c = (now - START_MS) % CYCLE_MS;
var s;
if (c < RAMP_MS) { s = c / RAMP_MS; return s * s * (3 - 2 * s); }
if (c < RAMP_MS + HOLD_MS) return 1;
if (c < 2 * RAMP_MS + HOLD_MS) { s = (c - RAMP_MS - HOLD_MS) / RAMP_MS; return 1 - s * s * (3 - 2 * s); }
return 0;
}
function paint() {
var a = mixRaw(from, to, mix);
var b = mixLinear(from, to, mix);
rawBtn.style.background = toHex(a);
linBtn.style.background = toHex(b);
rawHex.textContent = toHex(a);
linHex.textContent = toHex(b);
}
function stopDemo() { root.classList.remove('is-demo'); }
function goal(now) { return demoOn(now) || pointerIn || kbFocus ? 1 : 0; }
function jump() { mix = pointerIn || kbFocus ? 1 : 0; paint(); }
[rawBtn, linBtn].forEach(function (b) {
b.addEventListener('pointerenter', function () { stopDemo(); pointerIn = true; if (reduce) jump(); });
b.addEventListener('pointerleave', function () { pointerIn = false; if (reduce) jump(); });
b.addEventListener('focus', function () { kbFocus = b.matches(':focus-visible'); if (kbFocus) stopDemo(); if (reduce) jump(); });
b.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;
if (root.classList.contains('is-demo')) {
mix = demoMix(now);
} else {
var g = goal(now);
mix += (g - mix) * (1 - Math.exp(-dt * MIX_RATE));
if (Math.abs(g - mix) < 0.002) mix = g;
}
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// LinearLightMixPair — vanilla 판(02-btn-color-linear-light-mix-pair) 의 React 포트.
// 같은 두 색을 같은 진행도로 섞는다. 왼쪽은 sRGB 값 그대로(SRGB), 오른쪽은 선형 밝기에서 섞는다(LINEAR).
import { useEffect, useRef } from "react";
import styles from "./LinearLightMixPair.module.scss";
type Box<T> = { current: T };
type Rgb = [number, number, number];
const MIX_RATE = 5;
const START_MS = 200;
const ON_MS = 1000;
const RAMP_MS = 800;
const HOLD_MS = 100;
const CYCLE_MS = 2000;
function toHex(rgb: Rgb): string {
return "#" + rgb.map((c) => ("0" + Math.round(c).toString(16)).slice(-2)).join("");
}
function toLinear(c: number): number {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function toGamma(l: number): number {
const s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, s * 255));
}
// 시연 전용: 진행도를 smoothstep 으로 오르내려 변화가 가장 큰 순간이 두 중간 색이 벌어진 한가운데에 오게 한다.
function demoMix(now: number): number {
if (now < START_MS) return 0;
const c = (now - START_MS) % CYCLE_MS;
if (c < RAMP_MS) { const s = c / RAMP_MS; return s * s * (3 - 2 * s); }
if (c < RAMP_MS + HOLD_MS) return 1;
if (c < 2 * RAMP_MS + HOLD_MS) { const s = (c - RAMP_MS - HOLD_MS) / RAMP_MS; return 1 - s * s * (3 - 2 * s); }
return 0;
}
function mixRaw(a: Rgb, b: Rgb, t: number): Rgb {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function mixLinear(a: Rgb, b: Rgb, t: number): Rgb {
const one = (i: number): number => {
const la = toLinear(a[i]);
return toGamma(la + (toLinear(b[i]) - la) * t);
};
return [one(0), one(1), one(2)];
}
export default function LinearLightMixPair() {
const rawRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const linRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const rawHexRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const linHexRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const rawMidRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const linMidRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const rawMidHexRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const linMidHexRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
useEffect(() => {
const rawBtn = rawRef.current;
const linBtn = linRef.current;
const rawHex = rawHexRef.current;
const linHex = linHexRef.current;
const rawMidEl = rawMidRef.current;
const linMidEl = linMidRef.current;
const rawMidHexEl = rawMidHexRef.current;
const linMidHexEl = linMidHexRef.current;
if (!rawBtn || !linBtn || !rawHex || !linHex || !rawMidEl || !linMidEl || !rawMidHexEl || !linMidHexEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const readRgb = (name: string): Rgb => {
let v = getComputedStyle(rawBtn).getPropertyValue(name).trim().replace("#", "");
if (v.length === 3) v = v.split("").map((c) => c + c).join("");
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
const from = readRgb("--c-from");
const to = readRgb("--c-to");
const rawMid = toHex(mixRaw(from, to, 0.5));
const linMid = toHex(mixLinear(from, to, 0.5));
rawMidEl.style.background = rawMid;
linMidEl.style.background = linMid;
rawMidHexEl.textContent = rawMid;
linMidHexEl.textContent = linMid;
let mix = 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 => {
const a = mixRaw(from, to, mix);
const b = mixLinear(from, to, mix);
rawBtn.style.background = toHex(a);
linBtn.style.background = toHex(b);
rawHex.textContent = toHex(a);
linHex.textContent = toHex(b);
};
const stopDemo = (): void => { demo = false; };
const jump = (): void => { mix = pointerIn || kbFocus ? 1 : 0; paint(); };
const cleanups: Array<() => void> = [];
[rawBtn, linBtn].forEach((b) => {
const onEnter = (): void => { stopDemo(); pointerIn = true; if (reduce) jump(); };
const onLeave = (): void => { pointerIn = false; if (reduce) jump(); };
const onFocus = (): void => { kbFocus = b.matches(":focus-visible"); if (kbFocus) stopDemo(); if (reduce) jump(); };
const onBlur = (): void => { kbFocus = false; if (reduce) jump(); };
b.addEventListener("pointerenter", onEnter);
b.addEventListener("pointerleave", onLeave);
b.addEventListener("focus", onFocus);
b.addEventListener("blur", onBlur);
cleanups.push(() => {
b.removeEventListener("pointerenter", onEnter);
b.removeEventListener("pointerleave", onLeave);
b.removeEventListener("focus", onFocus);
b.removeEventListener("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;
if (demo) {
mix = demoMix(now);
} else {
const g = demoOn(now) || pointerIn || kbFocus ? 1 : 0;
mix += (g - mix) * (1 - Math.exp(-dt * MIX_RATE));
if (Math.abs(g - mix) < 0.002) mix = g;
}
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) paint();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
cleanups.forEach((fn) => fn());
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.bc}>
<div className={styles.bc__pair}>
<div className={styles.bc__cell}>
<button className={styles.bc__btn} type="button" ref={rawRef}>구독하기</button>
<p className={styles.bc__meta}><span className={styles.bc__tag}>SRGB</span> <span className={styles.bc__hex} ref={rawHexRef}>#ff9ebb</span></p>
<p className={styles.bc__meta}><span className={styles.bc__tag}>50%</span> <span className={styles.bc__swatch} ref={rawMidRef}></span> <span className={styles.bc__hex} ref={rawMidHexRef}>#a6b9b0</span></p>
</div>
<div className={styles.bc__cell}>
<button className={styles.bc__btn} type="button" ref={linRef}>구독하기</button>
<p className={styles.bc__meta}><span className={styles.bc__tag}>LINEAR</span> <span className={styles.bc__hex} ref={linHexRef}>#ff9ebb</span></p>
<p className={styles.bc__meta}><span className={styles.bc__tag}>50%</span> <span className={styles.bc__swatch} ref={linMidRef}></span> <span className={styles.bc__hex} ref={linMidHexRef}>#c1bcb1</span></p>
</div>
</div>
</div>
</main>
);
}02 · 밝기 보정 섞기와 그냥 섞기를 나란히 — 측정 누적면적 18.131% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function toLinear(c) {
var s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function toGamma(l) {
var s = l <= 0.0031308 ? l * 12.92 : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, s * 255));
}
function mixRaw(a, b, t) { return a.map(function (v, i) { return v + (b[i] - v) * t; }); }
function mixLinear(a, b, t) {
return a.map(function (v, i) {
var la = toLinear(v);
return toGamma(la + (toLinear(b[i]) - la) * t);
});
}
03색 경계가 스프링으로 밀려가는 그라데이션
그라데이션 정지점 --stop 을 STIFFNESS 와 DAMPING 으로 정한 스프링에 넣고 1/120초 고정 간격으로 적분해, 20% 에서 목표 70% 로 가며 11.1 만큼 지나쳤다 돌아온다. 떼면 20% 아래로 반대 방향 오버슛을 하고 난 뒤 자리를 잡는다. 03 항목은 누적면적 13.7%·강도 평균 92.8·움직인 프레임 20/23 이다.

<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 포함 · 비밀번호 보기
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);
}
04두 겹을 포개 그라데이션을 바꾸는 크로스페이드
왼쪽은 클래스를 갈아 끼워 진행도 0.5 에서 그라데이션이 통째로 바뀌는 컷이고, 오른쪽은 아래 겹 위에 다른 그라데이션 겹을 포개 그 겹의 opacity 만 FADE_RATE 비율로 옮겨 두 색 조합을 섞는다. 이 데모는 불투명도 값도 JS 가 계산하므로 겹을 포개 섞는 원리를 보이는 장면이다. 04 항목은 누적면적 17.3%·강도 평균 97.3·움직인 프레임 22/23 이다.

<main class="stage">
<div class="bc is-demo" id="bcRoot">
<div class="bc__pair">
<div class="bc__cell">
<button class="bc__btn" id="bcSwap" type="button"><span class="bc__label">그냥 교체</span></button>
<p class="bc__meta"><span class="bc__tag">SWAP</span> <b id="bcSwapVal">0</b></p>
</div>
<div class="bc__cell">
<button class="bc__btn" id="bcFade" type="button"><span class="bc__layer" id="bcLayer"></span><span class="bc__label">두 겹</span></button>
<p class="bc__meta"><span class="bc__tag">OPACITY</span> <b id="bcFadeVal">0.00</b></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 그라데이션은 transition 으로 보간되지 않는다. 아래 겹(버튼 배경)과 위 겹(.bc__layer)을 포개고 위 겹의 opacity 만 옮긴다.
$layer-a: linear-gradient(120deg, $subject-lilac, $subject-pink);
$layer-b: linear-gradient(120deg, $subject-mint, $subject-sky);
@include stage($stage-paper);
.bc { width: min(100%, 360px); }
.bc__pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $sp-3;
}
.bc__cell { display: grid; gap: $sp-3; justify-items: center; min-width: 0; }
.bc__btn {
position: relative;
isolation: isolate;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: $layer-a;
color: $subject-ink;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.bc__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.bc__btn.bc__on { background: $layer-b; }
.bc__layer {
position: absolute;
inset: 0;
z-index: -1;
background: $layer-b;
opacity: 0;
}
.bc__label { position: relative; }
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: rgba($subject-ink, .78);
}
.bc__tag { font-weight: 700; letter-spacing: .08em; }
.bc__meta b { min-width: 4ch; font-variant-numeric: tabular-nums; color: $subject-ink; }
@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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bc {
width: min(100%, 360px);
}
.bc__pair {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.bc__cell {
display: grid;
gap: 12px;
justify-items: center;
min-width: 0;
}
.bc__btn {
position: relative;
isolation: isolate;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: linear-gradient(120deg, #b9a0ff, #ff9ebb);
color: #17141a;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 20px;
cursor: pointer;
}
.bc__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.bc__btn.bc__on {
background: linear-gradient(120deg, #4cd4a6, #6ec8ff);
}
.bc__layer {
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(120deg, #4cd4a6, #6ec8ff);
opacity: 0;
}
.bc__label {
position: relative;
}
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.78);
}
.bc__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__meta b {
min-width: 4ch;
font-variant-numeric: tabular-nums;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
// 기법: background 의 그라데이션은 transition 으로 보간되지 않는다 (왼쪽처럼 한 번에 바뀐다).
// 그래서 아래 겹에 첫 그라데이션, 위 겹에 두 번째 그라데이션을 깔고 위 겹의 opacity 만 움직인다 (오른쪽).
// opacity 는 JS 가 목표로 일정 비율씩 끌어 옮긴다.
var FADE_RATE = 6;
var START_MS = 200;
var ON_MS = 1000;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var swapBtn = document.getElementById('bcSwap');
var fadeBtn = document.getElementById('bcFade');
var layer = document.getElementById('bcLayer');
var swapVal = document.getElementById('bcSwapVal');
var fadeVal = document.getElementById('bcFadeVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var amount = 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 ? 1 : 0; }
function paint() {
layer.style.opacity = amount.toFixed(3);
fadeVal.textContent = amount.toFixed(2);
var on = amount >= 0.5;
swapBtn.classList.toggle('bc__on', on);
swapVal.textContent = on ? '1' : '0';
}
function stopDemo() { root.classList.remove('is-demo'); }
function jump() { amount = pointerIn || kbFocus ? 1 : 0; paint(); }
[swapBtn, fadeBtn].forEach(function (b) {
b.addEventListener('pointerenter', function () { stopDemo(); pointerIn = true; if (reduce) jump(); });
b.addEventListener('pointerleave', function () { pointerIn = false; if (reduce) jump(); });
b.addEventListener('focus', function () { kbFocus = b.matches(':focus-visible'); if (kbFocus) stopDemo(); if (reduce) jump(); });
b.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);
amount += (g - amount) * (1 - Math.exp(-dt * FADE_RATE));
if (Math.abs(g - amount) < 0.004) amount = g;
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// TwoLayerGradientFade — vanilla 판(04-btn-gradient-two-layer-crossfade) 의 React 포트.
// 왼쪽은 그라데이션을 통째로 교체(보간 없음), 오른쪽은 위 겹의 opacity 만 옮겨 두 그라데이션을 섞는다.
import { useEffect, useRef } from "react";
import styles from "./TwoLayerGradientFade.module.scss";
type Box<T> = { current: T };
const FADE_RATE = 6;
const START_MS = 200;
const ON_MS = 1000;
const CYCLE_MS = 2000;
export default function TwoLayerGradientFade() {
const swapRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const fadeRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const layerRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const swapValRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const fadeValRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const swapBtn = swapRef.current;
const fadeBtn = fadeRef.current;
const layer = layerRef.current;
const swapVal = swapValRef.current;
const fadeVal = fadeValRef.current;
if (!swapBtn || !fadeBtn || !layer || !swapVal || !fadeVal) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let amount = 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 => {
layer.style.opacity = amount.toFixed(3);
fadeVal.textContent = amount.toFixed(2);
const on = amount >= 0.5;
swapBtn.classList.toggle(styles.bc__on, on);
swapVal.textContent = on ? "1" : "0";
};
const stopDemo = (): void => { demo = false; };
const jump = (): void => { amount = pointerIn || kbFocus ? 1 : 0; paint(); };
const cleanups: Array<() => void> = [];
[swapBtn, fadeBtn].forEach((b) => {
const onEnter = (): void => { stopDemo(); pointerIn = true; if (reduce) jump(); };
const onLeave = (): void => { pointerIn = false; if (reduce) jump(); };
const onFocus = (): void => { kbFocus = b.matches(":focus-visible"); if (kbFocus) stopDemo(); if (reduce) jump(); };
const onBlur = (): void => { kbFocus = false; if (reduce) jump(); };
b.addEventListener("pointerenter", onEnter);
b.addEventListener("pointerleave", onLeave);
b.addEventListener("focus", onFocus);
b.addEventListener("blur", onBlur);
cleanups.push(() => {
b.removeEventListener("pointerenter", onEnter);
b.removeEventListener("pointerleave", onLeave);
b.removeEventListener("focus", onFocus);
b.removeEventListener("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 ? 1 : 0;
amount += (g - amount) * (1 - Math.exp(-dt * FADE_RATE));
if (Math.abs(g - amount) < 0.004) amount = g;
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) paint();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
cleanups.forEach((fn) => fn());
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.bc}>
<div className={styles.bc__pair}>
<div className={styles.bc__cell}>
<button className={styles.bc__btn} type="button" ref={swapRef}><span className={styles.bc__label}>그냥 교체</span></button>
<p className={styles.bc__meta}><span className={styles.bc__tag}>SWAP</span> <b ref={swapValRef}>0</b></p>
</div>
<div className={styles.bc__cell}>
<button className={styles.bc__btn} type="button" ref={fadeRef}>
<span className={styles.bc__layer} ref={layerRef}></span>
<span className={styles.bc__label}>두 겹</span>
</button>
<p className={styles.bc__meta}><span className={styles.bc__tag}>OPACITY</span> <b ref={fadeValRef}>0.00</b></p>
</div>
</div>
</div>
</main>
);
}04 · 두 겹을 포개 그라데이션을 바꾸는 크로스페이드 — 측정 누적면적 17.253% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
amount += (g - amount) * (1 - Math.exp(-dt * FADE_RATE));
if (Math.abs(g - amount) < 0.004) amount = g;
paint();
requestAnimationFrame(tick);
}
05각도가 포인터 쪽으로 돌아서는 그라데이션
버튼 중심에서 포인터까지의 방향을 atan2(dx, -dy) 로 각도로 바꿔 그라데이션 --ang 가 FOLLOW_RATE 비율로 따라가고, 포인터가 나가면 REST_ANGLE 인 135도로 돌아온다. 현재 각도와 목표의 차이를 ((g - a + 540) % 360) - 180 으로 접어 가까운 쪽으로 돌기 때문에 350도에서 10도로 가도 반대로 한 바퀴를 돌지 않는다. 05 항목은 누적면적 14.7%·강도 평균 132.5·움직인 프레임 23/23 이다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
}
06누르고 있는 동안 색상이 가속하며 도는 버튼
누르는 동안 ACCEL 만큼 회전 속도가 MAX_SPEED 까지 오르고, 떼면 exp(-FRICTION * dt) 비율로 줄어 멈춘 자리의 색상에 그대로 남는다. 포인터는 setPointerCapture 로, 키보드는 Space·Enter 의 비반복 keydown 부터 keyup 까지를 누름으로 쳐서 같은 경로를 탄다. 06 항목은 누적면적 16.0%·강도 평균 77.4·움직인 프레임 23/23 이다.

<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">HUE</span> <b id="bcHue">270</b><span>deg</span> <span class="bc__tag">SPEED</span> <b id="bcSpeed">0</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 색상 각도(--hue)는 속도를 쌓아 얻는 값이다. 누르면 속도가 가속하고, 떼면 마찰로 줄어 멈춘 색에 남는다.
$hue-sat: 72%;
$hue-light: 82%;
@include stage($stage-ink);
.bc {
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
--hue: 270;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: hsl(var(--hue), $hue-sat, $hue-light);
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
}
.bc__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 3px; }
.bc__btn:active { transform: translateY(1px); }
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: $sp-1;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.bc__tag { margin: 0 $sp-1 0 $sp-2; font-weight: 700; letter-spacing: .08em; }
.bc__tag:first-child { margin-left: 0; }
.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; }
.bc__btn:active { transform: 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 {
--hue: 270;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: hsl(var(--hue), 72%, 82%);
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
touch-action: manipulation;
user-select: none;
}
.bc__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 3px;
}
.bc__btn:active {
transform: translateY(1px);
}
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.bc__tag {
margin: 0 4px 0 8px;
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__tag:first-child {
margin-left: 0;
}
.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;
}
.bc__btn:active {
transform: none;
}
}(function () {
// 기법: 누르는 동안 색상 각속도가 ACCEL 로 커지고(상한 MAX_SPEED), 떼면 FRICTION 으로 줄어 멈춘다.
// 색상 각도는 속도를 매 프레임 쌓아 얻는다. 시간은 rAF 타임스탬프 간격(dt)만 쓴다.
var ACCEL = 600;
var MAX_SPEED = 720;
var FRICTION = 2.4;
var HUE_START = 270;
var HUE_STEP = 60;
var START_MS = 200;
var HOLD_MS = 900;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var hueEl = document.getElementById('bcHue');
var speedEl = document.getElementById('bcSpeed');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var hue = HUE_START;
var speed = 0;
var pressed = false;
var t0 = null;
var lastTs = null;
function paint() {
btn.style.setProperty('--hue', hue.toFixed(1));
hueEl.textContent = String(Math.round(hue));
speedEl.textContent = String(Math.round(speed));
}
function stopDemo() { root.classList.remove('is-demo'); }
function press() {
stopDemo();
pressed = true;
if (reduce) { hue = (hue + HUE_STEP) % 360; paint(); }
}
function release() { pressed = false; }
btn.addEventListener('pointerdown', function (e) {
try { btn.setPointerCapture(e.pointerId); } catch (err) { /* 캡처를 못 걸어도 누름은 동작한다 */ }
press();
});
btn.addEventListener('pointerup', release);
btn.addEventListener('pointercancel', release);
btn.addEventListener('blur', release);
btn.addEventListener('keydown', function (e) {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) press();
});
btn.addEventListener('keyup', function (e) {
if (e.key === ' ' || e.key === 'Enter') release();
});
function demoPressed(now) {
return root.classList.contains('is-demo') && now >= START_MS && (now - START_MS) % CYCLE_MS < HOLD_MS;
}
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;
if (demoPressed(now) || pressed) {
speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
} else {
speed *= Math.exp(-FRICTION * dt);
if (speed < 0.5) speed = 0;
}
hue = (hue + speed * dt) % 360;
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// HueHoldButton — vanilla 판(06-btn-hue-hold-inertia-ramp) 의 React 포트.
// 누르는 동안 색상 각속도가 가속하고(MAX_SPEED 상한), 떼면 FRICTION 으로 줄어 멈춘다.
import { useEffect, useRef } from "react";
import styles from "./HueHoldButton.module.scss";
type Box<T> = { current: T };
type PointerLike = Event & { pointerId: number };
type KeyLike = Event & { key: string; repeat: boolean };
const ACCEL = 600;
const MAX_SPEED = 720;
const FRICTION = 2.4;
const HUE_START = 270;
const HUE_STEP = 60;
const START_MS = 200;
const HOLD_MS = 900;
const CYCLE_MS = 2000;
export default function HueHoldButton() {
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const hueRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const speedRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const hueEl = hueRef.current;
const speedEl = speedRef.current;
if (!btn || !hueEl || !speedEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let hue = HUE_START;
let speed = 0;
let pressed = false;
let demo = !reduce;
let t0: number | null = null;
let lastTs: number | null = null;
let raf = 0;
const paint = (): void => {
btn.style.setProperty("--hue", hue.toFixed(1));
hueEl.textContent = String(Math.round(hue));
speedEl.textContent = String(Math.round(speed));
};
const press = (): void => {
demo = false;
pressed = true;
if (reduce) { hue = (hue + HUE_STEP) % 360; paint(); }
};
const release = (): void => { pressed = false; };
const onDown = (e: Event): void => {
try { btn.setPointerCapture((e as PointerLike).pointerId); } catch (err) { /* 캡처를 못 걸어도 누름은 동작한다 */ }
press();
};
const onKeyDown = (e: Event): void => {
const k = e as KeyLike;
if ((k.key === " " || k.key === "Enter") && !k.repeat) press();
};
const onKeyUp = (e: Event): void => {
const k = e as KeyLike;
if (k.key === " " || k.key === "Enter") release();
};
btn.addEventListener("pointerdown", onDown);
btn.addEventListener("pointerup", release);
btn.addEventListener("pointercancel", release);
btn.addEventListener("blur", release);
btn.addEventListener("keydown", onKeyDown);
btn.addEventListener("keyup", onKeyUp);
const demoPressed = (now: number): boolean => demo && now >= START_MS && (now - START_MS) % CYCLE_MS < HOLD_MS;
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;
if (demoPressed(now) || pressed) {
speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
} else {
speed *= Math.exp(-FRICTION * dt);
if (speed < 0.5) speed = 0;
}
hue = (hue + speed * dt) % 360;
paint();
raf = requestAnimationFrame(tick);
}
if (reduce) paint();
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("pointerdown", onDown);
btn.removeEventListener("pointerup", release);
btn.removeEventListener("pointercancel", release);
btn.removeEventListener("blur", release);
btn.removeEventListener("keydown", onKeyDown);
btn.removeEventListener("keyup", onKeyUp);
};
}, []);
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}>HUE</span> <b ref={hueRef}>270</b><span>deg</span> <span className={styles.bc__tag}>SPEED</span> <b ref={speedRef}>0</b>
</p>
</div>
</main>
);
}06 · 누르고 있는 동안 색상이 가속하며 도는 버튼 — 측정 누적면적 15.999% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
if (demoPressed(now) || pressed) {
speed = Math.min(MAX_SPEED, speed + ACCEL * dt);
} else {
speed *= Math.exp(-FRICTION * dt);
if (speed < 0.5) speed = 0;
}
hue = (hue + speed * dt) % 360;
paint();
requestAnimationFrame(tick);
}
07바탕이 밝아지면 글자색이 뒤집히는 버튼
바탕이 움직이는 프레임마다 WCAG 식 상대 밝기를 재서 FLIP_THRESHOLD 를 넘으면 글자를 검정으로, 아니면 흰색으로 바꾼다. 흰색과 검정이 같은 대비를 내는 밝기는 √(1.05×0.05)−0.05 = 0.179 이고 그때 대비가 4.58:1 이며, 분홍에서 민트로 가는 경로 전체의 최저 대비는 4.61:1 이다. 07 항목은 누적면적 16.1%·강도 평균 82.1·움직인 프레임 23/23 이다.

<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">L</span> <b id="bcLum">0.01</b> <span class="bc__tag">RATIO</span> <b id="bcRatio">17.0</b><span>:1</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 바탕이 어두운 색에서 밝은 색으로 옮겨 가는 동안 글자색을 매 프레임 상대 밝기로 정한다.
// 글자 두 색은 순수한 흰색과 검정이다 — 둘이 같은 대비를 내는 밝기(0.179)에서 4.58:1 이라 어느 중간 색도 4.5:1 을 넘는다.
$text-pure-dark: #000;
@include stage($stage-paper);
.bc {
--c-from: #{$subject-ink};
--c-to: #{$subject-lilac};
--t-light: #{$stage-paper};
--t-dark: #{$text-pure-dark};
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: $subject-ink;
color: $stage-paper;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.bc__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: $sp-1;
font-size: 12px;
line-height: 16px;
color: rgba($subject-ink, .78);
}
.bc__tag { margin: 0 $sp-1 0 $sp-2; font-weight: 700; letter-spacing: .08em; }
.bc__tag:first-child { margin-left: 0; }
.bc__meta b { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; color: $subject-ink; }
@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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bc {
--c-from: #17141a;
--c-to: #b9a0ff;
--t-light: #ffffff;
--t-dark: #000;
width: min(100%, 320px);
display: grid;
gap: 12px;
justify-items: center;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffffff;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.bc__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.78);
}
.bc__tag {
margin: 0 4px 0 8px;
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__tag:first-child {
margin-left: 0;
}
.bc__meta b {
min-width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
// 기법: 바탕색이 어두운 색에서 밝은 색으로 옮겨 가는 동안 매 프레임 상대 밝기 L 을 직접 계산한다.
// L 이 FLIP_THRESHOLD 를 넘는 순간 글자를 흰색에서 검정으로 뒤집는다.
// 흰색과 검정이 같은 대비(4.58:1)를 내는 밝기가 0.179 라서, 이 기준이면 어느 중간 색에서도 4.5:1 아래로 떨어지지 않는다.
var FLIP_THRESHOLD = 0.179;
var FILL_RATE = 4;
var START_MS = 200;
var ON_MS = 1000;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var lumEl = document.getElementById('bcLum');
var ratioEl = document.getElementById('bcRatio');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function readRgb(name) {
var v = getComputedStyle(btn).getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.split('').map(function (c) { return c + c; }).join('');
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
function toHex(rgb) {
return '#' + rgb.map(function (c) { return ('0' + Math.round(c).toString(16)).slice(-2); }).join('');
}
function channel(c) {
var s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function luminance(rgb) {
return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
}
function ratio(a, b) {
var hi = Math.max(a, b);
var lo = Math.min(a, b);
return (hi + 0.05) / (lo + 0.05);
}
var from = readRgb('--c-from');
var to = readRgb('--c-to');
var textLight = readRgb('--t-light');
var textDark = readRgb('--t-dark');
var lumLight = luminance(textLight);
var lumDark = luminance(textDark);
var cur = from.slice();
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 goalOf(now) { return demoOn(now) || pointerIn || kbFocus ? to : from; }
function paint() {
var l = luminance(cur);
var dark = l > FLIP_THRESHOLD;
btn.style.background = toHex(cur);
btn.style.color = toHex(dark ? textDark : textLight);
lumEl.textContent = l.toFixed(2);
ratioEl.textContent = ratio(l, dark ? lumDark : lumLight).toFixed(1);
}
function stopDemo() { root.classList.remove('is-demo'); }
function jump() { cur = (pointerIn || kbFocus ? to : from).slice(); 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 = goalOf(now);
var k = 1 - Math.exp(-dt * FILL_RATE);
var gap = 0;
for (var i = 0; i < 3; i += 1) {
cur[i] += (g[i] - cur[i]) * k;
gap += Math.abs(g[i] - cur[i]);
}
if (gap < 0.5) cur = g.slice();
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// TextFlipButton — vanilla 판(07-btn-color-text-contrast-flip) 의 React 포트.
// 바탕색이 어두운 색에서 밝은 색으로 옮겨 가는 동안 상대 밝기 L 을 계산해 FLIP_THRESHOLD 를 넘으면 글자색을 뒤집는다.
import { useEffect, useRef } from "react";
import styles from "./TextFlipButton.module.scss";
type Box<T> = { current: T };
type Rgb = [number, number, number];
const FLIP_THRESHOLD = 0.179;
const FILL_RATE = 4;
const START_MS = 200;
const ON_MS = 1000;
const CYCLE_MS = 2000;
function toHex(rgb: Rgb): string {
return "#" + rgb.map((c) => ("0" + Math.round(c).toString(16)).slice(-2)).join("");
}
function channel(c: number): number {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function luminance(rgb: Rgb): number {
return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
}
function ratio(a: number, b: number): number {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
export default function TextFlipButton() {
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const lumRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const ratioRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const lumEl = lumRef.current;
const ratioEl = ratioRef.current;
if (!btn || !lumEl || !ratioEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const readRgb = (name: string): Rgb => {
let v = getComputedStyle(btn).getPropertyValue(name).trim().replace("#", "");
if (v.length === 3) v = v.split("").map((c) => c + c).join("");
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
const from = readRgb("--c-from");
const to = readRgb("--c-to");
const textLight = readRgb("--t-light");
const textDark = readRgb("--t-dark");
const lumLight = luminance(textLight);
const lumDark = luminance(textDark);
let cur: Rgb = [from[0], from[1], from[2]];
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 => {
const l = luminance(cur);
const dark = l > FLIP_THRESHOLD;
btn.style.background = toHex(cur);
btn.style.color = toHex(dark ? textDark : textLight);
lumEl.textContent = l.toFixed(2);
ratioEl.textContent = ratio(l, dark ? lumDark : lumLight).toFixed(1);
};
const stopDemo = (): void => { demo = false; };
const jump = (): void => {
const g = pointerIn || kbFocus ? to : from;
cur = [g[0], g[1], g[2]];
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 ? to : from;
const k = 1 - Math.exp(-dt * FILL_RATE);
const next: Rgb = [cur[0] + (g[0] - cur[0]) * k, cur[1] + (g[1] - cur[1]) * k, cur[2] + (g[2] - cur[2]) * k];
const gap = Math.abs(g[0] - next[0]) + Math.abs(g[1] - next[1]) + Math.abs(g[2] - next[2]);
cur = gap < 0.5 ? [g[0], g[1], g[2]] : next;
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} ref={rootRef}>
<button className={styles.bc__btn} type="button" ref={btnRef}>읽히는 글자</button>
<p className={styles.bc__meta}>
<span className={styles.bc__tag}>L</span> <b ref={lumRef}>0.01</b> <span className={styles.bc__tag}>RATIO</span> <b ref={ratioRef}>17.0</b><span>:1</span>
</p>
</div>
</main>
);
}07 · 바탕이 밝아지면 글자색이 뒤집히는 버튼 — 측정 누적면적 16.139% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function channel(c) {
var s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function luminance(rgb) {
return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
}
function ratio(a, b) {
var hi = Math.max(a, b);
var lo = Math.min(a, b);
return (hi + 0.05) / (lo + 0.05);
}
function paint() {
var l = luminance(cur);
var dark = l > FLIP_THRESHOLD;
btn.style.background = toHex(cur);
btn.style.color = toHex(dark ? textDark : textLight);
lumEl.textContent = l.toFixed(2);
ratioEl.textContent = ratio(l, dark ? lumDark : lumLight).toFixed(1);
}
08기울어진 빛 띠가 색 위를 훑는 광택
hover 가 켜지는 순간마다 105도로 기울인 밝은 띠가 BAND_FROM 에서 BAND_TO 까지 700ms 동안 한 번 지나가고, 세기는 PEAK_GLOW * sin(π p) 로 올랐다 내려온다. 위치는 smoothstep 이라 양 끝에서 느려지고 스윕이 끝나면 세기가 0 으로 돌아가 원래 색만 남는다. 08 항목은 누적면적 13.6%·강도 평균 72.3·움직인 프레임 12/23 이다.

<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">BAND</span> <b id="bcX">-30</b><span>%</span> <span class="bc__tag">GLOW</span> <b id="bcA">0.00</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 밝은 띠는 별도 요소가 아니라 같은 background 의 위 겹 그라데이션이다. 위치(--x)와 세기(--glow)는 JS 가 매 프레임 계산한다.
$sheen-a: $subject-lilac;
$sheen-b: $subject-pink;
$sheen-skew: 105deg;
$sheen-half: 16%;
@include stage($stage-ink);
.bc {
width: min(100%, 320px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.bc__btn {
--x: -30;
--glow: 0;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background:
linear-gradient(
$sheen-skew,
rgba(255, 255, 255, 0) calc(var(--x) * 1% - #{$sheen-half}),
rgba(255, 255, 255, var(--glow)) calc(var(--x) * 1%),
rgba(255, 255, 255, 0) calc(var(--x) * 1% + #{$sheen-half})
),
linear-gradient(100deg, $sheen-a, $sheen-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;
flex-wrap: wrap;
justify-content: center;
gap: $sp-1;
font-size: 12px;
line-height: 16px;
color: rgba($stage-paper, .78);
}
.bc__tag { margin: 0 $sp-1 0 $sp-2; font-weight: 700; letter-spacing: .08em; }
.bc__tag:first-child { margin-left: 0; }
.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 {
--x: -30;
--glow: 0;
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: linear-gradient(105deg, rgba(255, 255, 255, 0) calc(var(--x) * 1% - 16%), rgba(255, 255, 255, var(--glow)) calc(var(--x) * 1%), rgba(255, 255, 255, 0) calc(var(--x) * 1% + 16%)), linear-gradient(100deg, #b9a0ff, #ff9ebb);
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;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(255, 255, 255, 0.78);
}
.bc__tag {
margin: 0 4px 0 8px;
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__tag:first-child {
margin-left: 0;
}
.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 () {
// 기법: 기울어진 밝은 띠의 위치(--x)와 세기(--glow)를 JS 가 계산한다. hover 가 시작되면 SWEEP_MS 동안 띠가 한 번 지나간다.
// 위치는 smoothstep 곡선, 세기는 사인 반 주기라서 띠가 들어올 때 서서히 밝아지고 나갈 때 서서히 사라진다.
var SWEEP_MS = 700;
var BAND_FROM = -30;
var BAND_TO = 130;
var PEAK_GLOW = 0.7;
var START_MS = 200;
var ON_MS = 900;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var xEl = document.getElementById('bcX');
var aEl = document.getElementById('bcA');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pointerIn = false;
var kbFocus = false;
var wasOn = false;
var sweepAt = -1;
var t0 = null;
function demoOn(now) {
return root.classList.contains('is-demo') && now >= START_MS && (now - START_MS) % CYCLE_MS < ON_MS;
}
function paint(x, glow) {
btn.style.setProperty('--x', x.toFixed(1));
btn.style.setProperty('--glow', glow.toFixed(3));
xEl.textContent = String(Math.round(x));
aEl.textContent = glow.toFixed(2);
}
function stopDemo() { root.classList.remove('is-demo'); }
function staticState() {
if (pointerIn || kbFocus) paint(50, 0.5); else paint(BAND_FROM, 0);
}
btn.addEventListener('pointerenter', function () { stopDemo(); pointerIn = true; if (reduce) staticState(); });
btn.addEventListener('pointerleave', function () { pointerIn = false; if (reduce) staticState(); });
btn.addEventListener('focus', function () { kbFocus = btn.matches(':focus-visible'); if (kbFocus) stopDemo(); if (reduce) staticState(); });
btn.addEventListener('blur', function () { kbFocus = false; if (reduce) staticState(); });
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var on = demoOn(now) || pointerIn || kbFocus;
if (on && !wasOn) sweepAt = now;
wasOn = on;
var p = sweepAt < 0 ? 1 : Math.min(1, (now - sweepAt) / SWEEP_MS);
var eased = p * p * (3 - 2 * p);
if (p >= 1) paint(BAND_FROM, 0);
else paint(BAND_FROM + (BAND_TO - BAND_FROM) * eased, PEAK_GLOW * Math.sin(Math.PI * p));
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
staticState();
} else {
requestAnimationFrame(tick);
}
})();// SheenSweepButton — vanilla 판(08-btn-gradient-sheen-sweep-skew) 의 React 포트.
// hover 가 시작되면 기울어진 밝은 띠가 SWEEP_MS 동안 한 번 지나간다. 위치는 smoothstep, 세기는 사인 반 주기.
import { useEffect, useRef } from "react";
import styles from "./SheenSweepButton.module.scss";
type Box<T> = { current: T };
const SWEEP_MS = 700;
const BAND_FROM = -30;
const BAND_TO = 130;
const PEAK_GLOW = 0.7;
const START_MS = 200;
const ON_MS = 900;
const CYCLE_MS = 2000;
export default function SheenSweepButton() {
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const xRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
const aRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const xEl = xRef.current;
const aEl = aRef.current;
if (!btn || !xEl || !aEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let pointerIn = false;
let kbFocus = false;
let wasOn = false;
let sweepAt = -1;
let t0: number | null = null;
let raf = 0;
const demoOn = (now: number): boolean => demo && now >= START_MS && (now - START_MS) % CYCLE_MS < ON_MS;
const paint = (x: number, glow: number): void => {
btn.style.setProperty("--x", x.toFixed(1));
btn.style.setProperty("--glow", glow.toFixed(3));
xEl.textContent = String(Math.round(x));
aEl.textContent = glow.toFixed(2);
};
const stopDemo = (): void => { demo = false; };
const staticState = (): void => {
if (pointerIn || kbFocus) paint(50, 0.5);
else paint(BAND_FROM, 0);
};
const onEnter = (): void => { stopDemo(); pointerIn = true; if (reduce) staticState(); };
const onLeave = (): void => { pointerIn = false; if (reduce) staticState(); };
const onFocus = (): void => { kbFocus = btn.matches(":focus-visible"); if (kbFocus) stopDemo(); if (reduce) staticState(); };
const onBlur = (): void => { kbFocus = false; if (reduce) staticState(); };
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 on = demoOn(now) || pointerIn || kbFocus;
if (on && !wasOn) sweepAt = now;
wasOn = on;
const p = sweepAt < 0 ? 1 : Math.min(1, (now - sweepAt) / SWEEP_MS);
const eased = p * p * (3 - 2 * p);
if (p >= 1) paint(BAND_FROM, 0);
else paint(BAND_FROM + (BAND_TO - BAND_FROM) * eased, PEAK_GLOW * Math.sin(Math.PI * p));
raf = requestAnimationFrame(tick);
}
if (reduce) staticState();
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}>BAND</span> <b ref={xRef}>-30</b><span>%</span> <span className={styles.bc__tag}>GLOW</span> <b ref={aRef}>0.00</b>
</p>
</div>
</main>
);
}08 · 기울어진 빛 띠가 색 위를 훑는 광택 — 측정 누적면적 13.553% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function tick(ts) {
if (t0 === null) t0 = ts;
var now = ts - t0;
var on = demoOn(now) || pointerIn || kbFocus;
if (on && !wasOn) sweepAt = now;
wasOn = on;
var p = sweepAt < 0 ? 1 : Math.min(1, (now - sweepAt) / SWEEP_MS);
var eased = p * p * (3 - 2 * p);
if (p >= 1) paint(BAND_FROM, 0);
else paint(BAND_FROM + (BAND_TO - BAND_FROM) * eased, PEAK_GLOW * Math.sin(Math.PI * p));
requestAnimationFrame(tick);
}
09바탕색을 따라 그림자 색이 번지는 버튼
진행도 p 하나로 바탕색·그림자 색·그림자 높이·번짐 반경을 함께 정하고, 그림자 색은 바탕색 각 채널에 SHADOW_DARKEN 을 곱한 값이다. 높이는 4px 에서 14px, 번짐은 10px 에서 28px 로 p 에 비례해 올라 색이 목표에 가까워질수록 버튼이 바닥에서 뜬다. 09 항목은 누적면적 23.9%·강도 평균 90.1·움직인 프레임 21/23 이다.

<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">SHADOW</span> <span id="bcHex">#6f6099</span> <span class="bc__tag">Y</span> <b id="bcY">4</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 바탕색·그림자 색·그림자 높이를 진행도 하나가 함께 정한다. 그림자 색은 지금 바탕색을 어둡게 곱해 만든 값이다.
$glow-from: $subject-lilac;
$glow-to: $subject-pink;
@include stage($stage-paper);
.bc {
--c-from: #{$glow-from};
--c-to: #{$glow-to};
width: min(100%, 320px);
display: grid;
gap: $sp-6;
justify-items: center;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: $r-control;
background: $glow-from;
box-shadow: 0 4px 10px rgba(111, 96, 153, .55);
color: $subject-ink;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.bc__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 3px; }
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: $sp-1;
font-size: 12px;
line-height: 16px;
color: rgba($subject-ink, .78);
}
.bc__tag { margin: 0 $sp-1 0 $sp-2; font-weight: 700; letter-spacing: .08em; }
.bc__tag:first-child { margin-left: 0; }
.bc__meta b { min-width: 2ch; text-align: right; font-variant-numeric: tabular-nums; color: $subject-ink; }
@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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bc {
--c-from: #b9a0ff;
--c-to: #ff9ebb;
width: min(100%, 320px);
display: grid;
gap: 24px;
justify-items: center;
}
.bc__btn {
box-sizing: border-box;
width: 100%;
height: 72px;
border: 0;
border-radius: 12px;
background: #b9a0ff;
box-shadow: 0 4px 10px rgba(111, 96, 153, 0.55);
color: #17141a;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.bc__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.bc__meta {
margin: 0;
display: flex;
align-items: baseline;
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(23, 20, 26, 0.78);
}
.bc__tag {
margin: 0 4px 0 8px;
font-weight: 700;
letter-spacing: 0.08em;
}
.bc__tag:first-child {
margin-left: 0;
}
.bc__meta b {
min-width: 2ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.bc__btn {
transition: none;
}
}(function () {
// 기법: 진행도 p 하나가 바탕색·그림자 색·그림자 높이를 함께 정한다. 바탕색은 from→to 로 섞고,
// 그림자 색은 지금 바탕색의 각 채널에 SHADOW_DARKEN 을 곱해 만든다. p 는 목표로 일정 비율씩 끌린다.
var SHADOW_DARKEN = 0.6;
var SHADOW_ALPHA = 0.55;
var LIFT_REST = 4;
var LIFT_HOVER = 14;
var BLUR_REST = 10;
var BLUR_HOVER = 28;
var GLOW_RATE = 6;
var START_MS = 200;
var ON_MS = 1000;
var CYCLE_MS = 2000;
var root = document.getElementById('bcRoot');
var btn = document.getElementById('bcBtn');
var hexEl = document.getElementById('bcHex');
var yEl = document.getElementById('bcY');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function readRgb(name) {
var v = getComputedStyle(btn).getPropertyValue(name).trim().replace('#', '');
if (v.length === 3) v = v.split('').map(function (c) { return c + c; }).join('');
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
}
function toHex(rgb) {
return '#' + rgb.map(function (c) { return ('0' + Math.round(c).toString(16)).slice(-2); }).join('');
}
var from = readRgb('--c-from');
var to = readRgb('--c-to');
var p = 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 paint() {
var fill = from.map(function (v, i) { return v + (to[i] - v) * p; });
var shade = fill.map(function (v) { return v * SHADOW_DARKEN; });
var lift = LIFT_REST + (LIFT_HOVER - LIFT_REST) * p;
var blur = BLUR_REST + (BLUR_HOVER - BLUR_REST) * p;
btn.style.background = toHex(fill);
btn.style.boxShadow = '0 ' + lift.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(' +
Math.round(shade[0]) + ', ' + Math.round(shade[1]) + ', ' + Math.round(shade[2]) + ', ' + SHADOW_ALPHA + ')';
hexEl.textContent = toHex(shade);
yEl.textContent = String(Math.round(lift));
}
function stopDemo() { root.classList.remove('is-demo'); }
function jump() { p = pointerIn || kbFocus ? 1 : 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 = demoOn(now) || pointerIn || kbFocus ? 1 : 0;
p += (g - p) * (1 - Math.exp(-dt * GLOW_RATE));
if (Math.abs(g - p) < 0.002) p = g;
paint();
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
paint();
} else {
requestAnimationFrame(tick);
}
})();// ShadowFollowButton — vanilla 판(09-btn-color-shadow-follow-glow) 의 React 포트.
// 진행도 p 하나가 바탕색·그림자 색·그림자 높이를 함께 정한다. 그림자 색은 지금 바탕색에 SHADOW_DARKEN 을 곱한 값이다.
import { useEffect, useRef } from "react";
import styles from "./ShadowFollowButton.module.scss";
type Box<T> = { current: T };
type Rgb = [number, number, number];
const SHADOW_DARKEN = 0.6;
const SHADOW_ALPHA = 0.55;
const LIFT_REST = 4;
const LIFT_HOVER = 14;
const BLUR_REST = 10;
const BLUR_HOVER = 28;
const GLOW_RATE = 6;
const START_MS = 200;
const ON_MS = 1000;
const CYCLE_MS = 2000;
function toHex(rgb: Rgb): string {
return "#" + rgb.map((c) => ("0" + Math.round(c).toString(16)).slice(-2)).join("");
}
export default function ShadowFollowButton() {
const btnRef = useRef<HTMLButtonElement | null>(null) as Box<HTMLButtonElement | null>;
const hexRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const yRef = useRef<HTMLElement | null>(null) as Box<HTMLElement | null>;
useEffect(() => {
const btn = btnRef.current;
const hexEl = hexRef.current;
const yEl = yRef.current;
if (!btn || !hexEl || !yEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const readRgb = (name: string): Rgb => {
let v = getComputedStyle(btn).getPropertyValue(name).trim().replace("#", "");
if (v.length === 3) v = v.split("").map((c) => c + c).join("");
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)];
};
const from = readRgb("--c-from");
const to = readRgb("--c-to");
let p = 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 => {
const fill: Rgb = [from[0] + (to[0] - from[0]) * p, from[1] + (to[1] - from[1]) * p, from[2] + (to[2] - from[2]) * p];
const shade: Rgb = [fill[0] * SHADOW_DARKEN, fill[1] * SHADOW_DARKEN, fill[2] * SHADOW_DARKEN];
const lift = LIFT_REST + (LIFT_HOVER - LIFT_REST) * p;
const blur = BLUR_REST + (BLUR_HOVER - BLUR_REST) * p;
btn.style.background = toHex(fill);
btn.style.boxShadow = "0 " + lift.toFixed(1) + "px " + blur.toFixed(1) + "px rgba(" +
Math.round(shade[0]) + ", " + Math.round(shade[1]) + ", " + Math.round(shade[2]) + ", " + SHADOW_ALPHA + ")";
hexEl.textContent = toHex(shade);
yEl.textContent = String(Math.round(lift));
};
const stopDemo = (): void => { demo = false; };
const jump = (): void => { p = pointerIn || kbFocus ? 1 : 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 ? 1 : 0;
p += (g - p) * (1 - Math.exp(-dt * GLOW_RATE));
if (Math.abs(g - p) < 0.002) p = g;
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}>SHADOW</span> <span ref={hexRef}>#6f6099</span> <span className={styles.bc__tag}>Y</span> <b ref={yRef}>4</b>
</p>
</div>
</main>
);
}09 · 바탕색을 따라 그림자 색이 번지는 버튼 — 측정 누적면적 23.888% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function paint() {
var fill = from.map(function (v, i) { return v + (to[i] - v) * p; });
var shade = fill.map(function (v) { return v * SHADOW_DARKEN; });
var lift = LIFT_REST + (LIFT_HOVER - LIFT_REST) * p;
var blur = BLUR_REST + (BLUR_HOVER - BLUR_REST) * p;
btn.style.background = toHex(fill);
btn.style.boxShadow = '0 ' + lift.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(' +
Math.round(shade[0]) + ', ' + Math.round(shade[1]) + ', ' + Math.round(shade[2]) + ', ' + SHADOW_ALPHA + ')';
hexEl.textContent = toHex(shade);
yEl.textContent = String(Math.round(lift));
}
값이 장면을 바꾸는 곳 — 끌림 비율·스프링 세기·밝기 기준
세 상수가 색이 도착하는 속도와 흔들림, 글자가 읽히는 구간을 거의 다 정한다. 표의 수치는 아홉 항목을 각각 렌더해 23개 프레임 간격마다 잰 값이다.
| 항목 | 누적면적 % | 강도 평균 | 움직인 프레임 |
|---|---|---|---|
| 01 기본 끌림 | 16.2% | 114.6 | 22/23 |
| 02 밝기 보정 섞기 | 18.1% | 48.3 | 21/23 |
| 03 스프링 경계 | 13.7% | 92.8 | 20/23 |
| 04 겹 크로스페이드 | 17.3% | 97.3 | 22/23 |
| 05 포인터 각도 | 14.7% | 132.5 | 23/23 |
| 06 가속 색상 | 16.0% | 77.4 | 23/23 |
| 07 글자 뒤집힘 | 16.1% | 82.1 | 23/23 |
| 08 빛 띠 광택 | 13.6% | 72.3 | 12/23 |
| 09 그림자 번짐 | 23.9% | 90.1 | 21/23 |
누적면적이 10% 아래인 항목은 없고, 08 이 12/23 으로 낮은 것은 hover 한 번에 띠가 700ms 동안 한 번만 지나가고 그 뒤에는 멈추기 때문이다. 02 의 강도가 가장 낮은 것도 두 버튼의 색 차이가 크지 않은 한 쌍이라서 정상이다.
끌림 비율이 도착 속도를 정한다
01 의 PULL_RATE 7 은 시정수가 약 143ms 라서 색이 절반쯤은 금방 도착하고 끝이 길게 끌린다. 값을 올릴수록 도착이 빨라지지만 색 변화가 한눈에 읽히는 구간이 짧아진다.
스프링 세기가 오버슛을 정한다
03 은 STIFFNESS 170 에서 목표 70% 를 11.1 지나쳐 최고 81.1 까지 갔다가 약 0.76초 안에 자리를 잡는다. 같은 DAMPING 11 에서 세기를 400 으로 올리면 오버슛이 19.9 로 커지고, 50 으로 낮추면 0.8 만 넘는다.
밝기 기준이 읽히는 구간을 정한다
07 의 FLIP_THRESHOLD 는 흰색과 검정이 같은 대비를 내는 0.179 가 기준이다. 이 값에서 벗어날수록 글자가 뒤집히기 전에 한쪽 색 위에서 대비가 급히 떨어진다.
어디서 깨지는가 — 빠른 hover 반복·탁한 중간색·읽히지 않는 글자
빠른 hover 반복에서 오버슛이 겹친다
01·09 는 상태가 항상 현재 값에서 이어지므로 올렸다 내리기를 반복해도 끊기지 않는다. 03 만은 속도 vel 이 남은 채 다시 들어오면 오버슛이 겹쳐 처음보다 크게 튄다.
그냥 섞은 중간색은 양 끝보다 어두워진다
02 의 왼쪽 버튼은 중간에서 밝기가 양 끝 아래로 꺼지고, hover 도중 색이 탁해 보이는 것은 이 밝기 저하와 맞물린다. 두 색의 채도는 중간에서도 약 0.12 와 0.11 로 비슷하니, 보정의 효과는 선명함이 아니라 중간에서 밝기가 꺼지지 않는다는 데 있다.
토큰 잉크 글자는 중간 구간에서 4.5 에 못 미친다
07 은 처음에 사이트 토큰의 잉크색 글자를 썼더니 크로스오버 구간의 대비가 4.15~4.27:1 로 기준 4.5 에 못 미쳐, 순수 흰색과 검정으로 바꿨다. 기준값을 0.08 로 낮추면 최저 대비가 2.64:1 로, 0.30 으로 올리면 3.01:1 로 내려가 읽기 어려운 구간이 생긴다.
각도의 경계를 접지 않으면 반대로 돈다
05 에서 접기 식을 빼면 350도에서 10도로 가는 길에 20도가 아닌 340도를 거꾸로 돌아 그라데이션이 한 바퀴 휘돈다. 각도를 쓰는 보간에는 항상 이 접기를 같이 둔다.
코드 전체와 SCSS 를 직접 만져 보려면 압축 파일을 내려받아 비밀번호 82z8gdmt 으로 열면 되고, 아홉 폴더 각각에 데모 페이지와 스타일이 들어 있다.
변형 세 가지와 접근성
| 항목 | 상수 | 값 | 느낌 |
|---|---|---|---|
| 01 | PULL_RATE | 7 → 14 | 시정수가 약 143ms 에서 71ms 로 줄어 색이 거의 즉시 도착하고, 낮추면 끝이 길게 끌린다 |
| 03 | STIFFNESS | 170 → 400 | 경계가 목표를 11.1 이 아니라 19.9 만큼 지나쳤다 돌아오고, 50 이면 0.8 만 넘어 거의 흔들림 없이 멈춘다 |
| 07 | FLIP_THRESHOLD | 0.179 → 0.08 | 글자가 일찍 검정이 되어 대비가 최저 2.64:1 까지 떨어지고, 0.30 이면 3.01:1 까지 내려간다 |
모든 장면은 네이티브 button 이라 Tab 으로 닿고, 01·02·03·04·07·08·09 는 :focus-visible 일 때 hover 와 같은 목표로 움직이며 06 은 Space·Enter 로 누른다. 05 는 포인터 방향이 정보라 키보드 목표가 없고 글자와 3px 포커스 링만 유지한다. prefers-reduced-motion 이 reduce 이면 루프를 돌리지 않고 입력이 오는 순간 목표 값을 바로 칠한다. 그래서 01·02·03·04·07·09 는 최종 색으로 점프하고 05 는 포인터 방향 각도로 즉시 바뀌며 06 은 누를 때마다 60도씩 뛰고 08 은 hover 중 정지한 띠만 남는다. 색 변화는 장식이라 aria-live 를 쓰지 않았고, 읽어야 하는 정보인 글자 대비는 07 이 linear-gradient 위가 아니라 계산 쪽에서 지킨다.
css 버튼 색상 변경은 transition 한 줄로 충분한가?
색 한 쌍을 단순하게 바꾸는 버튼이라면 한 줄로 충분하다. 다만 그 줄은 중간 색의 밝기와 그라데이션, 글자 대비를 따로 챙기지 않으므로 02·04·07 처럼 그 틈이 보이는 화면에서만 계산을 얹으면 된다.
색 말고 다른 반응이 필요하면 이웃 편을 이어 읽자. 클릭한 뒤의 상태를 다루는 css 버튼 클릭 효과 9종 — 누른 다음이 진짜다와, 키프레임 기반 복붙 버튼 모음인 CSS 버튼 애니메이션 9종 — 복붙하면 되는 SCSS + React가 맞다.
css 버튼 색상 변경을 쓰기 전에 자주 묻는 질문
css 버튼 그라데이션은 transition 으로 움직일 수 있나요?
이 편의 그라데이션 장면은 transition 에 맡기지 않고 JS 가 계산한 숫자로 옮긴다. 04 는 겹을 포개 위 겹의 불투명도를 옮기고, 03 은 정지점 위치를 숫자로 움직이며, 05 는 각도를 숫자로 움직인다.
그라데이션 버튼의 hover 색은 어떻게 고르나요?
두 색의 상대 밝기가 비슷하면 중간이 어두워지기 쉬우므로 02 처럼 밝기 보정 섞기를 먼저 보고 고른다. 바탕이 크게 밝아지는 쌍이면 07 처럼 글자색도 계산하게 둔다.
hover 색 바꾸기를 다른 입력에도 쓸 수 있나요?
포인터가 아닌 입력은 목표 값만 바꾸면 되어서 06 은 누름, 05 는 포인터 방향을 입력으로 받는다. 어떤 입력이든 현재 값에서 목표로 옮기는 계산 자체는 01 과 같다.









