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

数量の + と - のボタンで、数字が上下に回って変わる

デモ

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

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

+ を押すと数字が上へ回って次の数に替わり、- を押すと下へ回って前の数に替わります。数字は、上下の縁を透かした小さな窓の中にあります。押すたびに新しい数の要素を足し、古い数を窓の外へ、新しい数を反対側から中央へ、窓の高さぶん送ります。回り終えたら古い要素を外すので、何度押しても要素は増えません。

送る時間は 0.4 秒で、ease は back.out(1.7) です。止まる直前に少し行き過ぎて戻るので、目盛りを回したような手応えが出ます。回っている途中で続けて押されたときは、前の回転を最後まで進めてから次を回すので、表示の数と実際の数がずれません。

在庫の上限と、1 の下限では、ボタンを aria-disabled にします。disabled にするとクリックそのものが届かず、押しても何も起きない理由が伝わりません。aria-disabled なら焦点も残り、押されたときに数を変えずに欄を 6px の振れ幅で 4 往復震わせ、上限なら「在庫は残り5点です」の札を明るませて知らせます。

震えは、始まりと終わりでちょうど 0 になる減衰する正弦波を、ease としてかけています。札は CSS 変数を 0 から 1 へ補間して clip-path で左から開き、文は開くときに入れるので、読み上げにも届きます。動きを減らす設定では、回さずに数を入れ替え、震わせずに札の色だけで知らせます。

上限と下限は HTML の data 属性で、初めの数は隠した input の値で決めます。数が変わるとフォームに change が届くので、小計や送料はそこで書き直します。窓の高さと透かす幅を変えると、回る数字の見え方が変わります。EC のカート、予約の人数、チケットの枚数、注文フォームの数量の入力に向いています。

コード

HTML
<!-- 数は output に出し、フォームへは隠した input で送る。限界のボタンは disabled にせず aria-disabled にして、押されたら震わせる -->
<div class="qty" data-min="1" data-max="5">
  <span class="qty__label" id="qty-label">数量</span>
  <div class="qty__field">
    <button class="qty__button" type="button" data-step="-1" aria-label="1つ減らす" aria-disabled="true">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14" /></svg>
    </button>
    <output class="qty__window" aria-labelledby="qty-label">
      <span class="qty__num">1</span>
    </output>
    <button class="qty__button" type="button" data-step="1" aria-label="1つ増やす">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M12 5v14" /></svg>
    </button>
  </div>
  <p class="qty__note" role="status"></p>
  <input type="hidden" name="quantity" value="1">
</div>

CSS
/* 暗い面の上の数量。文字と札の色は、この面の上で読める明るさにしてある */
.qty {
  --surface: #262626;             /* 面 */
  --ink: #eeece6;                 /* 文字と印 */
  --mute-small: #a8a6a0;          /* ラベル(16px 未満の補足) */
  --rule: rgba(238, 236, 230, 0.14); /* 欄の枠 */
  --off: #5c5c5c;                 /* 押せないボタンの印 */
  --warn: #c07a3a;                /* 上限に達したときの枠 */
  --band: #513e2c;                /* 在庫の札の地(面に --warn を 28% 混ぜた色) */
  --band-flash: #64482e;          /* 上限で押したときに札が明るむ色 */
  --shake: 6px;                   /* 震えの振れ幅 */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px 16px;
  padding: 24px;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
}

.qty__label {
  flex-basis: 100%;
  color: var(--mute-small);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.06em;
}

.qty__field {
  display: flex;
  align-items: center;
  height: 48px;
  padding: 0 5px;
  box-sizing: border-box;
  border: 1px solid var(--rule);
  border-radius: 999px;
}

.qty.is-max .qty__field { border-color: var(--warn); }

.qty__button {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: transparent;
  color: var(--ink);
  cursor: pointer;
}

.qty__button:hover { background-color: rgba(238, 236, 230, 0.1); }
.qty__button:active { background-color: rgba(238, 236, 230, 0.22); }
.qty__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.qty__button[aria-disabled="true"] { background-color: transparent; color: var(--off); cursor: not-allowed; }
/* 画面の上で線が 2px になる太さ(24 の枠を 16px で描く) */
.qty__button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; }

