F-007 Form simple

How to build a quantity stepper 数量ステッパーの作り方

Choose a quantity with buttons, direct input, or native arrow keys, with clear feedback at every boundary. ボタン・直接入力・上下キーで数量を選択し、上下限や入力エラーを確認できます。

ライブデモ Live Demo

1〜10の整数。直接入力・上下キーでも変更できます。Whole numbers from 1 to 10. Type or use the up and down arrow keys.

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

A native number input paired with clearly labeled decrement and increment buttons. It accepts whole numbers within a configured range.

標準の数値入力欄に、数量を減らす・増やすボタンを組み合わせたUIです。指定した範囲の整数を入力できます。

どこで使うかWhere to use

Use it for item counts, reservation guests, or equipment quantities in a form.

商品の個数、予約人数、備品の数量などをフォームで指定するときに使えます。

特徴Key features

Plus and minus controls, native arrow-key input, disabled controls at the bounds, live validation feedback, and a reset button.

増減ボタン、標準の上下キー操作、上下限でのボタン無効化、入力状態の通知、リセットボタンを備えます。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
min / max1 / 10Inclusive safe integer bounds. Update the HTML hint when changing them.
value / defaultValue1Initial value and reset target, within the bounds.
locale (React)enUse ja for Japanese labels and status messages.
--quantity-accent#16665dButton, top border, and focus outline color.

実装コード Implementation Code

<div class="quantity-stepper demo" data-quantity-stepper data-invalid="false">
  <p class="quantity-stepper__eyebrow" aria-hidden="true">QUANTITY / 01</p>
  <label class="quantity-stepper__label" for="quantity-input">Choose quantity</label>
  <p class="quantity-stepper__hint" id="quantity-hint">Whole numbers from 1 to 10. Type or use the up and down arrow keys.</p>
  <div class="quantity-stepper__controls">
    <button class="quantity-stepper__button" type="button" data-decrease disabled><span aria-hidden="true">−</span><span class="quantity-stepper__sr">Decrease quantity</span></button>
    <input class="quantity-stepper__input" id="quantity-input" name="quantity" type="number" min="1" max="10" step="1" value="1" required aria-describedby="quantity-hint quantity-status" aria-invalid="false" />
    <button class="quantity-stepper__button" type="button" data-increase><span aria-hidden="true">+</span><span class="quantity-stepper__sr">Increase quantity</span></button>
  </div>
  <p class="quantity-stepper__status" id="quantity-status" role="status" aria-atomic="true"><span data-status-en></span></p>
  <button class="quantity-stepper__reset" type="button" data-reset>Reset quantity</button>
</div>

