合計自動計算フォーム(Auto Calculate Form)— 明細行の小計・合計・消費税を自動計算

フォーム入力 初級

このコンポーネントについて

見積書・請求書・発注画面などでは、明細行ごとに「単価 × 数量」の小計を出し、それらを合算した税抜合計・消費税・税込合計を表示するのが定番です。このページでは、単価・数量を入力すると同時に各行の小計と下部の合計が自動で更新される「合計自動計算フォーム(auto calculate form)」の実装例を紹介します。明細行は「行を追加」「行を削除」で自由に増減でき、金額はすべて円単位の整数で計算します。消費税は税抜合計に対して10%・切り捨てで算出し、浮動小数点による1円のズレを防ぎます。HTML・CSS・バニラJavaScriptのみで動くため、フレームワークなしでそのまま業務アプリに組み込めます。

  • 行小計のリアルタイム計算 — 単価・数量の input イベントで、その行の小計(単価×数量)を即時に更新する
  • 税抜・消費税・税込の3段表示 — 全行小計の合計(税抜)、その10%を切り捨てた消費税、両者の和(税込)を常に最新化する
  • 明細行の追加・削除 — 「行を追加」で行を動的に生成し、各行の削除ボタンで除去する。残り1行のときは削除ボタンを無効化して最低1行を保つ
  • 整数計算で誤差を防ぐ — 金額を円単位の整数で加算し、消費税は Math.floor(税抜合計 × 0.1) で端数を切り捨てる。1円ズレを起こさない
  • 不正入力に強い — 空欄・負数・小数が入力されても0以上の整数に丸め、計算を破綻させない
  • 3桁区切り表示 — 金額は toLocaleString() でカンマ区切りにして読みやすくする

実装のポイント・注意点

金額計算で最も注意すべきは浮動小数点の誤差です。JavaScriptでは 0.1 + 0.20.3 にならないため、金額を小数のまま足し込むと合計が1円ずれることがあります。この事例では単価・数量を parseInt で整数に丸め、行小計(単価×数量)を整数のまま加算し、消費税だけを Math.floor(税抜合計 * 0.1) で切り捨てています。こうすると端数が結果に残らず、ズレを防げます。

行の追加・削除で入力欄が動的に増減するため、各入力に個別のイベントを付けるのではなく、tbodyinputclick を1つずつ登録する「イベント委譲」を使うのがポイントです。これにより後から追加した行も自動的に計算対象になります。行は createElement で生成し innerHTML に文字列を結合しないため、品名にどんな文字を入れてもXSSの心配がありません。

なお、このデモのようなフロント側の即時計算は入力中のUXフィードバック用と割り切り、実際の業務システムでは保存・請求確定の直前にサーバー側で同じ計算を再実行して、その結果を正としてください。フロントとサーバーで計算ロジックを二重に持つと、仕様変更時の修正漏れで金額がズレる事故につながります。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

品名 単価(円) 数量 小計(円) 削除
税抜合計 0 円
消費税(10%) 0 円
税込合計 0 円

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>合計自動計算フォーム サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="fc1-wrap">
  <table class="fc1-table">
    <thead>
      <tr>
        <th class="fc1-th-name">品名</th>
        <th class="fc1-th-num">単価(円)</th>
        <th class="fc1-th-num">数量</th>
        <th class="fc1-th-num">小計(円)</th>
        <th class="fc1-th-del"><span class="fc1-visually-hidden">削除</span></th>
      </tr>
    </thead>
    <!-- 明細行は script.js が生成する(初期3行) -->
    <tbody id="fc1-tbody"></tbody>
  </table>

  <div class="fc1-actions">
    <button type="button" class="fc1-add-btn" id="fc1-add">+ 行を追加</button>
  </div>

  <div class="fc1-totals">
    <div class="fc1-total-line">
      <span class="fc1-total-label">税抜合計</span>
      <span class="fc1-total-value" id="fc1-subtotal-sum">0 円</span>
    </div>
    <div class="fc1-total-line">
      <span class="fc1-total-label">消費税(10%)</span>
      <span class="fc1-total-value" id="fc1-tax">0 円</span>
    </div>
    <div class="fc1-total-line fc1-total-grand">
      <span class="fc1-total-label">税込合計</span>
      <span class="fc1-total-value" id="fc1-grand">0 円</span>
    </div>
  </div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* === 合計自動計算フォーム(明細行対応) ===
   色は :root の変数を書き換えるだけで調整できます */
