レンジスライダー ui 9種 — ハンドルが交差しない計算まで
レンジスライダー ui 9種のうち、1つは2つのハンドルが最小間隔より内側で重ならないよう計算で防ぎ、1つは値が近づくと2つの値の吹き出しが1つに合体し、1つはトラックの奥に棒グラフを敷いて分布を一緒に示します。
01 dual-slider-handle-crossing-clamp
02 dual-slider-fill-range-track
03 dual-slider-tooltip-pair-follow
04 dual-slider-drag-both-handles-shift
05 dual-slider-keyboard-pair-navigate
06 dual-slider-histogram-track-bg
07 dual-slider-snap-step-ticks
08 dual-slider-min-gap-elastic-push
09 dual-slider-reduced-motion-static
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 2つのハンドルが交差しない範囲スライダー
- 02 2つのハンドルの間だけ色が満ちる範囲スライダー
- 03 値が近づくと2つの吹き出しが1つに合体する範囲スライダー
- 04 中央をドラッグすると2つのハンドルが一緒に動く範囲スライダー
- 05 Tab で2つのハンドルを交互に操作する範囲スライダー
- 06 ヒストグラム背景の上で区間を選ぶ範囲スライダー
- 07 10 刻みの目盛りに揃う範囲スライダー
- 08 最小間隔を守るために弾性で押される範囲スライダー
- 09 モーション削減と通常モーションを並べて比較する範囲スライダー
レンジスライダーとは、ひとつのトラック上で最小値と最大値を同時に決める2ハンドルの入力パーツのことで、下の9種はどれも同じポインタ操作とキーボード操作の土台の上に、名前の付いた小さな計算関数を一層だけ重ねています。交差防止クランプ・区間だけ塗るフィル・吹き出し合体・中央ドラッグでの平行移動・Tab交互操作・ヒストグラム背景・10刻みスナップ・弾性プッシュ・モーション比較の9種で、どれも <input type="range"> を2本並べただけでは足りない部分を、それぞれ1つの関数で補っています。
デモ

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="15" aria-valuetext="15" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="85" aria-valuetext="85" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">15</span> – <span id="hiOut">85</span></p>
<p class="ds__caption">間隔を14以上に保つ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
// 계산 겹: setLo()/setHi() 가 최소·최대값이 GAP(14) 보다 가까워지지 못하게 매 입력마다 다시 clamp 한다.
@include stage($stage-ink);
.ds { width: min(280px, 84vw); color: $subject-cream; text-align: center; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 15%; width: 70%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 15%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%);
}
.ds__thumb--hi { left: 85%; }
.ds__thumb:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__thumb.is-blocked { background: $subject-orange-deep; }
.ds__readout {
margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-cream, .72); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing, background $dur-quick $ease-out;
}
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { 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);
}
.ds {
width: min(280px, 84vw);
color: #fff7e6;
text-align: center;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 15%;
width: 70%;
background: #2f6df6;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 15%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
}
.ds__thumb--hi {
left: 85%;
}
.ds__thumb:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__thumb.is-blocked {
background: #c93000;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1), background 220ms ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, GAP = 14;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var v = { lo: 15, hi: 85 };
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
}
// 계산 겹: 최소값이 最大値 - GAP 을 넘지 못하게, 최대값이 最小値 + GAP 아래로 못 내려가게 막는다
function setLo(n) {
var blocked = document.getElementById('track');
v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
lo.classList.toggle('is-blocked', n > v.hi - GAP);
paint();
}
function setHi(n) {
v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
hi.classList.toggle('is-blocked', n < v.lo + GAP);
paint();
}
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo + GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi - GAP : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var presets = [[40, 46], [10, 90], [55, 61], [20, 80], [35, 41]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
setLo(p[0]); setHi(p[1]);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderHandleCrossingClamp.module.scss';
const MIN = 0;
const MAX = 100;
const GAP = 14;
const TICK_MS = 480;
const PRESETS: [number, number][] = [[40, 46], [10, 90], [55, 61], [20, 80], [35, 41]];
export default function DualSliderHandleCrossingClamp() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!root || !track || !fill || !lo || !hi || !loOut || !hiOut) return undefined;
const v = { lo: 15, hi: 85 };
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
}
// 계산 겹: 최소값이 최대값 - GAP 을 넘지 못하게, 최대값이 최소값 + GAP 아래로 못 내려가게 막는다
function setLo(n: number) {
v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
lo!.classList.toggle(styles['is-blocked'], n > v.hi - GAP);
paint();
}
function setHi(n: number) {
v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
hi!.classList.toggle(styles['is-blocked'], n < v.lo + GAP);
paint();
}
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo + GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi - GAP : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
setLo(p[0]); setHi(p[1]);
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={15} aria-valuetext="15" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={85} aria-valuetext="85" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>15</span> – <span ref={hiOutRef}>85</span></p>
<p className={styles.dsCaption}>간격 14 이상 유지</p>
</div>
</main>
);
}01 · 2つのハンドルが交差しない範囲スライダー — 計測 累積面積 3.058% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
上の01は setLo/setHi の中のクランプだけで2つのハンドルの間隔を保っています。残り8種も、それぞれのコードブロックの直上にデモが並んでいます。
9種の構造 — 計算が一層どこに走るか
| # | パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|---|
| 01 | 交差防止クランプ | 3.058% | 176.2 | 23中22 |
| 02 | 区間だけ塗るフィル | 3.797% | 101.0 | 23中21 |
| 03 | 吹き出し合体 | 8.158% | 111.0 | 23中21 |
| 04 | 中央ドラッグ平行移動 | 4.107% | 55.4 | 23中17 |
| 05 | Tab交互操作 | 3.223% | 145.1 | 23中21 |
| 06 | ヒストグラム背景 | 6.338% | 87.0 | 23中21 |
| 07 | 10刻みスナップ | 5.147% | 129.7 | 23中21 |
| 08 | 弾性プッシュ | 3.027% | 159.9 | 23中21 |
| 09 | モーション比較 | 5.484% | 129.3 | 23中21 |
9種の中で画面をいちばん広く塗り替えるのは03(累積8.158%)です。2つの吹き出しを1つに合体させる瞬間、両方の値ラベルと合体後のラベルを一緒に描き直すためです。逆にいちばん狭いのは08(3.027%)で、スプリングが1フレームごとにハンドルをわずかしか動かさず、吹き出しも棒グラフも描き直さないからです。平均強度がもっとも高いのは01(176.2)で、動いたフレームも9種の中で最多(23中22)です。ポインタが動くたびに毎回クランプの判定を行い、is-blocked クラスの切り替えとハンドルの移動が同時に起きるためです。反対に04は平均強度がもっとも低く(55.4)、動いたフレームも最少(23中17)です。中央をドラッグしても2つのハンドルと1つの塗り区間しか描き直さず、どちらかの端に触れた瞬間にドラッグそのものが止まるからです。
9種を「一層の計算が実際に何を読んでいるか」で分けると、3つの系統に整理できます。
ハンドル同士の関係を縛る系統
01・04・08は、ハンドル自身の位置ではなく2つのハンドルの間隔を縛ります。01の setLo/setHi は相手側の値から GAP を引いた(足した)値でクランプします。04の shiftBoth は幅(hi - lo)を固定したまま両端を同じ delta だけ動かします。08はさらに踏み込み、要求された間隔が MIN_GAP を下回りそうになると相手側の目標値ごと押し出し、その視覚的な追従をスプリングに任せます。
getBoundingClientRect で実測する系統
02・03・07は、%の値をトラックの実際の描画幅から px に変換します。02は塗り幅を%と一緒に px でも表示します。03は2つの値吹き出しの実際の画面上の間隔を px で測り、MERGE_PX と比較してから合体を決めます。07は各目盛りの left を px でトラック実測幅から計算し直すため、トラック幅が変わっても目盛りがずれません。
ドラッグのたびに計算し直さない系統
05・06・09は、ドラッグそのものには触れない状態を1つ持っています。05は focusin/focusout からどちらのハンドルが「アクティブ」かを追跡し、ポインタ操作とは独立しています。06は固定の HEIGHTS 配列から棒グラフを一度だけ作り、描画のたびに [lo, hi] に入っているかだけを再判定します。09は共有する1組の値を、トランジションありのパネルとトランジションなしのパネルの2枚に描き分けており、片方の描画がもう片方に依存しません。
パターンごとのコード
以下のブロックはすべて、そのパターンの index.html が実際に実行している関数そのものです — 変数名も順序もそのまま、省略はありません。
012つのハンドルが交差しない範囲スライダー
最小値のハンドルを最大値側へ寄せても、決めておいた最小の間隔より近づかずその位置で止まります。最小・最大価格を同時に決めるフィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="15" aria-valuetext="15" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="85" aria-valuetext="85" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">15</span> – <span id="hiOut">85</span></p>
<p class="ds__caption">間隔を14以上に保つ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
// 계산 겹: setLo()/setHi() 가 최소·최대값이 GAP(14) 보다 가까워지지 못하게 매 입력마다 다시 clamp 한다.
@include stage($stage-ink);
.ds { width: min(280px, 84vw); color: $subject-cream; text-align: center; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 15%; width: 70%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 15%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%);
}
.ds__thumb--hi { left: 85%; }
.ds__thumb:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__thumb.is-blocked { background: $subject-orange-deep; }
.ds__readout {
margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-cream, .72); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing, background $dur-quick $ease-out;
}
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { 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);
}
.ds {
width: min(280px, 84vw);
color: #fff7e6;
text-align: center;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 15%;
width: 70%;
background: #2f6df6;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 15%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
}
.ds__thumb--hi {
left: 85%;
}
.ds__thumb:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__thumb.is-blocked {
background: #c93000;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1), background 220ms ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, GAP = 14;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var v = { lo: 15, hi: 85 };
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
}
// 계산 겹: 최소값이 最大値 - GAP 을 넘지 못하게, 최대값이 最小値 + GAP 아래로 못 내려가게 막는다
function setLo(n) {
var blocked = document.getElementById('track');
v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
lo.classList.toggle('is-blocked', n > v.hi - GAP);
paint();
}
function setHi(n) {
v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
hi.classList.toggle('is-blocked', n < v.lo + GAP);
paint();
}
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo + GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi - GAP : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var presets = [[40, 46], [10, 90], [55, 61], [20, 80], [35, 41]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
setLo(p[0]); setHi(p[1]);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderHandleCrossingClamp.module.scss';
const MIN = 0;
const MAX = 100;
const GAP = 14;
const TICK_MS = 480;
const PRESETS: [number, number][] = [[40, 46], [10, 90], [55, 61], [20, 80], [35, 41]];
export default function DualSliderHandleCrossingClamp() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!root || !track || !fill || !lo || !hi || !loOut || !hiOut) return undefined;
const v = { lo: 15, hi: 85 };
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
}
// 계산 겹: 최소값이 최대값 - GAP 을 넘지 못하게, 최대값이 최소값 + GAP 아래로 못 내려가게 막는다
function setLo(n: number) {
v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
lo!.classList.toggle(styles['is-blocked'], n > v.hi - GAP);
paint();
}
function setHi(n: number) {
v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
hi!.classList.toggle(styles['is-blocked'], n < v.lo + GAP);
paint();
}
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo + GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi - GAP : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
setLo(p[0]); setHi(p[1]);
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={15} aria-valuetext="15" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={85} aria-valuetext="85" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>15</span> – <span ref={hiOutRef}>85</span></p>
<p className={styles.dsCaption}>간격 14 이상 유지</p>
</div>
</main>
);
}01 · 2つのハンドルが交差しない範囲スライダー — 計測 累積面積 3.058% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 最小値が最大値 - GAP を超えないよう、最大値が最小値 + GAP を下回らないよう固定する
function setLo(n) {
var blocked = document.getElementById('track');
v.lo = Math.max(MIN, Math.min(n, v.hi - GAP));
lo.classList.toggle('is-blocked', n > v.hi - GAP);
paint();
}
function setHi(n) {
v.hi = Math.min(MAX, Math.max(n, v.lo + GAP));
hi.classList.toggle('is-blocked', n < v.lo + GAP);
paint();
}
022つのハンドルの間だけ色が満ちる範囲スライダー
トラック全体ではなく低い方と高い方のハンドルの間の区間だけ色が満ち、ハンドルを動かすとその区間の両端が位置に合わせて動きます — 224 とは異なる実演です。224(range-slider-dual-thumb-fill)は値の更新と単純な塗りのみですが、こちらは getBoundingClientRect 実測トラック幅で塗り幅を px 換算して示し、自動再生のスイープ位相まで加えます。選んだ区間がひと目でわかる必要がある統計フィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"><div class="ds__sweep" id="sweep"></div></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-valuetext="20" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="78" aria-valuetext="78" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">20</span> – <span id="hiOut">78</span></p>
<p class="ds__caption">塗り幅 <span id="pxOut">0</span>px</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: paint() 가 값 두 개를 채움 %로 바꾸고, 실측 트랙 폭(getBoundingClientRect)으로 px 도 환산한다.
// 224 의 듀얼 섬과 다른 시연 — 여긴 채움 안에 sweep 밴드가 값 비율(phase)로 다시 위치를 계산해 돈다.
@include stage($stage-yellow);
.ds { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.ds__track {
position: relative; height: 8px; border-radius: $r-pill;
background: rgba($subject-ink, .14); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 58%;
background: $color; border-radius: $r-pill; overflow: hidden;
}
.ds__sweep {
position: absolute; top: 0; bottom: 0; left: 40%; width: 22%;
background: linear-gradient(100deg, rgba($subject-cream, 0) 0%, rgba($subject-cream, .85) 50%, rgba($subject-cream, 0) 100%);
transform: translateX(-50%);
}
.ds__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 1;
}
.ds__thumb--hi { left: 78%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__readout {
margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-ink, .72); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__sweep {
transition: left $duration $easing, width $duration $easing;
}
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill, .ds.is-demo .ds__sweep { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ds {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.ds__track {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 58%;
background: #4cd4a6;
border-radius: 999px;
overflow: hidden;
}
.ds__sweep {
position: absolute;
top: 0;
bottom: 0;
left: 40%;
width: 22%;
background: linear-gradient(100deg, rgba(255, 247, 230, 0) 0%, rgba(255, 247, 230, 0.85) 50%, rgba(255, 247, 230, 0) 100%);
transform: translateX(-50%);
}
.ds__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 1;
}
.ds__thumb--hi {
left: 78%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(23, 20, 26, 0.72);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__sweep {
transition: left 2s ease-out, width 2s ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill, .ds.is-demo .ds__sweep {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var sweep = document.getElementById('sweep');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var pxOut = document.getElementById('pxOut');
var v = { lo: 20, hi: 78, phase: 0.2 };
// 계산 겹: 두 값을 트랙 채움 %로 바꾸고, 실측 트랙 폭(getBoundingClientRect)으로 채움을 px 로도 환산한다.
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
var sweepPct = v.lo + (v.hi - v.lo) * v.phase;
var trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
sweep.style.left = trackPct + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
var r = track.getBoundingClientRect();
pxOut.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
window.addEventListener('resize', paint);
var presets = [[8, 92, 0.15], [30, 55, 0.9], [45, 88, 0.35], [12, 40, 0.7], [60, 96, 0.5]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; v.phase = p[2];
paint();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderFillRangeTrack.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
const PRESETS: [number, number, number][] = [[8, 92, 0.15], [30, 55, 0.9], [45, 88, 0.35], [12, 40, 0.7], [60, 96, 0.5]];
export default function DualSliderFillRangeTrack() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const sweepRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
const pxOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const sweep = sweepRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
const pxOut = pxOutRef.current;
if (!root || !track || !fill || !sweep || !lo || !hi || !loOut || !hiOut || !pxOut) return undefined;
const v = { lo: 20, hi: 78, phase: 0.2 };
// 계산 겹: 두 값을 트랙 채움 %로 바꾸고, 실측 트랙 폭(getBoundingClientRect)으로 채움을 px 로도 환산한다.
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
const sweepPct = v.lo + (v.hi - v.lo) * v.phase;
const trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
sweep!.style.left = trackPct + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
const r = track!.getBoundingClientRect();
pxOut!.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
window.addEventListener('resize', paint);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; v.phase = p[2];
paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.removeEventListener('resize', paint); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef}>
<div className={styles.dsSweep} ref={sweepRef} />
</div>
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={20} aria-valuetext="20" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={78} aria-valuetext="78" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>20</span> – <span ref={hiOutRef}>78</span></p>
<p className={styles.dsCaption}>채움 폭 <span ref={pxOutRef}>0</span>px</p>
</div>
</main>
);
}02 · 2つのハンドルの間だけ色が満ちる範囲スライダー — 計測 累積面積 3.797% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 2つの値をトラック塗りつぶしの%に変換し、実測したトラック幅(getBoundingClientRect)で塗り幅を px にも換算する
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
var sweepPct = v.lo + (v.hi - v.lo) * v.phase;
var trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
sweep.style.left = trackPct + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
var r = track.getBoundingClientRect();
pxOut.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
}
03値が近づくと2つの吹き出しが1つに合体する範囲スライダー
各ハンドルに値の吹き出しが付いてきますが、2つの値が近づくと吹き出し同士が重ならないよう1つの吹き出しに両方の値を並べて表示します。狭い区間を精密に選ぶ予約日フィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<div class="ds__bubble ds__bubble--lo" id="bubbleLo">20</div>
<div class="ds__bubble ds__bubble--hi" id="bubbleHi">76</div>
<div class="ds__bubble ds__bubble--merged" id="bubbleMerged">20 – 76</div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-valuetext="20" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-valuetext="76" tabindex="0"></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
// 계산 겹: 두 풍선 사이 실제 화면 간격(px)이 MERGE_PX 보다 좁아지면 root 에 is-merged 를 켜
// 개별 풍선은 숨기고 합친 풍선 하나로 값을 나란히 보여준다.
@include stage($stage-orange);
.ds { width: min(300px, 86vw); color: $subject-ink; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-ink, .18); margin: $sp-12 0 $sp-6;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 56%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 76%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__bubble {
position: absolute; top: -$sp-10; transform: translate(-50%, 0);
background: $subject-ink; color: $subject-cream; border-radius: $r-sm;
padding: $sp-1 $sp-2; font-weight: 700; font-size: 12px; line-height: 1.3;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.ds__bubble--merged { opacity: 0; background: $subject-orange-deep; }
.ds.is-merged .ds__bubble--lo,
.ds.is-merged .ds__bubble--hi { opacity: 0; transform: translate(-50%, 0) scale(.6); }
.ds.is-merged .ds__bubble--merged { opacity: 1; }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__bubble {
transition: left $duration $easing, width $duration $easing, opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
}
@media (max-width: 340px) {
.ds { width: min(250px, 82vw); }
.ds__bubble { font-size: 11px; top: -$sp-8; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill, .ds.is-demo .ds__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ds {
width: min(300px, 86vw);
color: #17141a;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
margin: 48px 0 24px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 56%;
background: #17141a;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 76%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__bubble {
position: absolute;
top: -40px;
transform: translate(-50%, 0);
background: #17141a;
color: #fff7e6;
border-radius: 8px;
padding: 4px 8px;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ds__bubble--merged {
opacity: 0;
background: #c93000;
}
.ds.is-merged .ds__bubble--lo,
.ds.is-merged .ds__bubble--hi {
opacity: 0;
transform: translate(-50%, 0) scale(0.6);
}
.ds.is-merged .ds__bubble--merged {
opacity: 1;
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__bubble {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms ease-out, transform 220ms ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(250px, 82vw);
}
.ds__bubble {
font-size: 11px;
top: -32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill, .ds.is-demo .ds__bubble {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, MERGE_PX = 46;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var bLo = document.getElementById('bubbleLo');
var bHi = document.getElementById('bubbleHi');
var bMerged = document.getElementById('bubbleMerged');
var v = { lo: 20, hi: 76 };
// 계산 겹: 두 값 풍선의 실제 화면 간격(px)을 트랙 실측 폭으로 구해 MERGE_PX 보다 좁으면 하나로 합친다.
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
bLo.style.left = v.lo + '%'; bHi.style.left = v.hi + '%';
bMerged.style.left = (v.lo + v.hi) / 2 + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
bLo.textContent = String(v.lo); bHi.textContent = String(v.hi);
bMerged.textContent = v.lo + ' – ' + v.hi;
var r = track.getBoundingClientRect();
var gapPx = r.width * (v.hi - v.lo) / 100;
var merged = gapPx < MERGE_PX;
root.classList.toggle('is-merged', merged);
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var presets = [[44, 52], [10, 90], [38, 44], [15, 70], [58, 63]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderTooltipPairFollow.module.scss';
const MIN = 0;
const MAX = 100;
const MERGE_PX = 46;
const TICK_MS = 480;
const PRESETS: [number, number][] = [[44, 52], [10, 90], [38, 44], [15, 70], [58, 63]];
export default function DualSliderTooltipPairFollow() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const bLoRef = useRef<HTMLDivElement>(null);
const bHiRef = useRef<HTMLDivElement>(null);
const bMergedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const bLo = bLoRef.current;
const bHi = bHiRef.current;
const bMerged = bMergedRef.current;
if (!root || !track || !fill || !lo || !hi || !bLo || !bHi || !bMerged) return undefined;
const v = { lo: 20, hi: 76 };
// 계산 겹: 두 값 풍선의 실제 화면 간격(px)을 트랙 실측 폭으로 구해 MERGE_PX 보다 좁으면 하나로 합친다.
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
bLo!.style.left = v.lo + '%'; bHi!.style.left = v.hi + '%';
bMerged!.style.left = (v.lo + v.hi) / 2 + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
bLo!.textContent = String(v.lo); bHi!.textContent = String(v.hi);
bMerged!.textContent = v.lo + ' – ' + v.hi;
const r = track!.getBoundingClientRect();
const gapPx = r.width * (v.hi - v.lo) / 100;
const merged = gapPx < MERGE_PX;
root!.classList.toggle(styles['is-merged'], merged);
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<div className={styles.dsBubble + ' ' + styles.dsBubbleLo} ref={bLoRef}>20</div>
<div className={styles.dsBubble + ' ' + styles.dsBubbleHi} ref={bHiRef}>76</div>
<div className={styles.dsBubble + ' ' + styles.dsBubbleMerged} ref={bMergedRef}>20 – 76</div>
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={20} aria-valuetext="20" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={76} aria-valuetext="76" tabIndex={0} />
</div>
</div>
</main>
);
}03 · 値が近づくと2つの吹き出しが1つに合体する範囲スライダー — 計測 累積面積 8.158% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 2つの値吹き出しの実際の画面上の間隔(px)をトラック実測幅から求め、MERGE_PX より狭くなったら1つに合体させる
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
bLo.style.left = v.lo + '%'; bHi.style.left = v.hi + '%';
bMerged.style.left = (v.lo + v.hi) / 2 + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
bLo.textContent = String(v.lo); bHi.textContent = String(v.hi);
bMerged.textContent = v.lo + ' – ' + v.hi;
var r = track.getBoundingClientRect();
var gapPx = r.width * (v.hi - v.lo) / 100;
var merged = gapPx < MERGE_PX;
root.classList.toggle('is-merged', merged);
}
04中央をドラッグすると2つのハンドルが一緒に動く範囲スライダー
満ちた区間の中央をつかんで引くと、2つのハンドルの間隔はそのままに区間全体が一緒に動き、端に触れるとその場で止まります。選んだ期間を幅を保ったまま前後に動かすスケジュールフィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="10" aria-valuetext="10" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="40" aria-valuetext="40" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">10</span> – <span id="hiOut">40</span></p>
<p class="ds__caption">区間の中央を引くと幅はそのままに移動</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-lilac;
// 계산 겹: shiftBoth()/fill 드래그 핸들러가 폭(hi-lo)을 상수로 고정한 채 두 값을 같은 delta 만큼
// 평행 이동시키고, MIN·MAX 끝단에서 그 이상 못 가게 clamp 한다.
@include stage($stage-paper);
.ds { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.ds__track {
position: relative; height: 10px; border-radius: $r-pill;
background: rgba($subject-ink, .1); margin: $sp-10 0 $sp-4;
border: 1px solid rgba($subject-ink, .12);
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 10%; width: 30%;
background: $color; border-radius: $r-pill; cursor: grab; touch-action: none;
}
.ds__fill:active { cursor: grabbing; }
.ds__thumb {
position: absolute; top: 50%; left: 10%; width: 18px; height: 18px;
appearance: none; border: 2px solid $color; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 40%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__readout {
margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-ink, .68); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing;
}
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { 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);
}
.ds {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.ds__track {
position: relative;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
margin: 40px 0 16px;
border: 1px solid rgba(23, 20, 26, 0.12);
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 10%;
width: 30%;
background: #b9a0ff;
border-radius: 999px;
cursor: grab;
touch-action: none;
}
.ds__fill:active {
cursor: grabbing;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 10%;
width: 18px;
height: 18px;
appearance: none;
border: 2px solid #b9a0ff;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 40%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(23, 20, 26, 0.68);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s ease-out, width 2s ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var v = { lo: 10, hi: 40 };
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
// 계산 겹: 폭(hi-lo)은 그대로 두고 두 값을 같은 delta 만큼 평행 이동시키되, 끝단에 닿으면 그 자리에서 멈춘다.
function shiftBoth(deltaPct) {
var w = v.hi - v.lo;
var nlo = v.lo + deltaPct;
nlo = Math.max(MIN, Math.min(nlo, MAX - w));
v.lo = nlo; v.hi = nlo + w;
paint();
}
function pctFromX(x) {
var r = track.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); e.stopPropagation(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(Math.round(pctFromX(ev.clientX))); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
fill.addEventListener('pointerdown', function (e) {
human(); fill.setPointerCapture(e.pointerId);
var startX = e.clientX;
var r = track.getBoundingClientRect();
function move(ev) {
var deltaPct = (ev.clientX - startX) / r.width * 100;
shiftBoth(deltaPct);
startX = ev.clientX;
}
function up() { fill.removeEventListener('pointermove', move); fill.removeEventListener('pointerup', up); }
fill.addEventListener('pointermove', move); fill.addEventListener('pointerup', up);
});
var centers = [25, 70, 45, 85, 15];
var width = v.hi - v.lo;
var pi = 0;
function tick() {
var c = centers[pi % centers.length]; pi++;
var target = c - width / 2;
shiftBoth(target - v.lo);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderDragBothHandlesShift.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
const CENTERS = [25, 70, 45, 85, 15];
export default function DualSliderDragBothHandlesShift() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!root || !track || !fill || !lo || !hi || !loOut || !hiOut) return undefined;
const v = { lo: 10, hi: 40 };
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
// 계산 겹: 폭(hi-lo)은 그대로 두고 두 값을 같은 delta 만큼 평행 이동시키되, 끝단에 닿으면 그 자리에서 멈춘다.
function shiftBoth(deltaPct: number) {
const w = v.hi - v.lo;
let nlo = v.lo + deltaPct;
nlo = Math.max(MIN, Math.min(nlo, MAX - w));
v.lo = nlo; v.hi = nlo + w;
paint();
}
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); e.stopPropagation(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(Math.round(pctFromX(ev.clientX))); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
function onFillDown(e: PointerEvent) {
human(); fill!.setPointerCapture(e.pointerId);
let startX = e.clientX;
const r = track!.getBoundingClientRect();
function move(ev: PointerEvent) {
const deltaPct = (ev.clientX - startX) / r.width * 100;
shiftBoth(deltaPct);
startX = ev.clientX;
}
function up() { fill!.removeEventListener('pointermove', move); fill!.removeEventListener('pointerup', up); }
fill!.addEventListener('pointermove', move); fill!.addEventListener('pointerup', up);
}
fill.addEventListener('pointerdown', onFillDown);
const width = v.hi - v.lo;
let pi = 0;
function tick() {
const c = CENTERS[pi % CENTERS.length]; pi++;
const target = c - width / 2;
shiftBoth(target - v.lo);
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); fill!.removeEventListener('pointerdown', onFillDown); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={10} aria-valuetext="10" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={40} aria-valuetext="40" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>10</span> – <span ref={hiOutRef}>40</span></p>
<p className={styles.dsCaption}>구간 가운데를 끌면 폭은 그대로 이동</p>
</div>
</main>
);
}04 · 中央をドラッグすると2つのハンドルが一緒に動く範囲スライダー — 計測 累積面積 4.107% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 幅(hi-lo)はそのままに2つの値を同じ delta だけ平行移動させ、端に触れた時点でその場に止める
function shiftBoth(deltaPct) {
var w = v.hi - v.lo;
var nlo = v.lo + deltaPct;
nlo = Math.max(MIN, Math.min(nlo, MAX - w));
v.lo = nlo; v.hi = nlo + w;
paint();
}
05Tab で2つのハンドルを交互に操作する範囲スライダー
Tab キーで低い方と高い方のハンドルにフォーカスが交互に移り、方向キーを押すと今フォーカスのあるハンドルだけが一段ずつ動き、最小間隔は保たれます。マウスなしで値を正確に合わせるフォームフィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="24" aria-valuetext="24" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="66" aria-valuetext="66" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">24</span> – <span id="hiOut">66</span></p>
<p class="ds__status" id="status" role="status" aria-live="polite">Tab · 最小値にフォーカス</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
// 계산 겹: focusin/focusout 으로 지금 초점이 어느 핸들인지 추적해 그 핸들에만 강조 테두리를 준다.
@include stage($stage-ink);
.ds { width: min(280px, 84vw); color: $subject-cream; text-align: center; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 24%; width: 42%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 24%; width: 18px; height: 18px;
appearance: none; border: 2px solid transparent; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 66%; }
.ds__thumb:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds.is-active-lo .ds__thumb--lo,
.ds.is-active-hi .ds__thumb--hi { border-color: $color; }
.ds__readout {
margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__status { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-cream, .72); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing, border-color $dur-quick $ease-out;
}
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__status { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { 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);
}
.ds {
width: min(280px, 84vw);
color: #fff7e6;
text-align: center;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 24%;
width: 42%;
background: #6ec8ff;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 24%;
width: 18px;
height: 18px;
appearance: none;
border: 2px solid transparent;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 66%;
}
.ds__thumb:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds.is-active-lo .ds__thumb--lo,
.ds.is-active-hi .ds__thumb--hi {
border-color: #6ec8ff;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__status {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s ease-out, width 2s ease-out, border-color 220ms ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__status {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var status = document.getElementById('status');
var v = { lo: 24, hi: 66 };
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
// 계산 겹: focusin/focusout 으로 지금 초점이 어느 핸들인지 추적해 그 핸들만 방향키로 움직인다.
function setActive(which) {
root.classList.toggle('is-active-lo', which === 'lo');
root.classList.toggle('is-active-hi', which === 'hi');
status.textContent = which === 'lo' ? 'Tab · 最小値にフォーカス' : 'Tab · 最大値にフォーカス';
}
lo.addEventListener('focusin', function () { setActive('lo'); });
hi.addEventListener('focusin', function () { setActive('hi'); });
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var seq = [
{ which: 'lo', to: 55 },
{ which: 'hi', to: 60 },
{ which: 'lo', to: 8 },
{ which: 'hi', to: 94 },
{ which: 'lo', to: 24 }
];
var si = 0;
function tick() {
var step = seq[si % seq.length]; si++;
var el = step.which === 'lo' ? lo : hi;
el.focus({ preventScroll: true });
setActive(step.which);
if (step.which === 'lo') setLo(step.to); else setHi(step.to);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderKeyboardPairNavigate.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
const SEQ: { which: 'lo' | 'hi'; to: number }[] = [
{ which: 'lo', to: 55 },
{ which: 'hi', to: 60 },
{ which: 'lo', to: 8 },
{ which: 'hi', to: 94 },
{ which: 'lo', to: 24 }
];
export default function DualSliderKeyboardPairNavigate() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
const status = statusRef.current;
if (!root || !track || !fill || !lo || !hi || !loOut || !hiOut || !status) return undefined;
const v = { lo: 24, hi: 66 };
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
// 계산 겹: focusin/focusout 으로 지금 초점이 어느 핸들인지 추적해 그 핸들만 방향키로 움직인다.
function setActive(which: 'lo' | 'hi') {
root!.classList.toggle(styles['is-active-lo'], which === 'lo');
root!.classList.toggle(styles['is-active-hi'], which === 'hi');
status!.textContent = which === 'lo' ? 'Tab · 최소값 초점' : 'Tab · 최대값 초점';
}
function onFocusLo() { setActive('lo'); }
function onFocusHi() { setActive('hi'); }
lo.addEventListener('focusin', onFocusLo);
hi.addEventListener('focusin', onFocusHi);
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let si = 0;
function tick() {
const step = SEQ[si % SEQ.length]; si++;
const el = step.which === 'lo' ? lo! : hi!;
el.focus({ preventScroll: true });
setActive(step.which);
if (step.which === 'lo') setLo(step.to); else setHi(step.to);
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => {
offLo(); offHi();
lo!.removeEventListener('focusin', onFocusLo); hi!.removeEventListener('focusin', onFocusHi);
window.clearInterval(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={24} aria-valuetext="24" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={66} aria-valuetext="66" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>24</span> – <span ref={hiOutRef}>66</span></p>
<p className={styles.dsStatus} ref={statusRef} role="status" aria-live="polite">Tab · 최소값 초점</p>
</div>
</main>
);
}05 · Tab で2つのハンドルを交互に操作する範囲スライダー — 計測 累積面積 3.223% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: focusin/focusout で今どちらのハンドルにフォーカスがあるかを追跡し、そのハンドルだけを方向キーで動かす
function setActive(which) {
root.classList.toggle('is-active-lo', which === 'lo');
root.classList.toggle('is-active-hi', which === 'hi');
status.textContent = which === 'lo' ? 'Tab · 最小値にフォーカス' : 'Tab · 最大値にフォーカス';
}
lo.addEventListener('focusin', function () { setActive('lo'); });
hi.addEventListener('focusin', function () { setActive('hi'); });
06ヒストグラム背景の上で区間を選ぶ範囲スライダー
トラックのすぐ奥に値の区間ごとの棒グラフが敷かれていて、ハンドルで選んだ区間が全体の分布のどこに当たるかを棒の高さで一緒に示します。価格帯別の件数分布を一緒に見せる検索フィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__hist" id="hist" aria-hidden="true"></div>
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="30" aria-valuetext="30" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="72" aria-valuetext="72" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">30</span> – <span id="hiOut">72</span></p>
<p class="ds__caption">棒グラフの分布の上で区間だけを明るくする</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: 고정 배열 막대의 중심 좌표가 선택 구간 안인지 매 프레임 재계산해 강조를 다시 칠한다.
@include stage($stage-yellow);
.ds { width: min(300px, 86vw); color: $subject-ink; text-align: center; }
.ds__hist {
position: relative; height: 48px; margin-bottom: $sp-2;
}
.ds__bar {
position: absolute; bottom: 0; background: rgba($subject-ink, .18);
border-radius: $r-xs $r-xs 0 0;
}
.ds__bar.is-in { background: $color; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-ink, .14); margin: 0 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 30%; width: 42%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 30%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 72%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__readout { margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2; font-variant-numeric: tabular-nums; }
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-ink, .68); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing;
}
.ds__bar { transition: background $dur-quick $ease-out; }
@media (max-width: 340px) {
.ds { width: min(250px, 82vw); }
.ds__hist { height: 38px; }
.ds__readout { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { transition: none; }
.ds__bar { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ds {
width: min(300px, 86vw);
color: #17141a;
text-align: center;
}
.ds__hist {
position: relative;
height: 48px;
margin-bottom: 8px;
}
.ds__bar {
position: absolute;
bottom: 0;
background: rgba(23, 20, 26, 0.18);
border-radius: 4px 4px 0 0;
}
.ds__bar.is-in {
background: #4cd4a6;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
margin: 0 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 30%;
width: 42%;
background: #4cd4a6;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 30%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 72%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(23, 20, 26, 0.68);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s ease-out, width 2s ease-out;
}
.ds__bar {
transition: background 220ms ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(250px, 82vw);
}
.ds__hist {
height: 38px;
}
.ds__readout {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
.ds__bar {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
// 결정론적 분포 — Math.random 없이 고정 배열로 막대 높이를 정한다 (%, 0~100)
var HEIGHTS = [22, 40, 65, 38, 52, 70, 45, 60, 33, 48, 80, 55, 42, 66, 30, 58, 44, 72, 50, 36, 62, 48, 54, 40];
var root = document.getElementById('ds');
var hist = document.getElementById('hist');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var v = { lo: 30, hi: 72 };
var bars = [];
HEIGHTS.forEach(function (h, i) {
var bar = document.createElement('div');
bar.className = 'ds__bar';
bar.style.left = (i / HEIGHTS.length * 100) + '%';
bar.style.width = (100 / HEIGHTS.length * 0.7) + '%';
bar.style.height = h + '%';
hist.appendChild(bar);
bars.push({ el: bar, center: (i + 0.5) / HEIGHTS.length * 100 });
});
// 계산 겹: 막대의 실제 중심 위치(%)가 [lo, hi] 구간 안에 들어오는지로 강조 여부를 결정한다.
function paintBars() {
bars.forEach(function (b) {
b.el.classList.toggle('is-in', b.center >= v.lo && b.center <= v.hi);
});
}
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
paintBars();
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var presets = [[10, 35], [55, 90], [0, 100], [42, 48], [20, 65]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderHistogramTrackBg.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
// 결정론적 분포 — Math.random 없이 고정 배열로 막대 높이를 정한다 (%, 0~100)
const HEIGHTS = [22, 40, 65, 38, 52, 70, 45, 60, 33, 48, 80, 55, 42, 66, 30, 58, 44, 72, 50, 36, 62, 48, 54, 40];
const PRESETS: [number, number][] = [[10, 35], [55, 90], [0, 100], [42, 48], [20, 65]];
export default function DualSliderHistogramTrackBg() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
const barRefs = useRef<HTMLDivElement[]>([]);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!root || !track || !fill || !lo || !hi || !loOut || !hiOut || !barRefs.current) return undefined;
const bars = barRefs.current;
if (bars.length !== HEIGHTS.length) return undefined;
const v = { lo: 30, hi: 72 };
const centers = HEIGHTS.map((_, i) => (i + 0.5) / HEIGHTS.length * 100);
// 계산 겹: 막대의 실제 중심 위치(%)가 [lo, hi] 구간 안에 들어오는지로 강조 여부를 결정한다.
function paintBars() {
bars.forEach((el, i) => {
el.classList.toggle(styles['is-in'], centers[i] >= v.lo && centers[i] <= v.hi);
});
}
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
paintBars();
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsHist} aria-hidden="true">
{HEIGHTS.map((h, i) => (
<div
key={i}
className={styles.dsBar}
ref={(el: HTMLDivElement | null) => { if (el && barRefs.current) barRefs.current[i] = el; }}
style={{ left: (i / HEIGHTS.length * 100) + '%', width: (100 / HEIGHTS.length * 0.7) + '%', height: h + '%' }}
/>
))}
</div>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={30} aria-valuetext="30" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={72} aria-valuetext="72" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>30</span> – <span ref={hiOutRef}>72</span></p>
<p className={styles.dsCaption}>막대 분포 위에서 구간만 밝힌다</p>
</div>
</main>
);
}06 · ヒストグラム背景の上で区間を選ぶ範囲スライダー — 計測 累積面積 6.338% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
HEIGHTS.forEach(function (h, i) {
var bar = document.createElement('div');
bar.className = 'ds__bar';
bar.style.left = (i / HEIGHTS.length * 100) + '%';
bar.style.width = (100 / HEIGHTS.length * 0.7) + '%';
bar.style.height = h + '%';
hist.appendChild(bar);
bars.push({ el: bar, center: (i + 0.5) / HEIGHTS.length * 100 });
});
// 計算層: 棒の実際の中心位置(%)が [lo, hi] の区間に入っているかで強調するかどうかを決める
function paintBars() {
bars.forEach(function (b) {
b.el.classList.toggle('is-in', b.center >= v.lo && b.center <= v.hi);
});
}
0710 刻みの目盛りに揃う範囲スライダー
ハンドルを動かした瞬間に10刻みの目盛りへ丸められて止まり、低い方と高い方の値は互いに越えないよう常に比較して固定されます。決まった単位でしか値を選べない数量フィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__ticks" id="ticks" aria-hidden="true"></div>
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-valuetext="20" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" aria-valuetext="70" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">20</span> – <span id="hiOut">70</span></p>
<p class="ds__caption">10刻みの目盛りにだけ止まる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-orange-deep;
// 계산 겹: 값을 STEP=10 단위로 반올림(snap)해 고정하고, 눈금은 getBoundingClientRect 실측 px 로 배치한다.
@include stage($stage-paper);
.ds { width: min(300px, 86vw); color: $subject-ink; text-align: center; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-ink, .14); margin: $sp-12 0 $sp-4;
}
.ds__ticks { position: absolute; inset: 0; pointer-events: none; }
.ds__tick {
position: absolute; top: 50%; width: 2px; height: 8px;
background: rgba($subject-ink, .3); transform: translate(-50%, -50%);
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 50%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $color;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 70%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__readout { margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2; font-variant-numeric: tabular-nums; }
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-ink, .68); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing;
}
@media (max-width: 340px) {
.ds { width: min(250px, 82vw); }
.ds__readout { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { 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);
}
.ds {
width: min(300px, 86vw);
color: #17141a;
text-align: center;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
margin: 48px 0 16px;
}
.ds__ticks {
position: absolute;
inset: 0;
pointer-events: none;
}
.ds__tick {
position: absolute;
top: 50%;
width: 2px;
height: 8px;
background: rgba(23, 20, 26, 0.3);
transform: translate(-50%, -50%);
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 50%;
background: #c93000;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #c93000;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 70%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(23, 20, 26, 0.68);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1);
}
@media (max-width: 340px) {
.ds {
width: min(250px, 82vw);
}
.ds__readout {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, STEP = 10;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var ticksBox = document.getElementById('ticks');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var v = { lo: 20, hi: 70 };
var ticks = [];
for (var n = MIN; n <= MAX; n += STEP) {
var t = document.createElement('span');
t.className = 'ds__tick';
ticksBox.appendChild(t);
ticks.push({ el: t, value: n });
}
// 계산 겹: 눈금을 %가 아니라 트랙의 실측 픽셀 폭(getBoundingClientRect)으로 위치를 다시 계산해 배치한다.
function layoutTicks() {
var r = track.getBoundingClientRect();
ticks.forEach(function (t) {
var x = r.width * (t.value - MIN) / (MAX - MIN);
t.el.style.left = Math.round(x) + 'px';
});
}
function snap(n) { return Math.max(MIN, Math.min(MAX, Math.round(n / STEP) * STEP)); }
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
}
function setLo(n) { v.lo = Math.min(snap(n), v.hi); paint(); }
function setHi(n) { v.hi = Math.max(snap(n), v.lo); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: STEP, ArrowLeft: -STEP, ArrowUp: STEP, ArrowDown: -STEP }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
window.addEventListener('resize', layoutTicks);
var presets = [[0, 100], [30, 60], [10, 90], [40, 50], [20, 80]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
layoutTicks();
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderSnapStepTicks.module.scss';
const MIN = 0;
const MAX = 100;
const STEP = 10;
const TICK_MS = 480;
const TICK_VALUES: number[] = [];
for (let n = MIN; n <= MAX; n += STEP) TICK_VALUES.push(n);
const PRESETS: [number, number][] = [[0, 100], [30, 60], [10, 90], [40, 50], [20, 80]];
export default function DualSliderSnapStepTicks() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
const tickRefs = useRef<HTMLSpanElement[]>([]);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!root || !track || !fill || !lo || !hi || !loOut || !hiOut || !tickRefs.current) return undefined;
const ticks = tickRefs.current;
const v = { lo: 20, hi: 70 };
// 계산 겹: 눈금을 %가 아니라 트랙의 실측 픽셀 폭(getBoundingClientRect)으로 위치를 다시 계산해 배치한다.
function layoutTicks() {
const r = track!.getBoundingClientRect();
ticks.forEach((el, i) => {
const x = r.width * (TICK_VALUES[i] - MIN) / (MAX - MIN);
el.style.left = Math.round(x) + 'px';
});
}
function snap(n: number) { return Math.max(MIN, Math.min(MAX, Math.round(n / STEP) * STEP)); }
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
}
function setLo(n: number) { v.lo = Math.min(snap(n), v.hi); paint(); }
function setHi(n: number) { v.hi = Math.max(snap(n), v.lo); paint(); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: STEP, ArrowLeft: -STEP, ArrowUp: STEP, ArrowDown: -STEP } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
window.addEventListener('resize', layoutTicks);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
layoutTicks();
paint();
return () => { offLo(); offHi(); window.removeEventListener('resize', layoutTicks); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsTicks} aria-hidden="true">
{TICK_VALUES.map((n, i) => (
<span key={n} className={styles.dsTick} ref={(el: HTMLSpanElement | null) => { if (el && tickRefs.current) tickRefs.current[i] = el; }} />
))}
</div>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={20} aria-valuetext="20" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={70} aria-valuetext="70" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>20</span> – <span ref={hiOutRef}>70</span></p>
<p className={styles.dsCaption}>10 단위 눈금에만 멈춘다</p>
</div>
</main>
);
}07 · 10 刻みの目盛りに揃う範囲スライダー — 計測 累積面積 5.147% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 目盛りを%ではなく、トラックの実測ピクセル幅(getBoundingClientRect)から位置を計算し直して配置する
function layoutTicks() {
var r = track.getBoundingClientRect();
ticks.forEach(function (t) {
var x = r.width * (t.value - MIN) / (MAX - MIN);
t.el.style.left = Math.round(x) + 'px';
});
}
function snap(n) { return Math.max(MIN, Math.min(MAX, Math.round(n / STEP) * STEP)); }
08最小間隔を守るために弾性で押される範囲スライダー
2つのハンドルの間隔が最小値より狭くなろうとすると反対側のハンドルも一緒に押し出されて間隔を守り、画面上の位置は質量減衰スプリング物理で目標値を追いかけてわずかにオーバーシュートしながら追従します。間隔が狭くなりすぎてはいけない人数範囲フィルターに向いています。

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-valuetext="20" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" aria-valuetext="70" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">20</span> – <span id="hiOut">70</span></p>
<p class="ds__caption">間隔を12以下に狭めると反対側が弾性で押される</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개 — 스프링 물리는 JS 상수(K, DAMP)로 관리해 CSS 트랜지션은 쓰지 않는다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-pink;
// 계산 겹: 목표값은 즉시 갱신되지만 화면 위치는 질량-감쇠 스프링으로 뒤쫓아 오버슈트한다 (rAF).
@include stage($stage-ink);
.ds { width: min(280px, 84vw); color: $subject-cream; text-align: center; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 50%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 70%; }
.ds__thumb:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__readout { margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2; font-variant-numeric: tabular-nums; }
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds__thumb, .ds__fill { 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);
}
.ds {
width: min(280px, 84vw);
color: #fff7e6;
text-align: center;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 50%;
background: #ff9ebb;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 70%;
}
.ds__thumb:focus-visible {
outline: 2px solid #ff9ebb;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds__thumb, .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, MIN_GAP = 12, K = 210, DAMP = 18;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var target = { lo: 20, hi: 70 };
var pos = { lo: { v: 20, vel: 0 }, hi: { v: 70, vel: 0 } };
function paintAria() {
lo.setAttribute('aria-valuenow', String(target.lo)); hi.setAttribute('aria-valuenow', String(target.hi));
lo.setAttribute('aria-valuetext', String(target.lo)); hi.setAttribute('aria-valuetext', String(target.hi));
loOut.textContent = String(target.lo); hiOut.textContent = String(target.hi);
}
function paintVisual() {
lo.style.left = pos.lo.v + '%'; hi.style.left = pos.hi.v + '%';
fill.style.left = pos.lo.v + '%'; fill.style.width = Math.max(0, pos.hi.v - pos.lo.v) + '%';
}
// 계산 겹: 간격이 MIN_GAP 아래로 좁혀지면 반대쪽 목표값을 함께 밀어내고, 시각 위치는 스프링(질량-감쇠) 물리로 목표를 뒤쫓아 오버슈트한다.
function syncReduced() {
if (!reduce) return;
pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual();
}
function setLo(n) {
var nlo = Math.max(MIN, Math.min(n, MAX));
if (nlo > target.hi - MIN_GAP) {
target.hi = Math.min(MAX, nlo + MIN_GAP);
nlo = Math.min(nlo, target.hi - MIN_GAP);
}
target.lo = nlo; paintAria(); syncReduced();
}
function setHi(n) {
var nhi = Math.min(MAX, Math.max(n, MIN));
if (nhi < target.lo + MIN_GAP) {
target.lo = Math.max(MIN, nhi - MIN_GAP);
nhi = Math.max(nhi, target.lo + MIN_GAP);
}
target.hi = nhi; paintAria(); syncReduced();
}
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? target.lo : target.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : target.lo + MIN_GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? target.hi - MIN_GAP : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var last = null;
function springFrame(ts) {
if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
if (last === null) last = ts;
var dt = Math.min(0.032, (ts - last) / 1000); last = ts;
['lo', 'hi'].forEach(function (k) {
var s = pos[k], t = target[k];
var force = -K * (s.v - t) - DAMP * s.vel;
s.vel += force * dt; s.v += s.vel * dt;
});
paintVisual();
requestAnimationFrame(springFrame);
}
if (!reduce) requestAnimationFrame(springFrame);
var seq = [
{ who: 'lo', to: 62 },
{ who: 'hi', to: 25 },
{ who: 'lo', to: 55 },
{ who: 'hi', to: 15 },
{ who: 'lo', to: 20 }
];
var si = 0;
function tick() {
var step = seq[si % seq.length]; si++;
if (step.who === 'lo') setLo(step.to); else setHi(step.to);
}
var timer = reduce ? null : setInterval(tick, 480);
paintAria(); paintVisual();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderMinGapElasticPush.module.scss';
const MIN = 0;
const MAX = 100;
const MIN_GAP = 12;
const K = 210;
const DAMP = 18;
const TICK_MS = 480;
const SEQ: { who: 'lo' | 'hi'; to: number }[] = [
{ who: 'lo', to: 62 },
{ who: 'hi', to: 25 },
{ who: 'lo', to: 55 },
{ who: 'hi', to: 15 },
{ who: 'lo', to: 20 }
];
export default function DualSliderMinGapElasticPush() {
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!track || !fill || !lo || !hi || !loOut || !hiOut) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const target = { lo: 20, hi: 70 };
const pos = { lo: { v: 20, vel: 0 }, hi: { v: 70, vel: 0 } };
function paintAria() {
lo!.setAttribute('aria-valuenow', String(target.lo)); hi!.setAttribute('aria-valuenow', String(target.hi));
lo!.setAttribute('aria-valuetext', String(target.lo)); hi!.setAttribute('aria-valuetext', String(target.hi));
loOut!.textContent = String(target.lo); hiOut!.textContent = String(target.hi);
}
function paintVisual() {
lo!.style.left = pos.lo.v + '%'; hi!.style.left = pos.hi.v + '%';
fill!.style.left = pos.lo.v + '%'; fill!.style.width = Math.max(0, pos.hi.v - pos.lo.v) + '%';
}
function syncReduced() {
if (!reduce) return;
pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual();
}
// 계산 겹: 간격이 MIN_GAP 아래로 좁혀지면 반대쪽 목표값을 함께 밀어내고, 시각 위치는 스프링(질량-감쇠) 물리로 목표를 뒤쫓아 오버슈트한다.
function setLo(n: number) {
let nlo = Math.max(MIN, Math.min(n, MAX));
if (nlo > target.hi - MIN_GAP) {
target.hi = Math.min(MAX, nlo + MIN_GAP);
nlo = Math.min(nlo, target.hi - MIN_GAP);
}
target.lo = nlo; paintAria(); syncReduced();
}
function setHi(n: number) {
let nhi = Math.min(MAX, Math.max(n, MIN));
if (nhi < target.lo + MIN_GAP) {
target.lo = Math.max(MIN, nhi - MIN_GAP);
nhi = Math.max(nhi, target.lo + MIN_GAP);
}
target.hi = nhi; paintAria(); syncReduced();
}
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? target.lo : target.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : target.lo + MIN_GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? target.hi - MIN_GAP : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let last: number | null = null;
let rafId = 0;
let stopped = false;
function springFrame(ts: number) {
if (stopped) return;
if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
if (last === null) last = ts;
const dt = Math.min(0.032, (ts - last) / 1000); last = ts;
(['lo', 'hi'] as const).forEach((k) => {
const s = pos[k]; const t = target[k];
const force = -K * (s.v - t) - DAMP * s.vel;
s.vel += force * dt; s.v += s.vel * dt;
});
paintVisual();
rafId = requestAnimationFrame(springFrame);
}
if (!reduce) rafId = requestAnimationFrame(springFrame);
let si = 0;
function tick() {
const step = SEQ[si % SEQ.length]; si++;
if (step.who === 'lo') setLo(step.to); else setHi(step.to);
}
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paintAria(); paintVisual();
return () => { offLo(); offHi(); stopped = true; cancelAnimationFrame(rafId); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds} id="ds">
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={20} aria-valuetext="20" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={70} aria-valuetext="70" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>20</span> – <span ref={hiOutRef}>70</span></p>
<p className={styles.dsCaption}>간격 12 이하로 좁히면 반대쪽이 탄성으로 밀린다</p>
</div>
</main>
);
}08 · 最小間隔を守るために弾性で押される範囲スライダー — 計測 累積面積 3.027% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function springFrame(ts) {
if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
if (last === null) last = ts;
var dt = Math.min(0.032, (ts - last) / 1000); last = ts;
['lo', 'hi'].forEach(function (k) {
var s = pos[k], t = target[k];
var force = -K * (s.v - t) - DAMP * s.vel;
s.vel += force * dt; s.v += s.vel * dt;
});
paintVisual();
requestAnimationFrame(springFrame);
}
09モーション削減と通常モーションを並べて比較する範囲スライダー
同じ2つのハンドル値を2つのパネルに同時に描画し、左パネルは位置の変化にトランジションがかかりますが右のプレビューはトランジションなしで即座に移動するので、モーション削減時の結果を通常の動きと並べて比較できます。動きに敏感なユーザーのためのアクセシビリティ確認用プレビューに向いています。

<main class="stage">
<div class="cmp">
<div class="cmp__panel">
<p class="cmp__label">通常モーション</p>
<div class="ds is-demo" id="dsA">
<div class="ds__track" id="trackA">
<div class="ds__fill" id="fillA"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLoA" role="slider"
aria-label="最小値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="25" aria-valuetext="25" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHiA" role="slider"
aria-label="最大値" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" aria-valuetext="70" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOutA">25</span> – <span id="hiOutA">70</span></p>
</div>
</div>
<div class="cmp__panel">
<p class="cmp__label">縮小モーションのプレビュー</p>
<div class="ds ds--mirror" id="dsB" aria-hidden="true">
<div class="ds__track">
<div class="ds__fill" id="fillB"></div>
<span class="ds__thumb ds__thumb--lo" id="thumbLoB"></span>
<span class="ds__thumb ds__thumb--hi" id="thumbHiB"></span>
</div>
<p class="ds__readout"><span id="loOutB">25</span> – <span id="hiOutB">70</span></p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-blue;
// 계산 겹: 오른쪽 미리보기 패널은 transition 규칙 자체가 없어 같은 상태값이 항상 즉시 이동으로 그려진다.
@include stage($stage-paper);
.cmp { display: flex; gap: $sp-8; width: min(340px, 92vw); }
.cmp__panel { flex: 1; text-align: center; }
.cmp__label {
margin: 0 0 $sp-2; font-weight: 700; font-size: 11px; color: rgba($subject-ink, .68);
text-transform: uppercase; letter-spacing: .02em;
}
.ds { color: $subject-ink; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-ink, .14); margin: $sp-6 0 $sp-3;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 25%; width: 45%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 25%; width: 16px; height: 16px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 70%; }
button.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
button.ds__thumb:active { cursor: grabbing; }
.ds__readout { margin: 0; font-weight: 700; font-size: 15px; line-height: 1.2; font-variant-numeric: tabular-nums; }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing;
}
// 미리보기(.ds--mirror) 는 is-demo 여부와 상관없이 transition 이 없다 — "즉시 이동"이 이 패널의 핵심.
.ds--mirror .ds__thumb,
.ds--mirror .ds__fill { transition: none; }
.ds--mirror .ds__thumb { cursor: default; }
@media (max-width: 340px) {
.cmp { width: min(280px, 92vw); gap: $sp-4; }
.ds__readout { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill { 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);
}
.cmp {
display: flex;
gap: 32px;
width: min(340px, 92vw);
}
.cmp__panel {
flex: 1;
text-align: center;
}
.cmp__label {
margin: 0 0 8px;
font-weight: 700;
font-size: 11px;
color: rgba(23, 20, 26, 0.68);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.ds {
color: #17141a;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
margin: 24px 0 12px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 25%;
width: 45%;
background: #2f6df6;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 25%;
width: 16px;
height: 16px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 70%;
}
button.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
button.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 15px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ds--mirror .ds__thumb,
.ds--mirror .ds__fill {
transition: none;
}
.ds--mirror .ds__thumb {
cursor: default;
}
@media (max-width: 340px) {
.cmp {
width: min(280px, 92vw);
gap: 16px;
}
.ds__readout {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
var root = document.getElementById('dsA');
var track = document.getElementById('trackA');
var fillA = document.getElementById('fillA');
var fillB = document.getElementById('fillB');
var lo = document.getElementById('thumbLoA');
var hi = document.getElementById('thumbHiA');
var loB = document.getElementById('thumbLoB');
var hiB = document.getElementById('thumbHiB');
var loOutA = document.getElementById('loOutA'); var hiOutA = document.getElementById('hiOutA');
var loOutB = document.getElementById('loOutB'); var hiOutB = document.getElementById('hiOutB');
var v = { lo: 25, hi: 70 };
// 계산 겹: 같은 상태값 하나를 두 패널에 동시에 그리되, 오른쪽 미리보기는 transition 을 원천적으로 갖지 않아
// "모션 줄임" 사용자가 보는 즉시-이동 결과를 항상 함께 보여준다 (실제 OS 설정과 무관한 비교용).
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fillA.style.left = v.lo + '%'; fillA.style.width = (v.hi - v.lo) + '%';
loB.style.left = v.lo + '%'; hiB.style.left = v.hi + '%';
fillB.style.left = v.lo + '%'; fillB.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOutA.textContent = String(v.lo); hiOutA.textContent = String(v.hi);
loOutB.textContent = String(v.lo); hiOutB.textContent = String(v.hi);
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var presets = [[10, 40], [55, 95], [20, 30], [5, 85], [45, 60]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderReducedMotionStatic.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
const PRESETS: [number, number][] = [[10, 40], [55, 95], [20, 30], [5, 85], [45, 60]];
export default function DualSliderReducedMotionStatic() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillARef = useRef<HTMLDivElement>(null);
const fillBRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loBRef = useRef<HTMLSpanElement>(null);
const hiBRef = useRef<HTMLSpanElement>(null);
const loOutARef = useRef<HTMLSpanElement>(null);
const hiOutARef = useRef<HTMLSpanElement>(null);
const loOutBRef = useRef<HTMLSpanElement>(null);
const hiOutBRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fillA = fillARef.current;
const fillB = fillBRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loB = loBRef.current;
const hiB = hiBRef.current;
const loOutA = loOutARef.current; const hiOutA = hiOutARef.current;
const loOutB = loOutBRef.current; const hiOutB = hiOutBRef.current;
if (!root || !track || !fillA || !fillB || !lo || !hi || !loB || !hiB || !loOutA || !hiOutA || !loOutB || !hiOutB) return undefined;
const v = { lo: 25, hi: 70 };
// 계산 겹: 같은 상태값 하나를 두 패널에 동시에 그리되, 오른쪽 미리보기는 transition 을 원천적으로 갖지 않아
// "모션 줄임" 사용자가 보는 즉시-이동 결과를 항상 함께 보여준다 (실제 OS 설정과 무관한 비교용).
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fillA!.style.left = v.lo + '%'; fillA!.style.width = (v.hi - v.lo) + '%';
loB!.style.left = v.lo + '%'; hiB!.style.left = v.hi + '%';
fillB!.style.left = v.lo + '%'; fillB!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOutA!.textContent = String(v.lo); hiOutA!.textContent = String(v.hi);
loOutB!.textContent = String(v.lo); hiOutB!.textContent = String(v.hi);
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.cmp}>
<div className={styles.cmpPanel}>
<p className={styles.cmpLabel}>일반 모션</p>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillARef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={25} aria-valuetext="25" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={70} aria-valuetext="70" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutARef}>25</span> – <span ref={hiOutARef}>70</span></p>
</div>
</div>
<div className={styles.cmpPanel}>
<p className={styles.cmpLabel}>축소 모션 미리보기</p>
<div className={styles.ds + ' ' + styles.dsMirror} aria-hidden="true">
<div className={styles.dsTrack}>
<div className={styles.dsFill} ref={fillBRef} />
<span className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loBRef} />
<span className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiBRef} />
</div>
<p className={styles.dsReadout}><span ref={loOutBRef}>25</span> – <span ref={hiOutBRef}>70</span></p>
</div>
</div>
</div>
</main>
);
}09 · モーション削減と通常モーションを並べて比較する範囲スライダー — 計測 累積面積 5.484% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算層: 同じ1つの状態を2つのパネルに同時に描画するが、右側のプレビューは transition を最初から持たないため、
// 「モーション削減」利用者が見る即時移動の結果を常に一緒に示す(実際のOS設定とは無関係な比較用)
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fillA.style.left = v.lo + '%'; fillA.style.width = (v.hi - v.lo) + '%';
loB.style.left = v.lo + '%'; hiB.style.left = v.hi + '%';
fillB.style.left = v.lo + '%'; fillB.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOutA.textContent = String(v.lo); hiOutA.textContent = String(v.hi);
loOutB.textContent = String(v.lo); hiOutB.textContent = String(v.hi);
}
値を変えると UI が変わる場所 — 最小間隔・スナップ目盛り間隔・吹き出し合体距離
01 — GAP
この定数は setLo と setHi が常に保つ最小距離を決めます。ドラッグできる範囲そのものではなく、どちらのハンドルも下回れない床の値だけを決めています。
07 — STEP
これは snap() が Math.round(n / STEP) * STEP でハンドルの値を丸める刻み幅です。目盛り線自体の px 幅ではなく、値がどれだけ粗く/細かく止まれるかだけを決めています。
03 — MERGE_PX
これは paint() が2つの値吹き出しを1つに切り替える前に比較する画面上の px 間隔です。値そのものではなく、吹き出しが2つのラベルではなく1つのラベルとして読めるようになる距離だけを決めています。
| 変形 | 変えた値 | 感触 |
|---|---|---|
| GAP 14 → 6 | 最小ハンドル間隔が14から6に縮む | クランプが効くまで2つのハンドルがほぼ重なる位置まで寄せられ、ほぼ幅ゼロの区間も選べるようになる |
| STEP 10 → 25 | スナップ刻みが10から25に広がる | 止まれる位置がぐっと減り、連続的というより粗く区切られた感触でドラッグが進む |
| MERGE_PX 46 → 18 | 合体しきい値が46pxから18pxに縮む | まだ隙間があるうちに合体していたのが、ほぼ触れ合うまで2つの吹き出しが分かれたままになる |
どこで壊れるか — アクセシビリティ・モーション削減・ハンドルの重なり
9種すべてが同じ土台を共有しています。各ハンドルは本物の <button role="slider"> で、aria-valuemin・aria-valuemax・aria-valuenow・aria-valuetext を毎回の描画で同期し、aria-label にはデモ自身が使うのと同じ文言ファイルから取った「最小値」「最大値」が入り、tabindex="0" によって通常の文書順で Tab が届きます。Home・End はハンドルをそれぞれの境界まで動かし、方向キーは1単位ずつ動かします。matchMedia('(prefers-reduced-motion: reduce)') はどのパターンでも同じもの — デモを自動で動かすオートプレイのループ — を止めます。それぞれの SCSS は、そのオートプレイ状態のときだけ left/width のトランジションを外すので、モーション削減を設定している訪問者はページを開いた瞬間からプリセットの切り替えを動きなしで見ます。9種のうち8種は、そのあとドラッグしたり方向キーを押したりすると通常どおりトランジションがかかります。人が意図して選んだ値の動きまでは、この設定が抑える対象ではないからです。
- 01 — サムとフィルのトランジションだけが外れ、オートプレイのプリセットが切り替わっている間だけ効きます。
- 02 — サム・フィル・オートプレイのスイープマーカーのトランジションが一緒に外れます。
- 03 — サム・フィル・合体吹き出しのトランジションが一緒に外れ、合体そのものもモーション削減下ではアニメーションしなくなります。
- 04 — サムとフィルのトランジションが外れ、
shiftBothによるオートプレイ中の平行移動が滑らずに瞬時に切り替わります。 - 05 — サムとフィルのトランジションが外れる一方、どちらのハンドルにフォーカスがあるかを知らせる
role="status"の告知はどちらの設定でも変わりません。テキストの更新は CSS のトランジションではないためです。 - 06 — サム・フィル・ヒストグラムの強調バーのトランジションが一緒に外れます。
- 07 — サムとフィルのトランジションが外れる一方、目盛りの位置はもともとアニメーションしていません。
layoutTicks()は一度と resize 時にしか再計算しないためです。 - 08 — 唯一の例外: ルールがオートプレイ状態に限定されておらず、トランジションが常に外れています。
syncReduced()がドラッグやキーボード操作で動かしたハンドルもスプリングのオーバーシュートを待たず目標値へ直接スナップさせるためです。 - 09 — このパターンの目的そのものが差を並べて見せることなので、条件付きで外れるものは何もありません。右パネルは常にトランジションなし、左パネルは常にトランジションありで、OS設定を変えなくても両方を比較できます。
パスワード nrfh573p で解いた zip を最初から最後まで読むのが、9枚のスタイルシートを並べて、モーション削減のためにどの1行を削っているのかを確かめるいちばん早い方法です。
3つの変形とアクセシビリティ
上の表にすでに確定した3つの変形 — GAP・STEP・MERGE_PX — を載せています。どれも定数を1つだけ入れ替え、リスナー・クランプ・ARIA 属性は元のパターンとまったく同じままです。
レンジスライダー html の実装例はどこで見られる?
「レンジスライダー デザイン」の実例を探すなら、決済前の価格帯の絞り込みや日程フィルターがいちばんわかりやすい場所です。上の9種はどれもプレーンなボタンとプレーンなポインタ・キーボードのリスナー、そして1つずつの小さな計算関数だけでできていて、zip の中にそのままフォームへ貼り付けられる形で入っています。
FAQ
なぜ07は resize でスナップ位置を再計算するのに、01は再計算が要らないのですか?
07の目盛りは layoutTicks() が getBoundingClientRect() で読み取ったトラックの px 幅から配置しているため、resize でその幅が変わると再計算しないと目盛りがラベルからずれてしまいます。01のクランプはハンドルがもともと使っている0〜100の%スケールだけで完結しているので、トラックの px 幅をそもそも知る必要がありません。
08のスプリングのオーバーシュートで間隔が MIN_GAP を下回ることはありますか?
いいえ。スプリングが動く前に target.lo/target.hi の確定値がすでに MIN_GAP でクランプされているため、実データが最小間隔を割ることはありません。オーバーシュートが影響するのは、その目標値を追いかける画面上の位置 pos.lo.v/pos.hi.v だけなので、見た目としては1〜2フレーム行き過ぎたり戻ったりして見えても、実際の値が床を越えることはありません。
この作りのレンジスライダー js をそのまま本番のフォームに使っても安全ですか?
クランプ・キーボード操作・ARIA まわりのロジックはそのまま移して使えます。デモのオートプレイ用プリセットタイマーを、フォームがすでに持っている実際の最小・最大の状態に差し替えれば、関数の残りの部分は変える必要がありません。唯一このデモが意図的に省いているのはフォーム送信用の name/value ペアなので、その結びつけだけは本番で自分で加えてください。
出典: MDN — role="slider" · MDN — Element.getBoundingClientRect() · MDN — prefers-reduced-motion · MDN — requestAnimationFrame() · MDN — Window.matchMedia()
関連記事: css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種 · カレンダー 選択 UI 9選 — コピペOK · 絞り込み ui9選 — パネルスライドからチップ削除まで









