02明るさ補正の混合と素の混合を並べる
同じピンクとミントの間を左のボタンは値のまま、右は明るさを線形に戻して混ぜ、ホバー途中の色の明るさが両端より沈む側と両端の間にとどまる側を一画面で比べる。
使いどころピンクからミントに変わるとき中間色がくすむ問題の確認
MIX_RATEtoLineartoGammamixLinear

<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 オリジナル · SCSS と React は zip に同梱 · css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面
このデモが属する記事
css ボタン 色 変更 9パターン — ホバー途中の色が濁らないよう計算した場面ボタンの色をホバーで変えるのは一行で済むが、その一行は途中の色を濁らせ、グラデーションを動かせず、文字を途中で読めなくする — 02番・04番・07番がそれを一つずつ塞ぐ。css ボタン 色 変更は、ホバーや押下に合わせて背景色やグラデーシ この記事のデモをすべて見る