<script>
document.querySelectorAll('[data-quantity-stepper]').forEach((root) => {
  const input = root.querySelector('input');
  const decrease = root.querySelector('[data-decrease]');
  const increase = root.querySelector('[data-increase]');
  const min = Number(input.min);
  const max = Number(input.max);
  if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min > max ||
      !Number.isSafeInteger(Number(input.defaultValue)) || Number(input.defaultValue) < min || Number(input.defaultValue) > max) {
    throw new RangeError('Use safe integer bounds and an initial value within them.');
  }

  function render() {
    const value = input.valueAsNumber;
    const invalid = !input.validity.valid || !Number.isSafeInteger(value);
    input.setAttribute('aria-invalid', String(invalid));
    root.dataset.invalid = String(invalid);
    decrease.disabled = invalid || value <= min;
    increase.disabled = invalid || value >= max;
    const en = invalid ? 'Enter a whole number from ' + min + ' to ' + max + '.'
      : 'Quantity: ' + value + (value === min ? ' — minimum reached.' : value === max ? ' — maximum reached.' : '.');
    const status = root.querySelector('[data-status-en]');
    if (status.textContent !== en) status.textContent = en;
  }

  function changeBy(amount) {
    if (!input.validity.valid || !Number.isSafeInteger(input.valueAsNumber)) return;
    input.value = String(Math.min(max, Math.max(min, input.valueAsNumber + amount)));
    input.dispatchEvent(new Event('input', { bubbles: true }));
  }
  decrease.addEventListener('click', () => changeBy(-1));
  increase.addEventListener('click', () => changeBy(1));
  input.addEventListener('input', render);
  root.querySelector('[data-reset]').addEventListener('click', () => {
    input.value = input.defaultValue;
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.focus();
  });
  input.form?.addEventListener('reset', (event) => {
    queueMicrotask(() => { if (!event.defaultPrevented) render(); });
  });
  render();
});
</script>
.quantity-stepper {
  --quantity-accent: #16665d;
  box-sizing: border-box;
  width: 100%;
  max-width: 440px;
  margin: 0 auto;
  padding: clamp(20px, 5vw, 32px);
  border: 1px solid #d7ddd3;
  border-top: 4px solid var(--quantity-accent);
  border-radius: 4px 4px 20px 20px;
  background: #faf9f3;
  color: #233e38;
  font-family: 'Trebuchet MS', 'Yu Gothic', sans-serif;
}
.quantity-stepper * { box-sizing: border-box; }
.quantity-stepper__eyebrow { margin: 0 0 18px; color: #566d64; font: 12px/1.5 monospace; letter-spacing: .14em; }
.quantity-stepper__label { display: block; margin-bottom: 8px; font-size: 24px; font-weight: 700; }
.quantity-stepper__hint { margin: 0 0 24px; color: #52695f; font-size: 14px; line-height: 1.7; }
.quantity-stepper__controls { display: grid; grid-template-columns: 56px minmax(0, 1fr) 56px; gap: 10px; }
.quantity-stepper__button { min-height: 56px; border: 1px solid var(--quantity-accent); border-radius: 10px; background: var(--quantity-accent); color: #fff; font-family: inherit; cursor: pointer; }
.quantity-stepper__button span[aria-hidden] { font-size: 28px; line-height: 1; }
.quantity-stepper__button:hover:not(:disabled) { background: #0c5149; }
.quantity-stepper__button:disabled { border-color: #d3dbd3; background: #e7ece5; color: #708277; cursor: not-allowed; }
.quantity-stepper__input { width: 100%; min-height: 56px; min-width: 0; padding: 6px; border: 1px solid #a1b4a9; border-radius: 10px; background: #fff; color: #233e38; text-align: center; font: 700 28px/1.3 'Trebuchet MS', sans-serif; font-variant-numeric: tabular-nums; }
.quantity-stepper__input[aria-invalid="true"] { border-color: #b42332; }
.quantity-stepper__input:focus-visible, .quantity-stepper button:focus-visible { outline: 3px solid var(--quantity-accent); outline-offset: 3px; }
.quantity-stepper__status { min-height: 3em; margin: 16px 0 12px; font-size: 13px; line-height: 1.5; }
.quantity-stepper[data-invalid="true"] .quantity-stepper__status { color: #a61b2b; }
.quantity-stepper__reset { min-height: 44px; padding: 8px 12px; border: 1px solid #a1b4a9; border-radius: 6px; background: transparent; color: #233e38; font: inherit; font-size: 13px; cursor: pointer; }
.quantity-stepper__reset:hover { background: #e7ece5; }
.quantity-stepper__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
import { useEffect, useId, useRef, useState } from 'react';
import './F-007.css';

export default function QuantityStepper({ min = 1, max = 10, defaultValue = 1, locale = 'en', name = 'quantity' }) {
  const id = useId();
  const inputRef = useRef(null);
  const [value, setValue] = useState(String(defaultValue));
  const ja = locale === 'ja';
  if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min > max ||
      !Number.isSafeInteger(defaultValue) || defaultValue < min || defaultValue > max) {
    throw new RangeError('Use safe integer bounds and an initial value within them.');
  }
  const number = value === '' ? NaN : Number(value);
  const invalid = !Number.isSafeInteger(number) || number < min || number > max;
  const status = invalid
    ? (ja ? min + '〜' + max + 'の整数を入力してください。' : 'Enter a whole number from ' + min + ' to ' + max + '.')
    : (ja ? '数量:' + number + (number === min ? '(最小値)' : number === max ? '(最大値)' : '')
      : 'Quantity: ' + number + (number === min ? ' — minimum reached.' : number === max ? ' — maximum reached.' : '.'));

  useEffect(() => {
    const form = inputRef.current.form;
    const reset = (event) => {
      queueMicrotask(() => { if (!event.defaultPrevented) setValue(String(defaultValue)); });
    };
    form?.addEventListener('reset', reset);
    return () => form?.removeEventListener('reset', reset);
  }, [defaultValue]);

  function changeBy(amount) {
    if (!invalid) setValue(String(Math.min(max, Math.max(min, number + amount))));
  }

  return (
    <div className="quantity-stepper" data-invalid={invalid}>
      <p className="quantity-stepper__eyebrow" aria-hidden="true">QUANTITY / 01</p>
      <label className="quantity-stepper__label" htmlFor={id}>{ja ? '数量を選ぶ' : 'Choose quantity'}</label>
      <p className="quantity-stepper__hint" id={id + '-hint'}>
        {ja ? min + '〜' + max + 'の整数。直接入力・上下キーでも変更できます。'
          : 'Whole numbers from ' + min + ' to ' + max + '. Type or use the up and down arrow keys.'}
      </p>
      <div className="quantity-stepper__controls">
        <button className="quantity-stepper__button" type="button" disabled={invalid || number <= min} onClick={() => changeBy(-1)}>
          <span aria-hidden="true">−</span><span className="quantity-stepper__sr">{ja ? '数量を減らす' : 'Decrease quantity'}</span>
        </button>
        <input ref={inputRef} className="quantity-stepper__input" id={id} name={name}
          type="number" min={min} max={max} step="1" required value={value}
          aria-describedby={id + '-hint ' + id + '-status'} aria-invalid={invalid}
          onChange={(event) => setValue(event.target.value)} />
        <button className="quantity-stepper__button" type="button" disabled={invalid || number >= max} onClick={() => changeBy(1)}>
          <span aria-hidden="true">+</span><span className="quantity-stepper__sr">{ja ? '数量を増やす' : 'Increase quantity'}</span>
        </button>
      </div>
      <p className="quantity-stepper__status" id={id + '-status'} role="status" aria-atomic="true">{status}</p>
      <button className="quantity-stepper__reset" type="button" onClick={() => {
        setValue(String(defaultValue));
        inputRef.current.focus();
      }}>{ja ? '初期値に戻す' : 'Reset quantity'}</button>
    </div>
  );
}
.quantity-stepper {
  --quantity-accent: #16665d;
  box-sizing: border-box;
  width: 100%;
  max-width: 440px;
  margin: 0 auto;
  padding: clamp(20px, 5vw, 32px);
  border: 1px solid #d7ddd3;
  border-top: 4px solid var(--quantity-accent);
  border-radius: 4px 4px 20px 20px;
  background: #faf9f3;
  color: #233e38;
  font-family: 'Trebuchet MS', 'Yu Gothic', sans-serif;
}
.quantity-stepper * { box-sizing: border-box; }
.quantity-stepper__eyebrow { margin: 0 0 18px; color: #566d64; font: 12px/1.5 monospace; letter-spacing: .14em; }
.quantity-stepper__label { display: block; margin-bottom: 8px; font-size: 24px; font-weight: 700; }
.quantity-stepper__hint { margin: 0 0 24px; color: #52695f; font-size: 14px; line-height: 1.7; }
.quantity-stepper__controls { display: grid; grid-template-columns: 56px minmax(0, 1fr) 56px; gap: 10px; }
.quantity-stepper__button { min-height: 56px; border: 1px solid var(--quantity-accent); border-radius: 10px; background: var(--quantity-accent); color: #fff; font-family: inherit; cursor: pointer; }
.quantity-stepper__button span[aria-hidden] { font-size: 28px; line-height: 1; }
.quantity-stepper__button:hover:not(:disabled) { background: #0c5149; }
.quantity-stepper__button:disabled { border-color: #d3dbd3; background: #e7ece5; color: #708277; cursor: not-allowed; }
.quantity-stepper__input { width: 100%; min-height: 56px; min-width: 0; padding: 6px; border: 1px solid #a1b4a9; border-radius: 10px; background: #fff; color: #233e38; text-align: center; font: 700 28px/1.3 'Trebuchet MS', sans-serif; font-variant-numeric: tabular-nums; }
.quantity-stepper__input[aria-invalid="true"] { border-color: #b42332; }
.quantity-stepper__input:focus-visible, .quantity-stepper button:focus-visible { outline: 3px solid var(--quantity-accent); outline-offset: 3px; }
.quantity-stepper__status { min-height: 3em; margin: 16px 0 12px; font-size: 13px; line-height: 1.5; }
.quantity-stepper[data-invalid="true"] .quantity-stepper__status { color: #a61b2b; }
.quantity-stepper__reset { min-height: 44px; padding: 8px 12px; border: 1px solid #a1b4a9; border-radius: 6px; background: transparent; color: #233e38; font: inherit; font-size: 13px; cursor: pointer; }
.quantity-stepper__reset:hover { background: #e7ece5; }
.quantity-stepper__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

The required number input uses min, max, and step=1 for native form validation. Button changes dispatch an input event. Empty, fractional, and out-of-range values show an error and disable both controls until corrected or reset; typing is never silently clamped.

required付きの数値入力にmin・max・step=1を指定し、標準フォーム検証を使います。ボタン操作時はinputイベントを発火します。空欄・小数・範囲外の値はエラーを表示し、修正またはリセットするまで両方の増減ボタンを無効にします。直接入力した値を勝手に丸めません。

カスタマイズ方法Customization

Set min, max, and value in HTML and update the hint text. React accepts min, max, defaultValue, locale, and name. Bounds and the initial value must be safe integers, with min <= defaultValue <= max. Use unique IDs for every copied HTML instance; React generates them with useId.

HTMLのmin・max・valueと説明文を変更します。Reactではmin・max・defaultValue・locale・nameを指定できます。上下限と初期値は安全な整数で、min <= defaultValue <= maxが必要です。HTML版を複数置く場合はIDを一意にし、React版はuseIdで自動生成します。

注意点Caveats

Both the component reset and an enclosing form reset restore the initial value. React requires React 18+; defaultValue initializes state and is also the reset target. Native arrow-key and number formatting behavior can vary by browser. Client validation does not replace server validation. Programmatic HTML value changes need an input event. This demo does not submit data.

専用のリセットボタンと親フォームのresetで初期値に戻ります。React版はReact 18以降が必要です。defaultValueは初期値とリセット先として使います。標準の上下キーや数値表記の挙動はブラウザによって異なります。サーバー側でも入力値を検証してください。HTMLの値をJavaScriptで変更した場合はinputイベントを発火します。このデモはデータを送信しません。

よくある質問 Frequently Asked Questions

What happens when the input is empty or outside the range? 空欄や範囲外の値はどうなりますか?

A visible error appears and both step buttons are disabled. Correct the value directly or use Reset quantity. The required input remains invalid for native form submission.

エラーを表示し、両方の増減ボタンを無効にします。直接入力で修正するか、初期値に戻してください。標準フォーム送信の検証でも無効な値として扱います。

Does it support keyboard and screen reader use? キーボードやスクリーンリーダーに対応していますか?

The native number input retains arrow-key behavior. Buttons have descriptive accessible names, focus outlines are visible, and a live region reports the quantity or validation message.

標準の数値入力による上下キー操作を利用できます。ボタンには操作名、フォーカスには枠線を設け、数量とエラーをライブリージョンで通知します。

Can I place multiple instances on one page? 同じページに複数置けますか?

Yes. HTML initialization scopes queries to each root; change each instance’s input and description IDs. React uses useId automatically. Choose distinct name values if the form needs separate fields.

可能です。HTMLの処理は各要素内に限定しているため、入力欄と説明文のIDを一意にしてください。React版はuseIdで自動生成します。別項目として送信する場合はnameも変更してください。

AIへの指示テンプレート AI Prompt Template

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.