I built a decimal-to-binary converter and then, for no good reason, decided that if you typed in the number 5, it should not just show you the answer. It should show you the call stack.
Here's the actual conversion logic. Nothing fancy:
const decimalToBinary = (input) => {
if (input === 0 || input === 1) {
return String(input);
} else {
return decimalToBinary(Math.floor(input / 2)) + (input % 2);
}
};
Classic recursion. Divide by two, keep the remainder, recurse on the quotient, stop at 0 or 1. decimalToBinary(5) becomes decimalToBinary(2) + "1", which becomes decimalToBinary(1) + "0" + "1", which bottoms out at "1". Concatenate it all back up and you get "101". Five in binary. Correct.
The problem is that "correct" doesn't mean "understood." I've watched people (including past me) stare at a recursive function like this and nod along with the base case, nod along with the recursive call, and still have no real mental model of what's happening on the call stack while it runs. The code reads top to bottom. The execution doesn't.
So I hardcoded a demo. If the input is exactly 5, instead of printing the result, the page runs a little animation:
const animationData = [
{
inputVal: 5,
addElDelay: 1000,
msg: 'decimalToBinary(5) returns "10" + 1 (5 % 2). Then it pops off the stack.',
showMsgDelay: 15000,
removeElDelay: 20000,
},
{
inputVal: 2,
addElDelay: 1500,
msg: 'decimalToBinary(2) returns "1" + 0 (2 % 2) and gives that value to the stack below. Then it pops off the stack.',
showMsgDelay: 10000,
removeElDelay: 15000,
},
{
inputVal: 1,
addElDelay: 2000,
msg: "decimalToBinary(1) returns '1' (base case) and gives that value to the stack below. Then it pops off the stack.",
showMsgDelay: 5000,
removeElDelay: 10000,
}
];
Each object is a stack frame. addElDelay is when the frame appears (staggered by 500ms, so 5 shows up first, then 2, then 1, mimicking the calls stacking on top of each other). showMsgDelay is when the explanation swaps in, and it's ordered in reverse: frame 1 gets its message first, at 5 seconds, because it's the base case and returns immediately. Frame 5 doesn't get its message until 15 seconds in, because it's waiting on everything underneath it to finish. removeElDelay is when the frame pops off, again bottom-up.
The whole thing is just three setTimeout calls per frame, driven by forEach:
const showAnimation = () => {
result.innerText = "Call Stack Animation";
animationData.forEach((obj) => {
setTimeout(() => {
animationContainer.innerHTML += `
<p id="${obj.inputVal}" class="animation-frame">
decimalToBinary(${obj.inputVal})
</p>
`;
}, obj.addElDelay);
setTimeout(() => {
document.getElementById(obj.inputVal).textContent = obj.msg;
}, obj.showMsgDelay);
setTimeout(() => {
document.getElementById(obj.inputVal).remove();
}, obj.removeElDelay);
});
};
Add the frame, wait, swap the text, wait, remove it. No animation library, no framework, just staggered timers and DOM writes. It works, and honestly, watching frame 5 sit there for fifteen seconds waiting for its children to resolve does more for building intuition about recursion than any explanation I could write.
I'm not going to pretend this is good engineering, though. It only works for the input 5. Type in 6 and you just get "110" printed like normal, no ceremony. The IDs on the frames are just the raw numbers, so if two frames in a longer chain happened to share a value, document.getElementById would grab whichever one came first and the animation would break silently. There's also nothing stopping you from mashing the convert button while an animation is mid-run and ending up with a pile of duplicate frames and orphaned timeouts, since nothing gets cleared. I noticed an empty leftover setTimeout at the 20 second mark in an earlier version that did nothing at all. I left it in for a while before realizing it was just dead code from an idea I'd abandoned.
If I were extending this for real, I'd generate the animation data from the actual recursion instead of hand-typing three objects for one specific input. Something like instrumenting decimalToBinary to push and pop from an array as it runs, then replaying that array as the animation timeline. That would work for any input, not just my one carefully staged example. But there's something to be said for the hacky version too. It took maybe twenty minutes to write, and it does exactly one job: making a stranger's mental model of "5 calls 2 calls 1, then it unwinds" click into place.
Sometimes the demo doesn't need to generalize. It just needs to demo.
Top comments (0)