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 resetsLogic
- Use a closure to encapsulate counter state —
countandstep - The closure returns methods —
increment,decrement,reset,setStep,getCount - Attach click listeners to the buttons
- Attach keyboard listener to the document
- After every change — update the display and apply a CSS animation
- Keep a history of the last 5 values to show a change log
Flow
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:
| Click | counter.increment() | count | Display |
|---|---|---|---|
| 1st | count += 1 | 1 | 1 (green) |
| 2nd | count += 1 | 2 | 2 (green) |
| 3rd | count += 1 | 3 | 3 (green) |
Trace through setting step to 5 then clicking +:
| Step | Code | Result |
|---|---|---|
User types 5 | counter.setStep(5) | step = 5 |
Click + | counter.increment() | count += 5 |
Current count was 3 | 3 + 5 | 8 |
| Display | countDisplay.textContent = 8 | shows 8 |
| History | "+5 → 8" | added to log |
Trace through the closure showing private state:
| Code | Result |
|---|---|
counter.count | undefined — private, not accessible |
counter.getCount() | 8 — only through the method |
counter.step | undefined — 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