DocsHub
JavascriptAdvanced

Infinite Counter

Build an infinite counter with increment, decrement, reset, and step controls — demonstrating closures and state management.

Infinite Counter

Problem

Build an infinite counter that goes in both directions — positive and negative — with no upper or lower limit. The user can set a custom step value, use keyboard shortcuts, and the counter animates when it changes.

Page loads
→ Counter shows 0, step is 1

User clicks +
→ Counter shows 1

User clicks + again
→ Counter shows 2

User sets step to 5 and clicks +
→ Counter shows 7

User clicks −
→ Counter shows 2

User clicks Reset
→ Counter shows 0, step resets to 1

User presses ArrowUp key
→ Counter increments by current step

User presses ArrowDown key
→ Counter decrements by current step

User presses R key
→ Counter resets

Logic

  1. Use a closure to encapsulate counter state — count and step
  2. The closure returns methods — increment, decrement, reset, setStep, getCount
  3. Attach click listeners to the buttons
  4. Attach keyboard listener to the document
  5. After every change — update the display and apply a CSS animation
  6. Keep a history of the last 5 values to show a change log

Flow

click + click - click Reset change step ArrowUp key ArrowDown key R key User action Which action? counter.increment counter.decrement counter.reset counter.setStep Update count Update display Add animation class Push to history Render history log

HTML Structure

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Infinite Counter</title>
    <style>
      body {
        font-family: sans-serif;
        max-width: 400px;
        margin: 60px auto;
        padding: 0 20px;
        text-align: center;
      }

      h1 {
        margin-bottom: 4px;
      }

      .subtitle {
        color: #888;
        font-size: 0.85rem;
        margin-bottom: 40px;
      }

      /* the big count display */
      #count-display {
        font-size: 5rem;
        font-weight: 800;
        color: #111;
        line-height: 1;
        margin-bottom: 8px;
        transition: color 0.2s;
        /* animation applied via class */
      }

      /* positive count — black */
      #count-display.positive {
        color: #1a7a3f;
      }

      /* negative count — red */
      #count-display.negative {
        color: #c0392b;
      }

      /* zero — default dark */
      #count-display.zero {
        color: #111;
      }

      /* animation — bounce up for increment, down for decrement */
      @keyframes bump-up {
        0% { transform: scale(1); }
        40% { transform: scale(1.15) translateY(-6px); }
        100% { transform: scale(1) translateY(0); }
      }

      @keyframes bump-down {
        0% { transform: scale(1); }
        40% { transform: scale(1.15) translateY(6px); }
        100% { transform: scale(1) translateY(0); }
      }

      #count-display.anim-up {
        animation: bump-up 0.2s ease;
      }

      #count-display.anim-down {
        animation: bump-down 0.2s ease;
      }

      /* step indicator */
      #step-label {
        font-size: 0.85rem;
        color: #aaa;
        margin-bottom: 28px;
      }

      /* main control buttons */
      .controls {
        display: flex;
        gap: 12px;
        justify-content: center;
        margin-bottom: 20px;
      }

      .btn {
        width: 56px;
        height: 56px;
        font-size: 1.6rem;
        border: none;
        border-radius: 50%;
        cursor: pointer;
        font-weight: 600;
        transition: transform 0.1s, opacity 0.1s;
      }

      .btn:active {
        transform: scale(0.93);
      }

      .btn-decrement {
        background: #fff0f0;
        color: #c0392b;
      }

      .btn-increment {
        background: #e6f9ee;
        color: #1a7a3f;
      }

      .btn-reset {
        width: auto;
        padding: 0 18px;
        border-radius: 28px;
        font-size: 0.9rem;
        background: #f0f0f0;
        color: #555;
      }

      /* step control row */
      .step-control {
        display: flex;
        align-items: center;
        justify-content: center;
        gap: 10px;
        margin-bottom: 28px;
        font-size: 0.9rem;
        color: #555;
      }

      .step-control input {
        width: 64px;
        padding: 7px 10px;
        font-size: 1rem;
        border: 2px solid #ddd;
        border-radius: 8px;
        text-align: center;
        outline: none;
      }

      .step-control input:focus {
        border-color: #555;
      }

      /* keyboard shortcuts hint */
      .shortcuts {
        font-size: 0.78rem;
        color: #ccc;
        margin-bottom: 28px;
      }

      /* history log */
      .history-section {
        text-align: left;
        border-top: 1px solid #f0f0f0;
        padding-top: 20px;
      }

      .history-title {
        font-size: 0.8rem;
        color: #aaa;
        font-weight: 600;
        margin-bottom: 10px;
        text-transform: uppercase;
        letter-spacing: 0.5px;
      }

      #history-list {
        list-style: none;
        padding: 0;
        margin: 0;
      }

      #history-list li {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 5px 0;
        font-size: 0.88rem;
        color: #666;
        border-bottom: 1px solid #f8f8f8;
      }

      #history-list li .change {
        font-weight: 600;
      }

      #history-list li .change.up {
        color: #1a7a3f;
      }

      #history-list li .change.down {
        color: #c0392b;
      }

      #history-list li .change.reset {
        color: #888;
      }

      #history-list li .time {
        font-size: 0.75rem;
        color: #ccc;
      }
    </style>
  </head>
  <body>
    <h1>Infinite Counter</h1>
    <p class="subtitle">No limits. Goes forever in both directions.</p>

    <!-- count display -->
    <div id="count-display" class="zero">0</div>

    <!-- step label -->
    <div id="step-label">Step: 1</div>

    <!-- buttons -->
    <div class="controls">
      <button class="btn btn-decrement" id="btn-decrement">−</button>
      <button class="btn btn-reset" id="btn-reset">Reset</button>
      <button class="btn btn-increment" id="btn-increment">+</button>
    </div>

    <!-- step input -->
    <div class="step-control">
      <label for="step-input">Step size:</label>
      <input type="number" id="step-input" value="1" min="1" max="1000" />
    </div>

    <!-- keyboard shortcuts -->
    <div class="shortcuts">
      ↑ Increment · ↓ Decrement · R Reset
    </div>

    <!-- history log -->
    <div class="history-section">
      <div class="history-title">Recent Changes</div>
      <ul id="history-list"></ul>
    </div>

    <script src="script.js"></script>
  </body>
