本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

スライダーのつまみを引くと値の吹き出しがついてくる

デモ

操作:ドラッグ。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
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 で変え、単位や桁区切りは吹き出しの文字を組み立てる所で変えます。予算や価格の絞り込み、音量、数量の入力に向いています。

コード

HTML
<!-- 本物の 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>

CSS
.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);
}

JavaScript
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);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。