02Gamma-Corrected Mix Beside a Plain Mix
Between the same pink and mint, the left button blends raw values and the right one blends in linear light, so one screen shows the midpoint that dips below both ends in brightness beside the one that stays between them.
Best forChecking why the midpoint dulls when pink turns mint
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">Subscribe</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">Subscribe</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 Gamma-Corrected Mix Beside a Plain Mix — Measured area 18.131% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · CSS Button Color Transition: 9 Hover Demos
The post this demo lives in
CSS Button Color Transition: 9 Hover DemosOne line recolors a button on hover, yet it muddies the midpoint, cannot move a gradient and loses the label midway — sc See all demos in this post