</html>

Solution

// Step 1 — counter factory using a closure
// returns an object with methods to interact with the counter state
// count and step are private — only accessible through the returned methods
function createCounter() {
  let count = 0;  // private — lives in the closure
  let step = 1;   // private — lives in the closure

  return {
    increment() {
      count += step;
      return count;
    },

    decrement() {
      count -= step;
      return count;
    },

    reset() {
      count = 0;
      step = 1;
      return count;
    },

    setStep(newStep) {
      // step must be a positive number
      if (newStep > 0 && !isNaN(newStep)) {
        step = newStep;
      }
    },

    getCount() {
      return count;
    },

    getStep() {
      return step;
    }
  };
}

// Step 2 — create the counter instance
const counter = createCounter();

// Step 3 — select DOM elements
const countDisplay = document.querySelector("#count-display");
const stepLabel = document.querySelector("#step-label");
const stepInput = document.querySelector("#step-input");
const historyList = document.querySelector("#history-list");

// Step 4 — history array — keeps the last 5 actions
const history = [];

// Step 5 — update the display after every change
function updateDisplay(action) {
  const count = counter.getCount();

  // update the number
  countDisplay.textContent = count;

  // update color class based on value
  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");

  // Step 6 — apply animation
  // remove animation class first — allows re-triggering on same count
  countDisplay.classList.remove("anim-up", "anim-down");

  // force reflow — without this removing and adding the same class
  // does not re-trigger the animation
  void countDisplay.offsetWidth;

  if (action === "increment") countDisplay.classList.add("anim-up");
  if (action === "decrement") countDisplay.classList.add("anim-down");

  // update step label
  stepLabel.textContent = `Step: ${counter.getStep()}`;
  stepInput.value = counter.getStep();

  // Step 7 — add to history
  addToHistory(action, count);
}

// Step 8 — add an entry to the history log
function addToHistory(action, count) {
  const time = new Date().toLocaleTimeString("en-US", {
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
  });

  // build the change description
  let changeText = "";
  let changeClass = "";

  if (action === "increment") {
    changeText = `+${counter.getStep()} → ${count}`;
    changeClass = "up";
  } else if (action === "decrement") {
    changeText = `−${counter.getStep()} → ${count}`;
    changeClass = "down";
  } else if (action === "reset") {
    changeText = `Reset → 0`;
    changeClass = "reset";
  }

  // add to beginning of history array
  history.unshift({ changeText, changeClass, time });

  // only keep last 5 entries
  if (history.length > 5) history.pop();

  // render history list
  historyList.innerHTML = history
    .map(
      (entry) => `
        <li>
          <span class="change ${entry.changeClass}">${entry.changeText}</span>
          <span class="time">${entry.time}</span>
        </li>
      `
    )
    .join("");
}

// Step 9 — button click listeners
document.querySelector("#btn-increment").addEventListener("click", () => {
  counter.increment();
  updateDisplay("increment");
});

document.querySelector("#btn-decrement").addEventListener("click", () => {
  counter.decrement();
  updateDisplay("decrement");
});

document.querySelector("#btn-reset").addEventListener("click", () => {
  counter.reset();
  updateDisplay("reset");
});

// Step 10 — step input change
stepInput.addEventListener("input", () => {
  const newStep = parseInt(stepInput.value);
  counter.setStep(newStep);
  stepLabel.textContent = `Step: ${counter.getStep()}`;
});

// Step 11 — keyboard shortcuts
document.addEventListener("keydown", (e) => {
  // ignore keyboard shortcuts when typing in the step input
  if (e.target === stepInput) return;

  if (e.key === "ArrowUp") {
    e.preventDefault(); // prevent page scroll
    counter.increment();
    updateDisplay("increment");
  }

  if (e.key === "ArrowDown") {
    e.preventDefault();
    counter.decrement();
    updateDisplay("decrement");
  }

  if (e.key === "r" || e.key === "R") {
    counter.reset();
    updateDisplay("reset");
  }
});

Code Execution

Trace through clicking + three times with step 1:

Clickcounter.increment()countDisplay
1stcount += 111 (green)
2ndcount += 122 (green)
3rdcount += 133 (green)

Trace through setting step to 5 then clicking +:

StepCodeResult
User types 5counter.setStep(5)step = 5
Click +counter.increment()count += 5
Current count was 33 + 58
DisplaycountDisplay.textContent = 8shows 8
History"+5 → 8"added to log

Trace through the closure showing private state:

CodeResult
counter.countundefined — private, not accessible
counter.getCount()8 — only through the method
counter.stepundefined — private
counter.getStep()5 — only through the method

Output

Click +           → 1  (green, bounce up animation)
Click +           → 2
Click + 5 times   → 7
Change step to 5  → Step: 5
Click +           → 12
Click −           → 7
Click Reset       → 0  (step resets to 1 too)
Press ArrowUp     → 1
Press R           → 0

On this page