Build a JS Counter App: Beginner DOM Mini Project
Build a JavaScript counter app from scratch. Learn DOM manipulation, event handling, state management, and CSS transitions in this beginner-friendly mini project.
A counter app is the perfect first JavaScript project. It is small enough to finish in one sitting but teaches the core DOM skills you will use in every future project: selecting elements, handling click events, updating the display, and managing state. This tutorial walks through building a feature-rich counter with increment, decrement, reset, custom step, history tracking, and visual feedback.
What You Will Build
| Feature | Description |
|---|---|
| Increment / Decrement | Add or subtract from the counter |
| Reset | Return the counter to zero |
| Custom step | Change how much each click adds or subtracts |
| Color feedback | Counter turns green (positive), red (negative), or neutral (zero) |
| Min / Max limits | Optional boundaries to prevent overflow |
| History log | Track every change with timestamps |
| Keyboard shortcuts | Arrow keys and R to reset |
Step 1: The HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.counter-app { text-align: center; background: #16213e; padding: 40px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.3); min-width: 360px; }
.counter-app h1 { font-size: 20px; margin-bottom: 24px; color: #a8b2d1; }
.count-display { font-size: 72px; font-weight: 700; margin: 20px 0; transition: color 0.3s, transform 0.15s; }
.count-display.positive { color: #64ffda; }
.count-display.negative { color: #ff6b6b; }
.count-display.zero { color: #ccd6f6; }
.count-display.bump { transform: scale(1.15); }
.controls { display: flex; gap: 12px; justify-content: center; margin: 20px 0; }
.btn { padding: 12px 24px; font-size: 18px; border: none; border-radius: 8px; cursor: pointer; transition: background 0.2s, transform 0.1s; }
.btn:active { transform: scale(0.95); }
.btn-dec { background: #e74c3c; color: white; }
.btn-inc { background: #2ecc71; color: white; }
.btn-reset { background: #3498db; color: white; }
.step-control { margin: 16px 0; display: flex; align-items: center; justify-content: center; gap: 8px; }
.step-control label { font-size: 14px; color: #a8b2d1; }
.step-control input { width: 60px; padding: 6px; text-align: center; border: 1px solid #444; border-radius: 4px; background: #0a192f; color: #ccd6f6; font-size: 14px; }
.history { margin-top: 24px; max-height: 150px; overflow-y: auto; text-align: left; }
.history h3 { font-size: 14px; color: #a8b2d1; margin-bottom: 8px; }
.history-item { font-size: 12px; color: #8892b0; padding: 4px 0; border-bottom: 1px solid #1a1a2e; }
.limits { display: flex; gap: 12px; justify-content: center; margin: 12px 0; }
.limits label { font-size: 13px; color: #a8b2d1; }
.limits input { width: 60px; padding: 4px; text-align: center; border: 1px solid #444; border-radius: 4px; background: #0a192f; color: #ccd6f6; font-size: 13px; }
</style>
</head>
<body>
<div class="counter-app" id="app">
<h1>JavaScript Counter</h1>
<div class="count-display zero" id="count-display">0</div>
<div class="controls">
<button class="btn btn-dec" id="btn-dec">−</button>
<button class="btn btn-reset" id="btn-reset">Reset</button>
<button class="btn btn-inc" id="btn-inc">+</button>
</div>
<div class="step-control">
<label for="step-input">Step:</label>
<input type="number" id="step-input" value="1" min="1" max="100">
</div>
<div class="limits">
<div>
<label for="min-input">Min:</label>
<input type="number" id="min-input" placeholder="none">
</div>
<div>
<label for="max-input">Max:</label>
<input type="number" id="max-input" placeholder="none">
</div>
</div>
<div class="history" id="history">
<h3>History</h3>
</div>
</div>
<script src="counter.js"></script>
</body>
</html>Step 2: The Core Counter Logic
// counter.js - State
let count = 0;
const history = [];
// DOM references
const countDisplay = document.getElementById("count-display");
const btnInc = document.getElementById("btn-inc");
const btnDec = document.getElementById("btn-dec");
const btnReset = document.getElementById("btn-reset");
const stepInput = document.getElementById("step-input");
const minInput = document.getElementById("min-input");
const maxInput = document.getElementById("max-input");
const historyContainer = document.getElementById("history");
function getStep() {
const value = parseInt(stepInput.value);
return isNaN(value) || value < 1 ? 1 : value;
}
function getLimits() {
const min = minInput.value !== "" ? parseInt(minInput.value) : null;
const max = maxInput.value !== "" ? parseInt(maxInput.value) : null;
return { min, max };
}
function clamp(value) {
const { min, max } = getLimits();
if (min !== null && value < min) return min;
if (max !== null && value > max) return max;
return value;
}Step 3: Update the Display
The display function handles the number, color, and bump animation:
function updateDisplay() {
countDisplay.textContent = count;
// Color feedback
countDisplay.classList.remove("positive", "negative", "zero");
if (count > 0) {
countDisplay.classList.add("positive");
} else if (count < 0) {
countDisplay.classList.add("negative");
} else {
countDisplay.classList.add("zero");
}
// Bump animation
countDisplay.classList.add("bump");
setTimeout(() => countDisplay.classList.remove("bump"), 150);
}Step 4: Actions and History Tracking
function addToHistory(action, oldValue, newValue) {
const time = new Date().toLocaleTimeString();
history.unshift({ action, oldValue, newValue, time });
// Keep only last 20 entries
if (history.length > 20) history.pop();
renderHistory();
}
function renderHistory() {
// Keep the h3 heading, clear the rest
const heading = historyContainer.querySelector("h3");
historyContainer.innerHTML = "";
historyContainer.appendChild(heading);
history.forEach(entry => {
const div = document.createElement("div");
div.className = "history-item";
div.textContent = `${entry.time} | ${entry.action}: ${entry.oldValue} -> ${entry.newValue}`;
historyContainer.appendChild(div);
});
}
function increment() {
const oldValue = count;
count = clamp(count + getStep());
if (count !== oldValue) {
addToHistory("INC", oldValue, count);
updateDisplay();
}
}
function decrement() {
const oldValue = count;
count = clamp(count - getStep());
if (count !== oldValue) {
addToHistory("DEC", oldValue, count);
updateDisplay();
}
}
function reset() {
if (count === 0) return;
const oldValue = count;
count = 0;
addToHistory("RESET", oldValue, count);
updateDisplay();
}Step 5: Wire Up Events
Use event listeners on the buttons and keyboard events for shortcuts:
// Button clicks
btnInc.addEventListener("click", increment);
btnDec.addEventListener("click", decrement);
btnReset.addEventListener("click", reset);
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return; // Don't hijack input fields
switch (e.key) {
case "ArrowUp":
case "ArrowRight":
e.preventDefault();
increment();
break;
case "ArrowDown":
case "ArrowLeft":
e.preventDefault();
decrement();
break;
case "r":
case "R":
reset();
break;
}
});
// Validate step input
stepInput.addEventListener("input", () => {
const value = parseInt(stepInput.value);
if (value < 1) stepInput.value = 1;
if (value > 100) stepInput.value = 100;
});
// Initialize display
updateDisplay();The Complete counter.js File
// State
let count = 0;
const history = [];
// DOM references
const countDisplay = document.getElementById("count-display");
const btnInc = document.getElementById("btn-inc");
const btnDec = document.getElementById("btn-dec");
const btnReset = document.getElementById("btn-reset");
const stepInput = document.getElementById("step-input");
const minInput = document.getElementById("min-input");
const maxInput = document.getElementById("max-input");
const historyContainer = document.getElementById("history");
// Helpers
function getStep() {
const val = parseInt(stepInput.value);
return isNaN(val) || val < 1 ? 1 : val;
}
function getLimits() {
const min = minInput.value !== "" ? parseInt(minInput.value) : null;
const max = maxInput.value !== "" ? parseInt(maxInput.value) : null;
return { min, max };
}
function clamp(value) {
const { min, max } = getLimits();
if (min !== null && value < min) return min;
if (max !== null && value > max) return max;
return value;
}
// Display
function updateDisplay() {
countDisplay.textContent = count;
countDisplay.classList.remove("positive", "negative", "zero");
if (count > 0) countDisplay.classList.add("positive");
else if (count < 0) countDisplay.classList.add("negative");
else countDisplay.classList.add("zero");
countDisplay.classList.add("bump");
setTimeout(() => countDisplay.classList.remove("bump"), 150);
}
// History
function addToHistory(action, oldVal, newVal) {
const time = new Date().toLocaleTimeString();
history.unshift({ action, oldValue: oldVal, newValue: newVal, time });
if (history.length > 20) history.pop();
renderHistory();
}
function renderHistory() {
const heading = historyContainer.querySelector("h3");
historyContainer.innerHTML = "";
historyContainer.appendChild(heading);
history.forEach(entry => {
const div = document.createElement("div");
div.className = "history-item";
div.textContent = `${entry.time} | ${entry.action}: ${entry.oldValue} -> ${entry.newValue}`;
historyContainer.appendChild(div);
});
}
// Actions
function increment() {
const old = count;
count = clamp(count + getStep());
if (count !== old) { addToHistory("INC", old, count); updateDisplay(); }
}
function decrement() {
const old = count;
count = clamp(count - getStep());
if (count !== old) { addToHistory("DEC", old, count); updateDisplay(); }
}
function reset() {
if (count === 0) return;
const old = count;
count = 0;
addToHistory("RESET", old, 0);
updateDisplay();
}
// Events
btnInc.addEventListener("click", increment);
btnDec.addEventListener("click", decrement);
btnReset.addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.key === "ArrowUp" || e.key === "ArrowRight") { e.preventDefault(); increment(); }
if (e.key === "ArrowDown" || e.key === "ArrowLeft") { e.preventDefault(); decrement(); }
if (e.key === "r" || e.key === "R") reset();
});
stepInput.addEventListener("input", () => {
const v = parseInt(stepInput.value);
if (v < 1) stepInput.value = 1;
if (v > 100) stepInput.value = 100;
});
updateDisplay();Concepts Practiced
| Concept | How It Is Used |
|---|---|
| getElementById | Selecting each DOM element by ID |
| addEventListener | Button clicks and keyboard shortcuts |
| classList | Toggling color and animation classes |
| createElement | Building history log entries |
| Keyboard events | Arrow keys and R shortcut |
| Click events | Increment, decrement, reset buttons |
| Functions | Organizing logic into reusable pieces |
Extension Ideas
Add localStorage Persistence
const STORAGE_KEY = "counter-app-state";
function saveState() {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ count, history }));
}
function loadState() {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
try {
const state = JSON.parse(data);
count = state.count || 0;
history.push(...(state.history || []));
} catch { /* ignore corrupt data */ }
}
}
// Call loadState() at startup, saveState() after every changeAdd Sound Effects
function playClick() {
const audio = new Audio("click.mp3");
audio.volume = 0.3;
audio.play().catch(() => {}); // Ignore autoplay errors
}
// Add playClick() inside increment() and decrement()Add Multiple Counters
function createCounter(containerId) {
let count = 0;
const container = document.getElementById(containerId);
const display = container.querySelector(".count-display");
return {
increment() { count++; display.textContent = count; },
decrement() { count--; display.textContent = count; },
reset() { count = 0; display.textContent = count; },
getCount() { return count; }
};
}
const counter1 = createCounter("counter-1");
const counter2 = createCounter("counter-2");Rune AI
Key Insights
- State drives the UI: Keep count in a variable, update it through functions, then call updateDisplay to sync the DOM
- Separation of concerns: Each function does one thing (increment, decrement, reset, render), making code easy to debug and extend
- Visual feedback matters: Color changes and bump animations give users immediate confirmation that their click registered
- Keyboard shortcuts improve UX: Arrow keys and R for reset make the app faster to use without a mouse
- Guard against edge cases: The clamp function and input validation prevent impossible states before they happen
Frequently Asked Questions
What DOM concepts does this counter app teach?
How does the color feedback work?
Why use a clamp function for limits?
How can I add persistence so the counter survives page refresh?
Can I use this pattern for a real application?
Conclusion
This counter app covers the core DOM cycle that every JavaScript project uses: read state, handle events, update state, re-render the display. You practiced selecting elements, attaching event listeners for clicks and keyboard shortcuts, toggling CSS classes for visual feedback, and creating elements dynamically for the history log. These exact patterns scale to every interactive feature you will ever build.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.