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.

JavaScriptbeginner
9 min read

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

FeatureDescription
Increment / DecrementAdd or subtract from the counter
ResetReturn the counter to zero
Custom stepChange how much each click adds or subtracts
Color feedbackCounter turns green (positive), red (negative), or neutral (zero)
Min / Max limitsOptional boundaries to prevent overflow
History logTrack every change with timestamps
Keyboard shortcutsArrow keys and R to reset

Step 1: The HTML Structure

htmlhtml
<!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">&minus;</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

javascriptjavascript
// 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:

javascriptjavascript
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

javascriptjavascript
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:

javascriptjavascript
// 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

javascriptjavascript
// 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

ConceptHow It Is Used
getElementByIdSelecting each DOM element by ID
addEventListenerButton clicks and keyboard shortcuts
classListToggling color and animation classes
createElementBuilding history log entries
Keyboard eventsArrow keys and R shortcut
Click eventsIncrement, decrement, reset buttons
FunctionsOrganizing logic into reusable pieces

Extension Ideas

Add localStorage Persistence

javascriptjavascript
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 change

Add Sound Effects

javascriptjavascript
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

javascriptjavascript
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

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
RunePowered by Rune AI

Frequently Asked Questions

What DOM concepts does this counter app teach?

This project covers [element selection](/tutorials/programming-languages/javascript/selecting-dom-elements-in-javascript-full-guide) with getElementById, [event listeners](/tutorials/programming-languages/javascript/how-to-add-event-listeners-in-js-complete-guide) for click and keyboard events, [changing text content](/tutorials/programming-languages/javascript/how-to-change-text-content-using-javascript-dom) with textContent, [CSS class manipulation](/tutorials/programming-languages/javascript/adding-and-removing-css-classes-with-javascript) with classList, and [creating elements](/tutorials/programming-languages/javascript/creating-html-elements-with-javascript-dom-guide) for history entries.

How does the color feedback work?

The code checks whether count is positive, negative, or zero after every update, then adds the matching CSS class (`positive`, `negative`, `zero`) to the display element. Each class has a different color defined in CSS. The `classList.remove` call first clears all three classes before adding the correct one.

Why use a clamp function for limits?

The clamp function enforces minimum and maximum boundaries by checking the new value against user-defined limits before assigning it to count. If the value exceeds the max or goes below the min, clamp returns the boundary value instead. This prevents the counter from going out of range without blocking the [click event](/tutorials/programming-languages/javascript/handling-click-events-in-javascript-full-guide) itself.

How can I add persistence so the counter survives page refresh?

Use localStorage to save the count and history array as a JSON string after every state change. On page load, read from localStorage with `getItem`, parse the JSON, and restore the state before the first render. Wrap `JSON.parse` in a try/catch to handle corrupt data gracefully.

Can I use this pattern for a real application?

Yes. The pattern of keeping state in a variable, updating via action functions, and re-rendering the display is the foundation of every UI framework. React uses `useState`, Vue uses `ref`, and Svelte uses reactive assignments, but they all follow this same cycle: state changes trigger display updates. This counter teaches that cycle without any framework.

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.