09바탕색을 따라 그림자 색이 번지는 버튼
hover 로 바탕색이 바뀌는 동안 그림자 색도 같은 계산값에 0.6 을 곱해 따라 바뀌고, 그림자 높이는 색이 목표에 가까워질수록 함께 올라간다.
쓰임색이 바뀌는 버튼이 바닥에서 뜬 것처럼 보이게 하기
SHADOW_DARKENSHADOW_ALPHALIFT_HOVERBLUR_HOVERGLOW_RATE

<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 포함 · css 버튼 색상 변경 9가지 — hover 중간 색이 탁해지지 않게 계산한 장면
이 데모가 속한 글
css 버튼 색상 변경 9가지 — hover 중간 색이 탁해지지 않게 계산한 장면버튼 색을 hover 에서 바꾸는 건 한 줄이면 되지만, 그 줄은 중간 색을 탁하게 만들고 그라데이션은 아예 움직이지 못하게 하고 글자는 중간에 안 읽히게 둔다 — 02번·04번·07번이 하나씩 막는다. css 버튼 이 글의 데모 전부 보기