:root {
  --color-border:  #D0D7E0; /* 罫線・入力枠の色 */
  --color-head-bg: #F4F6F9; /* テーブル見出しの背景 */
  --color-text:    #3A4A5A; /* 本文テキスト */
  --color-accent:  #2B7FE8; /* 「行を追加」ボタンの色 */
  --color-danger:  #E5484D; /* 削除ボタンの色 */
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 24px;
  max-width: 600px;
  margin: 0 auto;
  color: var(--color-text);
}

.fc1-wrap { width: 100%; }

.fc1-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}

.fc1-table th,
.fc1-table td {
  border: 1px solid var(--color-border);
  padding: 8px 10px;
  text-align: left;
}

/* 数値列は右寄せ、削除列は中央寄せ */
.fc1-th-num { text-align: right; }
.fc1-th-del { width: 44px; text-align: center; }

.fc1-table thead th {
  background: var(--color-head-bg);
  font-size: 13px;
  color: #5A6A7A;
  white-space: nowrap;
}

.fc1-name {
  width: 100%;
  min-width: 80px;
  font-size: 16px; /* 16px 未満だと iOS で自動ズームするため */
  padding: 6px 8px;
  border: 1px solid var(--color-border);
  border-radius: 4px;
}

.fc1-price,
.fc1-qty {
  width: 100%;
  max-width: 96px;
  font-size: 16px;
  padding: 6px 8px;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  text-align: right;
}

