01値のバブル — トラック位置を百分率に変えて追従する値ラベル
(value - min) / (max - min) * 100% をCSS変数に入れると、バブルラベルが left: calc(var(--pct) * 1%) でつまみを正確に追従します。つまみ幅分のオフセット補正で両端でもラベルがトラック外へ出ません。
使いどころ価格フィルター、設定画面
pointermoveCSS custom propertiescalc

<main class="stage">
<div class="vt" id="vt">
<p class="vt__hint">パーセントを計算してCSS変数に入れると、ツールチップがサムにそのまま追従します</p>
<div class="vt__wrap" id="vtWrap" role="img" aria-label="値が変わるたびにツールチップラベルがサムと一緒に移動する場面">
<div class="vt__bubble" id="vtBubble"><span id="vtVal">50</span></div>
<input class="vt__range" id="vtRange" type="range" min="0" max="100" step="1" value="50" aria-label="値スライダー">
</div>
<p class="vt__meta"><b class="vt__tag" id="vtCalc">50.0%</b><span class="vt__unit">calcで求めたツールチップ位置</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 풍선(01) — input[type=range] 의 runnable-track/progress 를 var(--pct) 로 채우고,
// 풍선은 계산된 left(calc) 로 썸을 따라간다. 트랙 두께는 변형 표의 기본값 8px.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
$track-h: 8px;
$thumb-d: 20px;
.vt {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 360px;
}
.vt__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.vt__wrap {
position: relative; width: 100%; height: 48px;
display: flex; align-items: center;
--pct: 50;
--tw: #{$thumb-d};
}
.vt__bubble {
position: absolute; top: 0;
left: calc((var(--pct) / 100) * (100% - var(--tw)) + var(--tw) / 2);
transform: translate(-50%, 0);
min-width: 32px; padding: $sp-1 $sp-2; box-sizing: border-box;
background: $color; color: $subject-cream;
border-radius: $r-sm;
font-weight: 700; font-size: 11px; line-height: 1.2; text-align: center;
font-variant-numeric: tabular-nums;
box-shadow: $shadow-press;
transition: background $duration $easing;
}
.vt__range {
appearance: none; -webkit-appearance: none;
width: 100%; height: $thumb-d; margin: 0; background: transparent;
position: relative; top: 14px;
}
.vt__range:focus-visible { outline: 2px solid $subject-cream; outline-offset: 4px; border-radius: $r-pill; }
.vt__range::-webkit-slider-runnable-track {
height: $track-h; border-radius: $r-pill;
background: linear-gradient(to right, $color calc(var(--pct) * 1%), rgba($subject-cream, .16) calc(var(--pct) * 1%));
}
.vt__range::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: $thumb-d; height: $thumb-d; border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
margin-top: calc(($track-h - $thumb-d) / 2);
}
.vt__range::-moz-range-track {
height: $track-h; border-radius: $r-pill; background: rgba($subject-cream, .16);
}
.vt__range::-moz-range-progress {
height: $track-h; border-radius: $r-pill; background: $color;
}
.vt__range::-moz-range-thumb {
width: $thumb-d; height: $thumb-d; border: 0; border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
}
.vt__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .82); }
.vt__tag { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.vt__unit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.vt { gap: $sp-2; max-width: 280px; }
.vt__hint { font-size: 8px; }
.vt__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.vt__bubble { 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);
}
.vt {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.vt__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.vt__wrap {
position: relative;
width: 100%;
height: 48px;
display: flex;
align-items: center;
--pct: 50;
--tw: 20px;
}
.vt__bubble {
position: absolute;
top: 0;
left: calc(var(--pct) / 100 * (100% - var(--tw)) + var(--tw) / 2);
transform: translate(-50%, 0);
min-width: 32px;
padding: 4px 8px;
box-sizing: border-box;
background: #0a4fe8;
color: #fff7e6;
border-radius: 8px;
font-weight: 700;
font-size: 11px;
line-height: 1.2;
text-align: center;
font-variant-numeric: tabular-nums;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: background 220ms ease-out;
}
.vt__range {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 20px;
margin: 0;
background: transparent;
position: relative;
top: 14px;
}
.vt__range:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 4px;
border-radius: 999px;
}
.vt__range::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, #0a4fe8 calc(var(--pct) * 1%), rgba(255, 247, 230, 0.16) calc(var(--pct) * 1%));
}
.vt__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
margin-top: -6px;
}
.vt__range::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
}
.vt__range::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: #0a4fe8;
}
.vt__range::-moz-range-thumb {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.vt__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.vt__tag {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.vt__unit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.vt {
gap: 8px;
max-width: 280px;
}
.vt__hint {
font-size: 8px;
}
.vt__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.vt__bubble {
transition: none;
}
}// 값 풍선(01) — (value - min) / (max - min) * 100 을 --pct 로 내려 풍선의 left 를
// calc(var(--pct) * 1%) 로 계산한다. 풍선 폭의 절반만큼 안쪽으로 당기는 오프셋 보정을
// 넣어 0%·100% 에서도 풍선이 트랙 밖으로 삐져나가지 않게 한다. CSS @keyframes 는 없다 —
// 자동 시연은 목표값 사이를 지수 감쇠로 부드럽게 접근하는 requestAnimationFrame 루프다.
(function () {
var root = document.getElementById('vt');
var wrap = document.getElementById('vtWrap');
var bubble = document.getElementById('vtBubble');
var valEl = document.getElementById('vtVal');
var calcEl = document.getElementById('vtCalc');
var range = document.getElementById('vtRange');
if (!root || !wrap || !bubble || !valEl || !calcEl || !range) return;
var MIN = 0, MAX = 100;
var TAU = 0.11; // 지수 감쇠 시간상수(秒) — 값 접근 속도
var current = 50;
function paint(v) {
var pct = (v - MIN) / (MAX - MIN) * 100;
wrap.style.setProperty('--pct', pct.toFixed(2));
valEl.textContent = String(Math.round(v));
calcEl.textContent = pct.toFixed(1) + '%';
range.setAttribute('aria-valuenow', String(Math.round(v)));
}
paint(current);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function onUserInput() {
stop();
current = Number(range.value);
paint(current);
}
range.addEventListener('input', onUserInput);
range.addEventListener('pointerdown', stop);
range.addEventListener('keydown', stop);
var targets = [50, 18, 82, 35, 92, 50]; // 마지막 = 처음, 루프가 이어 붙는다
var seg = 0, playing = true;
function stop() { playing = false; }
if (reduce) {
current = targets[0];
range.value = String(current);
paint(current);
} else {
var segStart = performance.now();
var segFrom = current;
var SEG_MS = 400;
(function step(now) {
if (playing) {
var elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % targets.length;
segStart = now;
segFrom = current;
elapsed = 0;
}
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);
}
})();// RangeSliderValueTooltipFollow — vanilla 판(run/334/demo/01-range-slider-value-tooltip-follow/index.html)
// 의 React 포트. (value - min) / (max - min) * 100 을 --pct 로 내려 풍선의 left 를
// calc(var(--pct) * 1%) 로 계산한다. CSS @keyframes 는 없다 — 자동 시연은 목표값 사이를
// 지수 감쇠로 부드럽게 접근하는 requestAnimationFrame 루프다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderValueTooltipFollow.module.scss";
export type RangeSliderValueTooltipFollowProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "퍼센트를 계산해 CSS 변수에 담으면 풍선이 썸을 그대로 따라다닙니다";
const ARIA_SCENE = "값이 바뀔 때마다 풍선 라벨이 썸을 따라 이동하는 장면";
const ARIA_RANGE = "값 슬라이더";
const UNIT = "calc 로 구한 풍선 위치";
const MIN = 0, MAX = 100;
const TAU = 0.11; // 지수 감쇠 시간상수(초) — 값 접근 속도
const TARGETS = [50, 18, 82, 35, 92, 50]; // 마지막 = 처음, 루프가 이어 붙는다
const SEG_MS = 400;
export default function RangeSliderValueTooltipFollow({
duration = "220ms",
easing = "ease-out",
color = "#0a4fe8"
}: RangeSliderValueTooltipFollowProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const valRef = useRef<HTMLElement | null>(null);
const calcRef = useRef<HTMLElement | null>(null);
const rangeRef = useRef<HTMLInputElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const wrap = wrapRef.current;
const valEl = valRef.current;
const calcEl = calcRef.current;
const range = rangeRef.current;
if (!wrap || !valEl || !calcEl || !range) return;
let current = 50;
function paint(v: number): void {
const pct = ((v - MIN) / (MAX - MIN)) * 100;
wrap!.style.setProperty("--pct", pct.toFixed(2));
valEl!.textContent = String(Math.round(v));
calcEl!.textContent = pct.toFixed(1) + "%";
range!.setAttribute("aria-valuenow", String(Math.round(v)));
}
paint(current);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
function stop(): void {
playing = false;
}
function onUserInput(): void {
stop();
current = Number(range!.value);
paint(current);
}
range.addEventListener("input", onUserInput);
range.addEventListener("pointerdown", stop);
range.addEventListener("keydown", stop);
if (reduce) {
current = TARGETS[0];
range.value = String(current);
paint(current);
} else {
let seg = 0;
let segStart = performance.now();
let segFrom = current;
const step = (now: number): void => {
if (playing) {
let elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % TARGETS.length;
segStart = now;
segFrom = current;
elapsed = 0;
}
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);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
range.removeEventListener("input", onUserInput);
range.removeEventListener("pointerdown", stop);
range.removeEventListener("keydown", stop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.vt}
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}>
<div className={styles.bubble}>
<span ref={valRef}>50</span>
</div>
<input
className={styles.range}
ref={rangeRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={ARIA_RANGE}
/>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={calcRef}>50.0%</b>
<span className={styles.unit}>{UNIT}</span>
</p>
</div>
</main>
);
}01 値のバブル — トラック位置を百分率に変えて追従する値ラベル — 計測 累積面積 6.988% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種
このデモが属する記事
css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種css レンジスライダーは値が変わるたびに計算が動く一枚の層です。ドラッグを離すと速度ベースの慣性lerpが減衰して止まり、値が変わるたびにスプリングでオーバーシュートしてから収まります。目盛りスナップ・縦型ボリューム・温度グラデーショント この記事のデモをすべて見る

