04フィル光沢 — 値比率で塗り区間を切り取り、ハイライトを流すsweep
linear-gradientをvar(--pct)の位置で2色に切り塗りを作り、その上に斜めハイライト帯を置くと、値が増えるほど光沢が塗りの先端に沿って流れます。background-clipでトラック内に限定し、丸い端からも光が漏れません。
使いどころオンボーディング調査、段階的設定
linear-gradientbackground-clipCSS custom properties

<main class="stage">
<div class="fg" id="fg">
<p class="fg__hint">塗りを値の比率で切り、その中だけを斜めのハイライトが流れるようbackground-clipで閉じ込めます</p>
<div class="fg__track" id="fgTrack" role="img" aria-label="塗りの端に沿って光沢が流れるフィルスライダーの場面">
<div class="fg__fill" id="fgFill"><div class="fg__gloss"></div></div>
<div class="fg__thumb" id="fgThumb"></div>
<input class="fg__range" id="fgRange" type="range" min="0" max="100" step="1" value="50" aria-label="値スライダー">
</div>
<p class="fg__meta"><b class="fg__tag" id="fgPct">50%</b><span class="fg__unit">塗り · 光沢は塗りの中だけ</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 필 광택(04, 아이디어형) — 네이티브 input 은 투명 오버레이로 조작만 받고, 채움·광택은
// 아래 div 가 그린다. background-clip 대신 overflow:hidden 트랙에 폭(--pct)만큼 자란
// .fg__fill 로 가둔다 — 안의 sweep 은 그 폭 밖으로 못 나간다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
$track-h: 8px;
$thumb-d: 20px;
.fg {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 360px;
}
.fg__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.fg__track {
position: relative; width: 100%; height: 40px;
display: flex; align-items: center;
--pct: 50; --sweep: 0;
}
.fg__track::before {
content: ""; position: absolute; left: 0; right: 0; top: 50%;
height: $track-h; margin-top: -($track-h / 2);
border-radius: $r-pill; background: rgba($subject-ink, .12);
}
.fg__fill {
position: absolute; left: 0; top: 50%; height: $track-h; margin-top: -($track-h / 2);
width: calc(var(--pct) * 1%);
border-radius: $r-pill; background: $color;
overflow: hidden;
}
.fg__gloss {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(115deg, transparent 40%, rgba($subject-cream, .65) 50%, transparent 60%);
background-size: 260% 100%;
background-position: calc(var(--sweep) * 1% - 80%) 0;
}
.fg__thumb {
position: absolute; top: 50%;
// 썸을 값 위치에 중심 정렬한다 — 가운데 정렬 트랜스폼 대신 자기 반지름만큼
// 왼쪽으로 당겨 같은 결과를 낸다(마퀴 트랙이 아니라 점 정렬이라 BD15 대상이 아니다).
left: calc(var(--pct) * 1% - #{$thumb-d} / 2);
width: $thumb-d; height: $thumb-d; margin-top: -($thumb-d / 2);
border-radius: 50%; background: $subject-ink; box-shadow: $shadow-press;
}
.fg__range {
position: absolute; inset: 0; width: 100%; height: 100%;
margin: 0; appearance: none; -webkit-appearance: none;
background: transparent; opacity: 0; cursor: pointer;
}
.fg__range:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; border-radius: $r-pill; }
.fg__range::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: $thumb-d; height: $thumb-d; }
.fg__range::-moz-range-thumb { width: $thumb-d; height: $thumb-d; border: 0; background: transparent; }
.fg__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .82); }
.fg__tag { font-weight: 700; color: $color; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.fg__unit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.fg { gap: $sp-2; max-width: 280px; }
.fg__hint { font-size: 8px; }
.fg__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.fg__gloss { background-position: -80% 0; }
}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);
}
.fg {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.fg__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.fg__track {
position: relative;
width: 100%;
height: 40px;
display: flex;
align-items: center;
--pct: 50;
--sweep: 0;
}
.fg__track::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 8px;
margin-top: -4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.fg__fill {
position: absolute;
left: 0;
top: 50%;
height: 8px;
margin-top: -4px;
width: calc(var(--pct) * 1%);
border-radius: 999px;
background: #0a4fe8;
overflow: hidden;
}
.fg__gloss {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(115deg, transparent 40%, rgba(255, 247, 230, 0.65) 50%, transparent 60%);
background-size: 260% 100%;
background-position: calc(var(--sweep) * 1% - 80%) 0;
}
.fg__thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * 1% - 20px / 2);
width: 20px;
height: 20px;
margin-top: -10px;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.fg__range {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
appearance: none;
-webkit-appearance: none;
background: transparent;
opacity: 0;
cursor: pointer;
}
.fg__range:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
border-radius: 999px;
}
.fg__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
}
.fg__range::-moz-range-thumb {
width: 20px;
height: 20px;
border: 0;
background: transparent;
}
.fg__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.82);
}
.fg__tag {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.fg__unit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.fg {
gap: 8px;
max-width: 280px;
}
.fg__hint {
font-size: 8px;
}
.fg__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.fg__gloss {
background-position: -80% 0;
}
}// 필 광택(04) — 네이티브 input 은 투명하게 겹쳐 실제 조작만 받고, 시각은 아래 채움
// div 가 맡는다(트랙·썸 anatomy 의 경계 — pseudo-element 만으로는 background-clip 가둠과
// 사선 sweep 을 함께 표현하기 어렵다). 채움 폭은 --pct, 광택 위치는 --sweep 로 매 프레임
// 다시 계산해 background-position 에 대입한다. CSS @keyframes 는 없다.
(function () {
var root = document.getElementById('fg');
var track = document.getElementById('fgTrack');
var fill = document.getElementById('fgFill');
var thumb = document.getElementById('fgThumb');
var range = document.getElementById('fgRange');
var pctEl = document.getElementById('fgPct');
if (!root || !track || !fill || !thumb || !range || !pctEl) return;
var MIN = 0, MAX = 100;
var TAU = 0.14;
var SWEEP_PERIOD = 1.1; // 秒 — 광택이 채움 안을 한 번 가로지르는 주기
function paint(v, sweepPhase) {
var pct = (v - MIN) / (MAX - MIN) * 100;
track.style.setProperty('--pct', pct.toFixed(2));
track.style.setProperty('--sweep', (sweepPhase * 100).toFixed(2));
pctEl.textContent = Math.round(pct) + '%';
range.setAttribute('aria-valuenow', String(Math.round(v)));
}
var current = 50;
paint(current, 0);
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);
});
var targets = [50, 22, 78, 40, 90, 50];
var seg = 0, segFrom = current;
var SEG_MS = 400;
var segStart = performance.now();
var start = segStart;
if (reduce) {
current = targets[0];
range.value = String(current);
paint(current, 0);
} 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));
}
var sweepT = ((now - start) / 1000) % SWEEP_PERIOD / SWEEP_PERIOD;
paint(current, sweepT);
requestAnimationFrame(step);
})(start);
}
})();// RangeSliderFillGlossSweep — vanilla 판(run/334/demo/04-range-slider-fill-gloss-sweep/index.html)
// 의 React 포트. 네이티브 input 은 투명 오버레이로 조작만 받고, 채움·광택은 아래 div 가
// 그린다. 채움 폭은 --pct, 광택 위치는 --sweep 로 매 프레임 다시 계산해 background-position
// 에 대입한다. CSS @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderFillGlossSweep.module.scss";
export type RangeSliderFillGlossSweepProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "채움을 값 비율로 자르고 그 안에서만 사선 하이라이트가 흐르게 background-clip 으로 가둡니다";
const ARIA_SCENE = "채움 끝을 따라 광택이 흐르는 필 슬라이더 장면";
const RANGE_LABEL = "값 슬라이더";
const UNIT = "채움 · 광택은 채움 안에서만";
const MIN = 0, MAX = 100;
const TAU = 0.14;
const SWEEP_PERIOD = 1.1; // 초 — 광택이 채움 안을 한 번 가로지르는 주기
const TARGETS = [50, 22, 78, 40, 90, 50];
const SEG_MS = 400;
export default function RangeSliderFillGlossSweep({
duration = "2s",
easing = "ease-out",
color = "#0a4fe8"
}: RangeSliderFillGlossSweepProps) {
const trackRef = useRef<HTMLDivElement | null>(null);
const rangeRef = useRef<HTMLInputElement | null>(null);
const pctRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const track = trackRef.current;
const range = rangeRef.current;
const pctEl = pctRef.current;
if (!track || !range || !pctEl) return;
function paint(v: number, sweepPhase: number): void {
const pct = ((v - MIN) / (MAX - MIN)) * 100;
track!.style.setProperty("--pct", pct.toFixed(2));
track!.style.setProperty("--sweep", (sweepPhase * 100).toFixed(2));
pctEl!.textContent = Math.round(pct) + "%";
range!.setAttribute("aria-valuenow", String(Math.round(v)));
}
let current = 50;
paint(current, 0);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
function stop(): void { playing = false; }
const onInput = (): void => { current = Number(range!.value); };
range.addEventListener("pointerdown", stop);
range.addEventListener("keydown", stop);
range.addEventListener("input", onInput);
let seg = 0, segFrom = current;
let segStart = performance.now();
const start = segStart;
if (reduce) {
current = TARGETS[0];
range.value = String(current);
paint(current, 0);
} 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));
}
const sweepT = (((now - start) / 1000) % SWEEP_PERIOD) / SWEEP_PERIOD;
paint(current, sweepT);
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
range.removeEventListener("pointerdown", stop);
range.removeEventListener("keydown", stop);
range.removeEventListener("input", onInput);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.fg}
style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}
>
<p className={styles.hint}>{HINT}</p>
<div className={styles.track} ref={trackRef} role="img" aria-label={ARIA_SCENE}>
<div className={styles.fill}>
<div className={styles.gloss}></div>
</div>
<div className={styles.thumb}></div>
<input
className={styles.range}
ref={rangeRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={RANGE_LABEL}
/>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={pctRef}>50%</b>
<span className={styles.unit}>{UNIT}</span>
</p>
</div>
</main>
);
}04 フィル光沢 — 値比率で塗り区間を切り取り、ハイライトを流すsweep — 計測 累積面積 4.78% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種
このデモが属する記事
css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種css レンジスライダーは値が変わるたびに計算が動く一枚の層です。ドラッグを離すと速度ベースの慣性lerpが減衰して止まり、値が変わるたびにスプリングでオーバーシュートしてから収まります。目盛りスナップ・縦型ボリューム・温度グラデーショント この記事のデモをすべて見る

