08色温度トラック — 値比率を色相・彩度補間に変えて染まるトラック
値比率を色補間関数(HSLに変換し色相・彩度のみ補間)へ通して毎フレームトラック背景とつまみを塗り直すと、冷たい青から暖かい橙へ染まる温度スライダーになります。値→色の対応表をCSS変数1つで渡し、全項目が同じパレットを共有します。
使いどころ照明・ディスプレイ設定
linear-gradientCSS custom propertiespointermove

<main class="stage">
<div class="ct" id="ct">
<p class="ct__hint">値の比率を毎フレームHSLカラーに変換し、トラックとサムを冷たい青から暖かいオレンジへ染めます</p>
<div class="ct__wrap" id="ctWrap" role="img" aria-label="値に応じて色温度が青からオレンジへ変わるスライダーの場面">
<input class="ct__range" id="ctRange" type="range" min="0" max="100" step="1" value="50" aria-label="色温度">
</div>
<p class="ct__meta"><b class="ct__tag" id="ctHue">hue 122</b><span class="ct__unit">HSL補間</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 색온도 그라디언트(08, 아이디어형·기능색) — 색 자체가 값을 나타내는 편이라
// hue 는 JS 가 매 프레임 계산해 --hue·--sat 커스텀 프로퍼티로 넘긴다. 트랙·썸은
// hsl(var(--hue) var(--sat)% L%) 로 그 값을 그대로 그린다 — 정적 토큰이 아니다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
$track-h: 8px;
$thumb-d: 20px;
.ct {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 360px;
}
.ct__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.ct__wrap {
position: relative; width: 100%; height: 40px;
display: flex; align-items: center;
--pct: 50; --hue: 165; --sat: 66;
}
.ct__range {
appearance: none; -webkit-appearance: none;
width: 100%; height: $thumb-d; margin: 0; background: transparent;
}
.ct__range:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-pill; }
.ct__range::-webkit-slider-runnable-track {
height: $track-h; border-radius: $r-pill;
background: linear-gradient(
to right,
hsl(calc(var(--hue) * 1deg) calc(var(--sat) * 1%) 50%) calc(var(--pct) * 1%),
rgba($subject-ink, .14) calc(var(--pct) * 1%)
);
}
.ct__range::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: $thumb-d; height: $thumb-d; border-radius: 50%;
background: hsl(calc(var(--hue) * 1deg) calc(var(--sat) * 1%) 50%);
box-shadow: $shadow-press;
margin-top: calc(($track-h - $thumb-d) / 2);
}
.ct__range::-moz-range-track {
height: $track-h; border-radius: $r-pill; background: rgba($subject-ink, .14);
}
.ct__range::-moz-range-progress {
height: $track-h; border-radius: $r-pill;
background: hsl(calc(var(--hue) * 1deg) calc(var(--sat) * 1%) 50%);
}
.ct__range::-moz-range-thumb {
width: $thumb-d; height: $thumb-d; border: 0; border-radius: 50%;
background: hsl(calc(var(--hue) * 1deg) calc(var(--sat) * 1%) 50%);
box-shadow: $shadow-press;
}
.ct__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .82); }
.ct__tag { font-weight: 700; color: $color; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.ct__unit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.ct { gap: $sp-2; max-width: 280px; }
.ct__hint { font-size: 8px; }
.ct__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.ct__range { 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);
}
.ct {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.ct__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.ct__wrap {
position: relative;
width: 100%;
height: 40px;
display: flex;
align-items: center;
--pct: 50;
--hue: 165;
--sat: 66;
}
.ct__range {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 20px;
margin: 0;
background: transparent;
}
.ct__range:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
border-radius: 999px;
}
.ct__range::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, hsl(calc(var(--hue) * 1deg), calc(var(--sat) * 1%), 50%) calc(var(--pct) * 1%), rgba(23, 20, 26, 0.14) calc(var(--pct) * 1%));
}
.ct__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: hsl(calc(var(--hue) * 1deg), calc(var(--sat) * 1%), 50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
margin-top: -6px;
}
.ct__range::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.ct__range::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: hsl(calc(var(--hue) * 1deg), calc(var(--sat) * 1%), 50%);
}
.ct__range::-moz-range-thumb {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: hsl(calc(var(--hue) * 1deg), calc(var(--sat) * 1%), 50%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ct__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.82);
}
.ct__tag {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.ct__unit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.ct {
gap: 8px;
max-width: 280px;
}
.ct__hint {
font-size: 8px;
}
.ct__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ct__range {
transition: none;
}
}// 色温度 그라디언트(08, 아이디어형) — 값 비율(0~1)을 hue 로 선형 보간한다.
// 차가운 파랑(HUE_COLD=210) → 따뜻한 주황(HUE_WARM=28). 매 프레임 --hue·--sat
// CSS 커스텀 프로퍼티를 갱신해 트랙 채움색과 썸 배경이 값과 함께 실시간으로
// 물든다. CSS @keyframes 는 없다 — 색은 매 프레임 계산으로 나온다.
(function () {
var root = document.getElementById('ct');
var wrap = document.getElementById('ctWrap');
var range = document.getElementById('ctRange');
var hueEl = document.getElementById('ctHue');
if (!root || !wrap || !range || !hueEl) return;
var MIN = 0, MAX = 100;
var HUE_COLD = 210, HUE_WARM = 28;
var SAT_MIN = 55, SAT_MAX = 78;
var TAU = 0.14;
function paint(v) {
var ratio = (v - MIN) / (MAX - MIN);
var hue = HUE_COLD + (HUE_WARM - HUE_COLD) * ratio;
var sat = SAT_MIN + (SAT_MAX - SAT_MIN) * ratio;
wrap.style.setProperty('--pct', (ratio * 100).toFixed(2));
wrap.style.setProperty('--hue', hue.toFixed(1));
wrap.style.setProperty('--sat', sat.toFixed(1));
hueEl.textContent = 'hue ' + Math.round(hue);
range.setAttribute('aria-valuenow', String(Math.round(v)));
}
var current = 50;
paint(current);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
function stop() { playing = false; }
range.addEventListener('pointerdown', stop);
range.addEventListener('keydown', stop);
range.addEventListener('input', function () { current = Number(range.value); paint(current); });
var targets = [50, 5, 92, 18, 78, 50];
var seg = 0, segFrom = current;
var SEG_MS = 400;
var segStart = performance.now();
if (reduce) {
current = targets[0];
range.value = String(current);
paint(current);
} else {
(function step(now) {
if (playing) {
var elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % targets.length;
segStart = now; elapsed = 0; segFrom = current;
}
var target = targets[seg];
var t = elapsed / 1000;
var k = 1 - Math.exp(-t / TAU);
current = segFrom + (target - segFrom) * k;
range.value = String(Math.round(current));
paint(current);
}
requestAnimationFrame(step);
})(segStart);
}
})();// RangeSliderColorTemperatureGradient — vanilla 판(run/334/demo/08-range-slider-color-temperature-gradient/index.html)
// 의 React 포트. 값 비율(0~1)을 hue 로 선형 보간한다: 차가운 파랑(HUE_COLD=210) → 따뜻한
// 주황(HUE_WARM=28). 매 프레임 --hue·--sat CSS 커스텀 프로퍼티를 갱신해 트랙 채움색과
// 썸 배경이 값과 함께 실시간으로 물든다. CSS @keyframes 는 없다 — 색은 매 프레임 계산으로 나온다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderColorTemperatureGradient.module.scss";
export type RangeSliderColorTemperatureGradientProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "값 비율을 HSL 색상으로 매 프레임 환산해 트랙과 썸을 차가운 파랑에서 따뜻한 주황으로 물들입니다";
const ARIA_SCENE = "값에 따라 색온도가 파랑에서 주황으로 바뀌는 슬라이더 장면";
const LABEL_RANGE = "색온도";
const UNIT = "HSL 보간";
export default function RangeSliderColorTemperatureGradient({
duration = "220ms",
easing = "ease-out",
color = "#0a4fe8"
}: RangeSliderColorTemperatureGradientProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const rangeRef = useRef<HTMLInputElement | null>(null);
const hueRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const root = rootRef.current;
const wrap = wrapRef.current;
const range = rangeRef.current;
const hueEl = hueRef.current;
if (!root || !wrap || !range || !hueEl) return;
const MIN = 0, MAX = 100;
const HUE_COLD = 210, HUE_WARM = 28;
const SAT_MIN = 55, SAT_MAX = 78;
const TAU = 0.14;
function paint(v: number): void {
const ratio = (v - MIN) / (MAX - MIN);
const hue = HUE_COLD + (HUE_WARM - HUE_COLD) * ratio;
const sat = SAT_MIN + (SAT_MAX - SAT_MIN) * ratio;
wrap!.style.setProperty("--pct", (ratio * 100).toFixed(2));
wrap!.style.setProperty("--hue", hue.toFixed(1));
wrap!.style.setProperty("--sat", sat.toFixed(1));
hueEl!.textContent = "hue " + Math.round(hue);
range!.setAttribute("aria-valuenow", String(Math.round(v)));
}
let current = 50;
paint(current);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
const stop = (): void => {
playing = false;
};
const onInput = (): void => {
current = Number(range.value);
paint(current);
};
range.addEventListener("pointerdown", stop);
range.addEventListener("keydown", stop);
range.addEventListener("input", onInput);
const targets = [50, 5, 92, 18, 78, 50];
let seg = 0, segFrom = current;
const SEG_MS = 400;
let segStart = performance.now();
if (reduce) {
current = targets[0];
range.value = String(current);
paint(current);
} else {
const step = (now: number): void => {
if (playing) {
let elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % targets.length;
segStart = now;
elapsed = 0;
segFrom = current;
}
const target = targets[seg];
const t = elapsed / 1000;
const k = 1 - Math.exp(-t / TAU);
current = segFrom + (target - segFrom) * k;
range.value = String(Math.round(current));
paint(current);
}
rafRef.current = requestAnimationFrame(step);
};
step(segStart);
}
return () => {
range.removeEventListener("pointerdown", stop);
range.removeEventListener("keydown", stop);
range.removeEventListener("input", onInput);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.ct}
ref={rootRef}
style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}
>
<p className={styles.hint}>{HINT}</p>
<div className={styles.wrap} ref={wrapRef} role="img" aria-label={ARIA_SCENE}>
<input
className={styles.range}
ref={rangeRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={LABEL_RANGE}
/>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={hueRef}>hue 122</b>
<span className={styles.unit}>{UNIT}</span>
</p>
</div>
</main>
);
}08 色温度トラック — 値比率を色相・彩度補間に変えて染まるトラック — 計測 累積面積 5.576% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種
このデモが属する記事
css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種css レンジスライダーは値が変わるたびに計算が動く一枚の層です。ドラッグを離すと速度ベースの慣性lerpが減衰して止まり、値が変わるたびにスプリングでオーバーシュートしてから収まります。目盛りスナップ・縦型ボリューム・温度グラデーショント この記事のデモをすべて見る