/* 計算結果セル。数字の桁を揃えて表示する */
.fc1-subtotal {
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

.fc1-del-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  font-size: 16px;
  line-height: 1;
  color: var(--color-danger);
  background: #fff;
  border: 1.5px solid #F0C0C2;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
.fc1-del-btn:hover:not(:disabled) {
  background: #FDECEC;
  border-color: var(--color-danger);
}
/* 残り1行のときは削除ボタンを無効化する */
.fc1-del-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

.fc1-actions { margin-top: 10px; }

.fc1-add-btn {
  padding: 8px 16px;
  font-size: 13px;
  color: var(--color-accent);
  background: #fff;
  border: 1.5px dashed #A9C7F0;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
.fc1-add-btn:hover {
  background: #F0F6FF;
  border-color: var(--color-accent);
}

/* 合計エリアは右寄せの2カラム */
.fc1-totals {
  margin-top: 16px;
  margin-left: auto;
  max-width: 280px;
}
.fc1-total-line {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  padding: 6px 0;
  font-size: 14px;
}
.fc1-total-value { font-variant-numeric: tabular-nums; }

/* 税込合計だけ強調する */
.fc1-total-grand {
  margin-top: 4px;
  padding-top: 10px;
  border-top: 2px solid var(--color-border);
  font-size: 18px;
  font-weight: 700;
  color: #1A2A3A;
}

/* スクリーンリーダー用の視覚的非表示 */
.fc1-visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* スマホ対応 */
@media (max-width: 480px) {
  body { padding: 16px; }
  .fc1-table th,
  .fc1-table td { padding: 6px; }
  .fc1-price,
  .fc1-qty { max-width: 72px; }
}
document.addEventListener('DOMContentLoaded', function () {
  'use strict';

  var TAX_RATE = 0.1;   // 消費税率(10%固定)
  var INITIAL_ROWS = 3; // 初期の明細行数

  var tbody = document.getElementById('fc1-tbody');
  var addBtn = document.getElementById('fc1-add');
  var subtotalSumEl = document.getElementById('fc1-subtotal-sum');
  var taxEl = document.getElementById('fc1-tax');
  var grandEl = document.getElementById('fc1-grand');

  // 入力値を0以上の整数に丸める(空欄・負数・小数を吸収)
  function toInt(value) {
    var n = parseInt(value, 10);
    if (isNaN(n) || n < 0) { return 0; }
    return n;
  }

  // 明細行を1行生成する(createElement で組み立て innerHTML は使わない)
  function createRow() {
    var tr = document.createElement('tr');
    tr.className = 'fc1-row';

    // 品名(計算には使わない任意入力)
    var tdName = document.createElement('td');
    var name = document.createElement('input');
    name.type = 'text';
    name.className = 'fc1-name';
    name.placeholder = '品名';
    tdName.appendChild(name);

    // 単価
    var tdPrice = document.createElement('td');
    var price = document.createElement('input');
    price.type = 'number';
    price.className = 'fc1-price';
    price.min = '0';
    price.step = '1';
    price.inputMode = 'numeric';
    price.placeholder = '0';
    tdPrice.appendChild(price);

    // 数量
    var tdQty = document.createElement('td');
    var qty = document.createElement('input');
    qty.type = 'number';
    qty.className = 'fc1-qty';
    qty.min = '0';
    qty.step = '1';
    qty.inputMode = 'numeric';
    qty.placeholder = '0';
    tdQty.appendChild(qty);

    // 小計(計算結果の表示セル)
    var tdSub = document.createElement('td');
    tdSub.className = 'fc1-subtotal';
    tdSub.textContent = '0';

    // 削除ボタン
    var tdDel = document.createElement('td');
    var del = document.createElement('button');
    del.type = 'button';
    del.className = 'fc1-del-btn';
    del.setAttribute('aria-label', 'この行を削除');
    del.textContent = '×';
    tdDel.appendChild(del);

    tr.appendChild(tdName);
    tr.appendChild(tdPrice);
    tr.appendChild(tdQty);
    tr.appendChild(tdSub);
    tr.appendChild(tdDel);
    return tr;
  }

  // 全行の小計・税抜合計・消費税・税込合計を再計算する
  function recalc() {
    var rows = tbody.querySelectorAll('.fc1-row');
    var subtotalSum = 0;

    rows.forEach(function (row) {
      var price = toInt(row.querySelector('.fc1-price').value);
      var qty = toInt(row.querySelector('.fc1-qty').value);
      var sub = price * qty; // 整数×整数なので誤差は出ない
      row.querySelector('.fc1-subtotal').textContent = sub.toLocaleString();
      subtotalSum += sub;
    });

    // 税額は税抜合計に対して10%・切り捨て。floor で端数を吸収し1円ズレを防ぐ
    var tax = Math.floor(subtotalSum * TAX_RATE);
    var grand = subtotalSum + tax;

    subtotalSumEl.textContent = subtotalSum.toLocaleString() + ' 円';
    taxEl.textContent = tax.toLocaleString() + ' 円';
    grandEl.textContent = grand.toLocaleString() + ' 円';
  }

  // 行が1行だけのときは削除ボタンを無効化して最低1行を保つ
  function updateDelButtons() {
    var delBtns = tbody.querySelectorAll('.fc1-del-btn');
    var disabled = delBtns.length <= 1;
    delBtns.forEach(function (btn) { btn.disabled = disabled; });
  }

  // 単価・数量の入力を監視(イベント委譲で動的追加行にも対応)
  tbody.addEventListener('input', function (e) {
    if (e.target.classList.contains('fc1-price') || e.target.classList.contains('fc1-qty')) {
      recalc();
    }
  });

  // 行削除もイベント委譲で処理(残り1行のときは削除しない)
  tbody.addEventListener('click', function (e) {
    if (!e.target.classList.contains('fc1-del-btn')) { return; }
    if (tbody.querySelectorAll('.fc1-row').length <= 1) { return; }
    e.target.closest('.fc1-row').remove();
    updateDelButtons();
    recalc();
  });

  // 「行を追加」ボタン
  addBtn.addEventListener('click', function () {
    tbody.appendChild(createRow());
    updateDelButtons();
  });

  // 初期3行を生成して初期表示を整える
  for (var i = 0; i < INITIAL_ROWS; i++) {
    tbody.appendChild(createRow());
  }
  updateDelButtons();
  recalc();
});

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。列の追加や税率変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# 明細行の合計自動計算フォーム 作成依頼

## 概要
明細行ごとに「単価 × 数量」の小計を計算し、全行の税抜合計・消費税・税込合計を入力と同時に自動計算するフォームを実装してください。見積・請求・発注画面で使う定番パターンです。

## 要件
- 明細テーブルの列は「品名/単価(円)/数量/小計(円)/削除」。初期は空欄の明細行を3行表示する
- 単価・数量を入力したら、その行の小計(単価×数量)と、下部の税抜合計・消費税・税込合計をリアルタイムに更新する
- 消費税は税抜合計に対して10%・切り捨て(総額単位)。税込合計 = 税抜合計 + 消費税
- 「行を追加」ボタンで明細行を1行増やせる。追加した行も計算対象にする
- 各行に削除ボタンを置き、押すとその行を削除して再計算する。ただし残り1行のときは削除できないようにする(最低1行を保証)
- 金額はすべてカンマ区切り(3桁区切り)で表示する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(スマホ幅でも崩れないこと)

## 動作詳細
- 金額の計算はすべて円単位の整数で行ってください。単価・数量は parseInt で0以上の整数に丸め、空欄・負数・小数が入っても計算が破綻しないようにします。
- 消費税は Math.floor(税抜合計 * 0.1) で切り捨てます。浮動小数点の直接加算による1円のズレを避けるため、各行の小計を整数で加算してから税額を計算してください。
- 単価・数量の入力監視と行削除は tbody へのイベント委譲で実装し、動的に追加した行も追加のバインドなしで動くようにしてください。
- 行の動的生成は createElement + appendChild で行い、innerHTML に文字列を結合しないでください。金額の表示は textContent を使ってください。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。