/* 数字の窓。上下の縁を透かして、回る数字が溶けるように出入りさせる */
.qty__window {
  position: relative;
  display: block;
  width: 48px;
  height: 36px;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(180deg, transparent, #000 28%, #000 72%, transparent);
  mask-image: linear-gradient(180deg, transparent, #000 28%, #000 72%, transparent);
}

.qty__num {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 20px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* 在庫の札。--reveal(0〜1)で左から開く */
.qty__note {
  --reveal: 0;
  display: flex;
  align-items: center;
  height: 28px;
  margin: 0;
  padding: 0 12px;
  border-radius: 999px;
  background-color: var(--band);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.06em;
  white-space: nowrap;
  clip-path: inset(0 calc((1 - var(--reveal)) * 100%) 0 0 round 999px);
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ROLL = 0.4; // 数字が回る時間(秒)
const SHAKES = 4; // 震えの往復の回数
// 減衰する正弦波。p が 0 と 1 のときちょうど 0 なので、震え終わりで位置が飛ばない
const shake = (p) => (1 - p) * Math.sin(p * Math.PI * 2 * SHAKES);

document.querySelectorAll(".qty").forEach((qty) => {
  const min = Number(qty.dataset.min);
  const max = Number(qty.dataset.max);
  const field = qty.querySelector(".qty__field");
  const win = qty.querySelector(".qty__window");
  const note = qty.querySelector(".qty__note");
  const hidden = qty.querySelector('input[type="hidden"]');
  const [minus, plus] = qty.querySelectorAll(".qty__button");
  // 色と振れ幅は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const styles = getComputedStyle(qty);
  const band = styles.getPropertyValue("--band").trim();
  const flashColor = styles.getPropertyValue("--band-flash").trim();
  const amplitude = parseFloat(styles.getPropertyValue("--shake"));
  let value = Number(hidden.value);
  let roll;

  // 在庫の札を開く・閉じる。文は開くときに入れ、閉じきってから消す(読み上げには開いたときに届く)
  function toggleNote(open) {
    if (open === qty.classList.contains("is-max")) return;
    qty.classList.toggle("is-max", open); // 枠の色は CSS が受け持つ
    if (open) note.textContent = `在庫は残り${max}点です`;
    gsap.to(note, {
      "--reveal": open ? 1 : 0,
      duration: reduceMotion ? 0 : open ? 0.35 : 0.25,
      ease: open ? "power3.out" : "power2.in",
      overwrite: true,
      onComplete: () => !open && (note.textContent = ""),
    });
  }

  function update() {
    hidden.value = value;
    minus.setAttribute("aria-disabled", String(value <= min));
    plus.setAttribute("aria-disabled", String(value >= max));
    toggleNote(value >= max);
  }

  function step(dir) {
    const next = value + dir;
    if (next < min || next > max) {
      // 限界では数を変えず、欄を震わせる。上限なら札も明るませる
      if (!reduceMotion) gsap.fromTo(field, { x: 0 }, { x: amplitude, duration: 0.45, ease: shake, snap: "x", overwrite: true });
      if (next > max) gsap.fromTo(note, { backgroundColor: flashColor }, { backgroundColor: band, duration: 0.5, ease: "power1.out" });
      return;
    }
    roll?.progress(1); // 回っている途中で押されたら、前の回転を終わらせてから
    const old = win.querySelector(".qty__num");
    const fresh = old.cloneNode();
    fresh.textContent = next;
    win.append(fresh);
    // 増やすと上へ、減らすと下へ回る。窓の高さぶん送る
    const h = win.offsetHeight;
    roll = gsap
      .timeline({ onComplete: () => old.remove() })
      .fromTo(fresh, { y: dir * h }, { y: 0, duration: ROLL, ease: "back.out(1.7)" }, 0)
      .to(old, { y: -dir * h, duration: ROLL, ease: "back.out(1.7)" }, 0);
    if (reduceMotion) roll.progress(1);
    value = next;
    update();
    hidden.dispatchEvent(new Event("change", { bubbles: true })); // 小計などは、フォームの change で書き直す
  }

  minus.addEventListener("click", () => step(-1));
  plus.addEventListener("click", () => step(1));
  update();
});

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

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