デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
本物の <input type="range"> を透明にしてトラックに重ね、見た目はつまみ・塗り・値の吹き出しで描きます。つかむとつまみが少し大きくなって淡い輪が付き、上に吹き出しが弾んで出ます。引くと吹き出しはつまみに少し遅れてついてきて、動く向きと逆へ傾き、止まるとまっすぐに戻ります。
値が変わるたびに(input イベント)、値からつまみの位置と塗りの幅を決めます。吹き出しの支点は矢印の先に置き、位置は quickTo で 0.2 秒かけて追わせるので、少し遅れてついてきます。傾きは 1 回の入力で動いた量の 0.8 倍(最大 16°)を逆向きに与え、0.1 秒止まったら 0° へ戻します。吹き出しは 0.5 倍から、行き過ぎて戻る動きで弾ませて出します。
range のつまみの中心は、両端から半径ぶん内側しか動きません。range を見た目のトラックより半径ぶん左右に広げ、range のつまみを見た目のつまみと同じ大きさにそろえないと、押した位置と値がずれます。塗りのグラデーションはトラックの幅で描いて見せる幅だけ切り出すので、伸びても色の位置は変わりません。端では吹き出しだけを内側へずらして矢印はつまみに残し、枠の外へはみ出させません。
キーボードの ← → や Home・End、指での操作、読み上げは、本物の range がそのまま受け持ちます。数字だけでは「80」としか読まれないので、値が変わるたびに aria-valuetext へ「80万円」のように単位つきで入れます。キーで動かしている間と、キーボードで焦点が入ったときも吹き出しを出し、焦点が外れたら閉じます。動きを減らす設定では、遅れも傾きも付けず、吹き出しはほぼ一瞬で出します。
吹き出しの遅れを延ばすと重さが出て、傾きを強めると軽やかになります。刻みは step で変え、単位や桁区切りは吹き出しの文字を組み立てる所で変えます。予算や価格の絞り込み、音量、数量の入力に向いています。
コード
<!-- 本物の range を透明にして重ねる。キーボード(← → Home End)・指・読み上げは range がそのまま受け持つ -->
<div class="budget">
<label class="budget__label" for="budget">ご予算</label>
<div class="budget__slider">
<input class="budget__input" id="budget" type="range" name="budget"
min="10" max="500" step="5" value="80" aria-valuetext="80万円">
<!-- ここから下は見た目だけ(pointer-events: none)。押した位置は range が受け取る -->
<span class="budget__track" aria-hidden="true"><span class="budget__fill"></span></span>
<span class="budget__knob" aria-hidden="true"></span>
<span class="budget__anchor" aria-hidden="true">
<span class="budget__bubble"><span class="budget__value">80万円</span></span>
</span>
</div>
<div class="budget__scale" aria-hidden="true">
<span>10万円</span><span>250万円</span><span>500万円</span>
</div>
</div>
.budget {
--ink: #262626; /* 文字・つまみの縁・吹き出し */
--mute: #898989; /* 塗りの左端の色 */
--mute-small: #6b6b6b; /* ラベルと目盛り */
--line: rgba(38, 38, 38, 0.12); /* トラック */
--knob: 32px; /* つまみの直径 */
--track: 8px; /* トラックの太さ */
max-width: 560px;
color: var(--ink);
}
.budget__label { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; color: var(--mute-small); }
/* 上は吹き出しの場所。左右は、端でつまみがはみ出す分 */
.budget__slider { position: relative; height: var(--knob); margin: 64px calc(var(--knob) / 2) 0; }
/* range のつまみの中心は、両端から半径ぶん内側しか動かない。range を半径だけ左右にはみ出させ、
つまみを見た目と同じ大きさにすると、押した位置と値がずれない */
.budget__input {
position: absolute;
top: 0;
left: calc(var(--knob) / -2);
width: calc(100% + var(--knob));
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.budget__input::-webkit-slider-thumb { width: var(--knob); height: var(--knob); -webkit-appearance: none; appearance: none; }
.budget__input::-moz-range-thumb { width: var(--knob); height: var(--knob); border: 0; }
.budget__track, .budget__knob, .budget__anchor { position: absolute; pointer-events: none; }
.budget__track { top: calc(50% - var(--track) / 2); left: 0; right: 0; height: var(--track); border-radius: var(--track); background: var(--line); }
/* グラデーションはトラックの幅で描き、見せる幅だけ切り出す(伸びても色の位置が変わらない) */
.budget__fill {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--mute), var(--ink)) 0 0 / var(--track-width, 100%) 100% no-repeat;
}
.budget__knob {
top: 0;
left: calc(var(--knob) / -2);
width: var(--knob);
height: var(--knob);
box-sizing: border-box;
border: 2px solid var(--ink);
border-radius: 50%;
background: #ffffff;
}
.budget__input:focus-visible ~ .budget__knob { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 吹き出しの支点(矢印の先)。つまみの 8px 上に置き、ここを中心に傾ける */
.budget__anchor { top: -8px; left: 0; }
/* 下に矢印の高さを空け、外枠の下端を矢印の先にする(script.js が中央へずらして出す) */
.budget__bubble { position: absolute; bottom: 0; left: 0; padding-bottom: 8px; visibility: hidden; }
.budget__bubble::after {
content: "";
position: absolute;
bottom: 0;
left: calc(50% - 8px - var(--shift, 0px)); /* 端で吹き出しをずらしても、矢印はつまみを指す */
border: 8px solid transparent;
border-top-color: var(--ink);
border-bottom: 0;
}
.budget__value {
display: block;
padding: 0 12px;
border-radius: 4px;
background: var(--ink);
color: #ffffff;
font-size: 15px;
font-weight: 700;
line-height: 32px;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.budget__scale {
display: flex;
justify-content: space-between;
margin: 16px calc(var(--knob) / 2) 0;
font-size: 13px;
font-weight: 600;
color: var(--mute-small);
}
import { gsap } from "gsap";
// つかんだときの淡い輪。box-shadow は、同じ書式の値どうしで補間する
const HALO_OFF = "0 0 0 0px rgba(38, 38, 38, 0)";
const HALO_ON = "0 0 0 8px rgba(38, 38, 38, 0.12)";
const LEAN = 0.8; // 1 回の入力で動いた量(px)あたりの傾き(度)
const MAX_TILT = 16; // 傾きの最大(度)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".budget").forEach((budget) => {
const input = budget.querySelector(".budget__input");
const track = budget.querySelector(".budget__track");
const fill = budget.querySelector(".budget__fill");
const knob = budget.querySelector(".budget__knob");
const anchor = budget.querySelector(".budget__anchor");
const bubble = budget.querySelector(".budget__bubble");
const output = budget.querySelector(".budget__value");
// 吹き出しは中央を矢印の先にそろえ、矢印の先を中心に拡大する(中央そろえも GSAP に持たせる)
gsap.set(bubble, { xPercent: -50, transformOrigin: "50% 100%" });
const grab = gsap
.timeline({ paused: true })
.fromTo(knob, { scale: 1, boxShadow: HALO_OFF }, { scale: 1.15, boxShadow: HALO_ON, duration: 0.2, ease: "power2.out" }, 0)
.fromTo(bubble, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.12, ease: "none" }, 0.05)
.fromTo(bubble, { scale: 0.5, y: 10 }, { scale: 1, y: 0, duration: 0.4, ease: "back.out(2.4)" }, 0.05);
// 動きを減らす設定では、ほぼ一瞬で出し入れし、遅れも傾きも付けない
if (reduceMotion) grab.timeScale(20);
const follow = reduceMotion ? (x) => gsap.set(anchor, { x }) : gsap.quickTo(anchor, "x", { duration: 0.2, ease: "power3.out" });
const lean = gsap.quickTo(anchor, "rotation", { duration: 0.35, ease: "power3.out" });
let lastX = null;
let room = 0; // トラックの端から、部品の枠の端までの余白
let settle;
const draw = () => {
const width = track.clientWidth;
const min = Number(input.min);
const max = Number(input.max);
const x = Math.round(((input.valueAsNumber - min) / (max - min)) * width);
gsap.set(knob, { x });
fill.style.width = `${x}px`;
follow(x); // 吹き出しは少し遅れてついてくる
if (lastX !== null && !reduceMotion) {
lean(gsap.utils.clamp(-MAX_TILT, MAX_TILT, -(x - lastX) * LEAN)); // 動いた向きと逆へ傾ける
settle?.kill();
settle = gsap.delayedCall(0.1, () => lean(0)); // 止まったらまっすぐに戻す
}
lastX = x;
const text = `${input.valueAsNumber.toLocaleString("ja-JP")}万円`;
output.textContent = text;
input.setAttribute("aria-valuetext", text); // 読み上げにも単位つきで届ける
// 端では吹き出しを内側へずらし、枠の外へはみ出させない。矢印は逆へずらして、つまみを指したままにする
const half = bubble.offsetWidth / 2;
const shift = Math.max(0, half - room - x) - Math.max(0, x + half - width - room);
gsap.set(bubble, { x: shift });
bubble.style.setProperty("--shift", `${shift}px`);
};
// 幅が変わったら、同じ値の位置に置き直す(傾けない)
new ResizeObserver(() => {
budget.style.setProperty("--track-width", `${track.clientWidth}px`);
room = track.getBoundingClientRect().left - budget.getBoundingClientRect().left;
lastX = null;
draw();
}).observe(track);
const show = () => grab.play();
const hide = () => grab.reverse();
// 放したのが range の外でも閉じる
const release = () => {
hide();
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
};
input.addEventListener("input", draw);
input.addEventListener("pointerdown", () => {
show();
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
// キーボードでは、焦点が入ったときと動かしている間に値を見せ、焦点が外れたら閉じる
input.addEventListener("focus", () => input.matches(":focus-visible") && show());
input.addEventListener("keydown", show);
input.addEventListener("blur", hide);
});