カウンターを1つ置いて数を増やすだけなら、jQueryでもバニラJavaScriptでも10行で終わります。手が止まるのはカウンターを何個でも増やせるようにしたときです。あとから作った要素にクリックが効かない、全角で入力された数字が合計に入らない、並べ替えを付けたらボタンが押せなくなる。増やした瞬間に、この3つが一度に出てきます。
この記事では、HTML・CSS・jQueryで名称と単価をつけたカウンターを何個でも並べ、「単価 × 数量」の合計をその場で集計するアプリを作ります。棚卸しや在庫チェック、点呼のように「種類ごとに数えて、最後に足したい」場面をそのまま形にしたものです。
完成品は カウンターアプリのデモ で触れます。先に動かしてから読むと、どのコードがどの挙動なのか対応がつきます。JavaScriptだけで小さなアプリを組み立てる流れは JavaScriptで作るミニアプリ4選 に、同じ「増やして消せるリスト」をReactで組んだ場合は Reactで作るTODOアプリ にまとめてあります。
作るもの:名称と単価をつけて、複数のカウンターを合計する
完成するアプリの機能は次のとおりです。
- 「カウンターを追加」でカードが1枚増える。枚数の上限はない
- カードごとに名称(例: りんご)と単価を入力できる
- + / − / リセット で数量を動かし、カードごと削除もできる
- 画面上部にすべてのカードの「単価 × 数量」の合計が出る
- カードは左端のハンドルをつかんで並べ替えられる
名称は覚え書きなので合計の計算には使いません。合計に効くのは単価と数量だけです。単価を空欄のままにすれば、ただの回数カウンターとして使えます。
| 使うもの | この記事での役割 |
|---|---|
| HTML | 土台だけを書く。カード自体は1枚もHTMLに書かず、JavaScriptで作る |
| CSS | カード型のレイアウト。数字は等幅で揃える |
| jQuery 3.7.1 | 要素の生成、イベント、合計の計算。$.fn.on() と each() が主役 |
| jQuery UI 1.13.3 | sortable() によるドラッグ並べ替え。これだけのために読み込む |
HTMLの骨組み:カードは1枚も書かない
最初に決めるのは「HTMLにカードを何枚書くか」です。答えは0枚です。カードは実行時にJavaScriptで作るので、HTMLには入れ物と、追加ボタンと、合計の表示場所だけを用意します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>カウンターアプリ</title>
<link rel="stylesheet" href="./css/style.css" />
</head>
<body>
<header class="header">
<h1>カウンターアプリ</h1>
</header>
<div class="container">
<div class="toolbar">
<button id="add-counter" type="button">カウンターを追加</button>
<div id="total-container">
<span class="total-label">合計</span>
<span id="total-sum">0</span>
</div>
</div>
<!-- カードはここへ差し込まれる -->
<div id="counter-list"></div>
</div>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://code.jquery.com/ui/1.13.3/jquery-ui.min.js"></script>
<script src="./js/script.js"></script>
</body>
</html>#counter-list という空の入れ物を1つ用意したことが、あとで効いてきます。カードを <body> 直下に積んでいくと、並べ替えの対象範囲にヘッダーやフッターまで含まれてしまい、意図しない要素が動きます。並べ替えたいものだけを、専用の親要素に入れておくのが要点です。
CSS:カードの形と、数字の揃え方
見た目は好みで構いませんが、2か所だけJavaScript側と関係します。カード本体の .button と、数字を表示する要素です。
/* カード1枚。JavaScript側はこの .button を数えて合計を出す */
.button {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px 16px;
padding: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
}
/* 数量と単価は桁がずれると読みにくいので等幅にする */
.counter,
.counter-name {
font-variant-numeric: tabular-nums;
}
.counter {
min-width: 3ch;
font-size: 1.5rem;
font-weight: 700;
text-align: right;
}
/* 掴む場所。ここを限定しないとボタンが押せなくなる */
.drag-handle {
cursor: grab;
user-select: none;
}font-variant-numeric: tabular-nums は数字の字幅を揃える指定です。カウンターのように数字が1桁ずつ変わる表示では、これが無いと 9 から 10 になった瞬間に周りのボタンごと横へずれます。
jQuery:カード1枚のふるまいを、そのカードの中に閉じ込める
カードを増やす処理の本体はこれだけです。文字列でHTMLを組み立てて #counter-list の末尾に足し、そのカード専用のイベントをその場で登録する、という流れです。
$("#add-counter").on("click", function () {
var $newCounter = $(
'<div class="button">' +
'<span class="drag-handle" aria-hidden="true">⠯</span>' +
'<input type="text" class="counter-label" placeholder="名称(例:りんご)" aria-label="名称">' +
'<div class="counter-field">' +
'<label class="counter-field-label">単価</label>' +
'<input type="text" class="counter-name" inputmode="numeric" placeholder="0" aria-label="単価">' +
"</div>" +
'<p class="counter-title">数量<span class="counter">0</span></p>' +
'<div class="counter-actions">' +
'<button type="button" class="increment">+</button>' +
'<button type="button" class="decrement">-</button>' +
'<button type="button" class="reset">リセット</button>' +
'<button type="button" class="remove">削除</button>' +
"</div>" +
"</div>"
);
$newCounter.appendTo($list).hide().fadeIn();
addCounterEvents($newCounter);
});単価の入力欄が type="number" ではなく type="text" なのには理由があります。number にすると全角で入力された数字はブラウザによって弾かれ、入力された文字を自分で受け取って半角へ直す処理が書けなくなります。テキストとして受け取り、キーボードだけ数字向けにする inputmode="numeric" を添えるのが、実際に日本語環境で使うときには扱いやすくなります。
そして、カード1枚分のふるまいを担当するのが addCounterEvents() です。数量を保持する変数を関数の中に置いているのがポイントです。
function addCounterEvents($counter) {
// この1枚のカードだけが持つ数量。カードごとに別々の値になる
var counter = 0;
var $counterSpan = $counter.find(".counter");
var $counterName = $counter.find(".counter-name");
function render() {
$counterSpan.text(counter);
updateTotalSum();
}
$counter.find(".increment").on("click", function () {
counter++;
render();
});
$counter.find(".decrement").on("click", function () {
counter--;
render();
});
$counter.find(".reset").on("click", function () {
counter = 0;
render();
});
$counter.find(".remove").on("click", function () {
$counter.fadeOut("fast", function () {
$(this).remove();
updateTotalSum();
});
});
}var counter を関数の外に出して1つの変数を使い回すと、どのカードの + を押しても同じ数字が動きます。関数の中で宣言することで、カード1枚ごとに独立した変数ができます。この「関数の中の変数が、その中で作った関数から見え続ける」仕組みがクロージャで、jQueryで動的にUIを増やすときは必ずここに行き当たります。
合計をどこから計算するか
合計の出し方には2つのやり方があります。画面(DOM)に書かれている値を読んで足すか、JavaScript側で配列を持ち、そこから足すかです。この記事では前者を採ります。
// 合計 = すべてのカードの「単価 × 数量」の総和
function updateTotalSum() {
var totalSum = 0;
$list.find(".button").each(function () {
var unitPrice = parseInt($(this).find(".counter-name").val(), 10) || 0;
var count = parseInt($(this).find(".counter").text(), 10) || 0;
totalSum += unitPrice * count;
});
$("#total-sum").text(totalSum.toLocaleString("ja-JP"));
}parseInt() の後ろの || 0 が効いています。単価が空欄のとき parseInt("", 10) は NaN を返し、NaN を1回でも足すと合計全体が NaN になります。空欄を0とみなすこの1行で、「単価は入れずに回数だけ数える」使い方も同時に成立します。
この設計の利点は、状態の置き場所が1つで済むことです。カードを削除したら合計から自動的に消えますし、並べ替えても each() の結果は変わりません。一方で限界もはっきりしています。画面に出ていない情報は合計に入れられませんし、保存機能や絞り込みを足す段になると、DOMから読み直す処理が増えて破綻します。そこまで育てるつもりなら、最初から配列を正とする設計にしてください。その形は Reactで作るTODOアプリ で扱っています。
増やした瞬間に出てくる、4つのつまずき
あとから追加したボタンでクリックが効かない
$(".increment").on("click", ...) をページ読み込み時に1度だけ書くと、その時点で存在していた要素にしかイベントが付きません。あとから appendTo() で足したカードのボタンは無反応になります。この記事では、カードを作った直後に addCounterEvents() を呼んで個別に登録することで回避しています。
もう1つの解き方がイベント委譲です。動かない子要素ではなく、ずっと存在する親要素に登録し、実際にどこが押されたかを第2引数で絞り込みます。
// 親(#counter-list)は最初から存在するので、あとから増えた .increment にも効く
$("#counter-list").on("click", ".increment", function () {
var $card = $(this).closest(".button");
var $span = $card.find(".counter");
$span.text(parseInt($span.text(), 10) + 1);
updateTotalSum();
});委譲方式は登録が1回で済み、カードが何百枚になってもハンドラは増えません。代わりに、数量をクロージャで持てなくなるため画面の文字列から現在値を読み直すことになります。どちらが良いという話ではなく、カードごとに状態を持ちたいなら個別登録、枚数が読めないなら委譲、という選び分けになります。
全角で数字を入れると合計に入らない
日本語入力がオンのまま単価を打つと 120 のような全角数字になります。parseInt("120", 10) は NaN なので、見た目には数字が入っているのに合計が動きません。入力のたびに半角へ直してしまうのが確実です。
// 全角数字を半角へ変換してから合計に反映する
$counterName.on("input", function () {
var converted = $counterName.val().replace(/[0-9]/g, function (s) {
return String.fromCharCode(s.charCodeAt(0) - 0xfee0);
});
$counterName.val(converted);
updateTotalSum();
});0xfee0 は全角数字と半角数字の文字コードの差です。全角の 0 から 9 はコード上で連続しているので、この差を引くだけで半角に揃います。
並べ替えを付けたらボタンが押せなくなった
jQuery UIの sortable() を素で当てると、カード全体がドラッグの対象になり、ボタンを押したつもりの操作がドラッグとして解釈されます。掴める場所を handle で限定すると解決します。
$("#counter-list").sortable({
items: ".button",
handle: ".drag-handle", // ここを掴んだときだけドラッグが始まる
placeholder: "counter-placeholder",
forcePlaceholderSize: true,
cursor: "grabbing",
tolerance: "pointer",
});placeholder は、ドラッグ中に空いた場所へ差し込まれる要素のクラス名です。forcePlaceholderSize と併せて指定しないと高さ0の要素が入り、掴んだ瞬間に下のカードが上へ詰まって視線が飛びます。なお sortable() はDOMの並びを入れ替えるだけなので、各カードに登録済みのイベントはそのまま生きています。並べ替えでボタンが効かなくなることはありません。
単価を打っている途中で、誤ってボタンを押してしまう
単価の入力欄とカウントのボタンは隣り合っています。数字を打ちながらタブやEnterを触ると、意図せずカウントが動くことがあります。入力中はボタン側を止めてしまうのが手堅い対処です。
$counterName.on("focus", function () {
$counter.find(".increment, .decrement, .reset, .remove").prop("disabled", true);
});
$counterName.on("blur", function () {
$counter.find(".increment, .decrement, .reset, .remove").prop("disabled", false);
updateTotalSum();
});jQueryを使うか、使わないか
この規模のアプリなら、jQueryなしでも書けます。実際、いま同じことをバニラJavaScriptで書いた場合との差は次の程度です。
| やりたいこと | jQuery | バニラJavaScript |
|---|---|---|
| 要素をまとめて処理する | $(".button").each(...) | document.querySelectorAll(".button").forEach(...) |
| イベント委譲 | $(parent).on("click", ".btn", fn) | parent.addEventListener("click", e => e.target.closest(".btn") && fn(e)) |
| フェードイン | .fadeIn() | CSSの transition かWeb Animations API |
| ドラッグ並べ替え | jQuery UIの sortable() | Pointer Eventsで自前実装、またはSortableJSなどのライブラリ |
差が残るのは最後の並べ替えだけです。jQuery UIの sortable() を使いたいかどうかが、jQueryを入れる実質的な理由になります。逆に言えば、並べ替えが要らないならjQueryを外して問題ありません。
バージョンの現況も押さえておきます。jQuery本体は 4.0.0(2026年1月)が最新ですが、この記事のコードは 3.7.1 を前提にしています。理由は jQuery UI 側の対応範囲です。jQuery UI 1.13.3 が要求するjQueryは >=1.8.0 <4.0.0 で、jQuery 4 は対象外です。jQuery 4 を使いたい場合は、jQuery UI も 1.14 以降(要求範囲が >=1.12.0 <5.0.0 に広がっています)へ上げてください。
jQuery UI 自体の位置づけも、公式が明言しています。jQuery UIの公式ブログは1.14.2のリリース(2026年1月28日)にあたって「jQuery UIはメンテナンス状態にあり、新しいjQueryリリースとの互換性の維持とセキュリティ問題の修正は行うが、significantな新機能の予定はない」と述べています。新規案件の土台に据えるものではないが、既存資産と小さな道具には使える、という読み方が実態に合います。
まとめと次の一手
カウンターを「複数に増やす」と決めた時点で、押さえる場所は次の3つに集約されます。
- 動的に作った要素へのイベント。生成直後に個別登録するか、親に委譲するかを選ぶ
- 状態の置き場所。DOMを正にするなら合計は毎回走査、配列を正にするなら描画側を書き直す
- 入力値の正規化。全角数字と空欄を通すと、合計は静かに壊れる
書き上げたら、次の3つを実際に操作して確かめてください。カードを3枚以上足してそれぞれ別の数字が動くか、単価を全角で入れて合計が反応するか、並べ替えたあとにボタンがまだ効くかの3つです。この3つを通れば、増やしたときに出る問題はひととおり潰せています。
次の一手としては、入力内容を localStorage に保存してリロードでも消えないようにする拡張が、同じ構造のまま入ります。ただしDOMを正としている今の設計では、保存のたびに画面を走査することになります。保存を前提にするなら状態の持ち方から見直すことになるので、その実装は TypeScriptで作るチェックリストアプリ と Reactで作るTODOアプリ が参考になります。
完成品は カウンターアプリのデモ でそのまま触れます。JavaScriptの学習の進め方全体は JavaScript学習ガイド にまとめてあります。
よくある質問(FAQ)
Q. あとから追加したボタンでクリックが効かないのはなぜですか?
イベントを登録した時点で、その要素がまだ存在していなかったからです。jQueryの on() は「今あるもの」に対して登録するため、あとから appendTo() などで足した要素には効きません。解決策は2つあり、要素を作った直後にその要素へ個別に登録するか、ずっと存在する親要素に登録して $(親).on(“click”, “.子のクラス”, 処理) の形で絞り込む(イベント委譲)かです。カードごとに独立した状態を持たせたいなら個別登録、枚数が読めないなら委譲が向いています。
Q. 全角で数字を入れると合計に反映されません
parseInt() が全角数字を解釈できず NaN を返しているためです。NaN を1回でも足すと合計全体が NaN になります。入力のたびに正規表現 /[0-9]/g で全角数字を拾い、文字コードから 0xfee0 を引いて半角へ直してください。あわせて parseInt() の結果に || 0 を付けておくと、空欄のときも0として扱われ、単価を入れずに回数だけ数える使い方もできるようになります。
Q. 並べ替えを付けたらボタンが押せなくなりました
jQuery UIの sortable() がカード全体をドラッグ対象にしているためです。オプションに handle を渡し、掴める場所を専用のハンドル要素だけに限定してください。なお sortable() はDOMの並びを入れ替えるだけなので、各カードに登録済みのイベントは並べ替えたあともそのまま動きます。ボタンが効かなくなるのは掴む範囲の問題であって、イベントが外れたわけではありません。
Q. 合計をDOMから読んで計算するのは良くない設計ですか?
この規模なら妥当です。状態の置き場所が画面1か所に決まるため、カードを削除すれば合計から自動的に消え、並べ替えても結果が変わりません。ただし限界もあり、画面に出していない情報は合計に含められませんし、保存や絞り込みを足す段階になるとDOMを読み直す処理が増えて破綻します。機能を育てる予定があるなら、最初からJavaScript側の配列を正として、そこから画面を描く設計にしてください。
Q. jQueryは今から学んでも意味がありますか?
この記事のような小さな道具と、既存サイトの保守という2つの用途では現役です。ただし新規の大きめの案件でjQueryを選ぶ理由は薄くなっており、要素の取得やイベント委譲は標準のJavaScriptでほぼ同じ手数で書けます。実務では「jQueryが読み込まれている既存ページに機能を足す」場面が最も多いので、そこで詰まらない程度に読み書きできれば十分です。
Q. リロードすると入力内容が消えてしまいます
この記事の実装は状態をメモリとDOMにしか置いていないため、再読み込みで初期化されます。残したい場合は localStorage への保存を足すことになりますが、DOMを正としている構造のままだと保存のたびに画面全体を走査する形になります。保存を前提にするなら、タスクの配列を持ってそこから描画する設計へ変えるほうが素直です。
