Building a 3D algorithm visualizer in pure HTML
A single 2,000-line HTML file. Three.js, ~350 lines of CSS, vanilla JavaScript. No npm, no bundler, no framework. Hosted on GitHub Pages for $0. The result is faster, smaller, and arguably better than what most teams ship after sprinting through React + Vite for a week. Here's the case for choosing constraints.
The brief
I wanted the homepage of lx8labs.com to feature an interactive 3D algorithm visualizer covering everything from Euclid's GCD (300 BCE) to Tim Sort (2002). Each algorithm needed to show its 3D state — sorting bars, graph nodes, DP grids, trees — alongside a code panel that highlighted the executing line in lock-step. A live variable inspector at the bottom. Filtering by name, era, or creator. Keyboard shortcuts. Step-through mode. The kind of thing you'd usually call visualgo.net-grade.
The default modern answer would be: React + Vite + Tailwind + react-three-fiber + zustand for state + maybe TanStack Query if I felt like it. I picked none of that.
The constraint
One HTML file. CDN scripts only. Hosted on GitHub Pages with zero build step. The kind of thing you can email someone as an attachment and they can open it in 2034 and it still works.
"The website is a build artefact. The build artefact should be the website."
That's not original — it's a paraphrase of every htmx essay ever — but it kept me honest. Whenever I felt the urge to reach for a library, I'd ask: is this saving a line of code, or is it adding 200 KB of node_modules to ship 20 KB of feature?
What I ended up with
The file lives at /algorithms.html. Open it. Inspect the source. The whole thing — sidebar, 3D canvas, code panel, search, keyboard shortcuts, every sorting algorithm, every graph algorithm, every DP solution — is in one document. The only external resources are Three.js r128 and OrbitControls from cdnjs, plus Google Fonts.
The wins
1. Time-to-first-paint is unbeatable
There's no JavaScript bundle to parse before the page paints. The browser sees HTML, paints it, then asynchronously fetches Three.js. By the time Three.js is ready, the user has already been staring at the sidebar and reading "Bubble Sort · 1956 · Edward Friend" for a hundred milliseconds.
Compare to a Vite-built React app: the browser sees an empty <div id="root">, fetches a 200 KB bundle, parses it, runs React's first render. Then maybe paints something.
2. The "view-source" affordance
Right-click → View Source on /algorithms.html shows the entire app. Every CSS rule, every algorithm, every Three.js scene. If a CS student wants to learn how a 3D Quick Sort visualization works, they can — by reading it. No source maps, no minification, no JSX compiling to React.createElement noise.
This was the unexpected delight. The site teaches twice: once by what it shows, once by what it is.
3. Maintenance cost approaches zero
Six months from now, when I want to add a new algorithm, there's nothing to npm audit fix. No vulnerable dependency to patch. No "the framework deprecated this hook" migration. The file is the file.
4. Forking is trivial
Someone wants to take this and build their own visualizer? Save the HTML, open in their editor, change what they want. No "clone, install, configure" ceremony. The file is the project.
The patterns that made it possible
Data-driven sidebar
Every algorithm is an entry in one INFO object:
const INFO = {
bubble: {
name: 'Bubble Sort', cat: 'sort',
year: 1956, creator: 'Edward Friend',
time: 'O(n²)', space: 'O(1)', stable: 'Yes',
desc: 'Adjacent-pair swaps bubble the largest…',
history: "Knuth famously called it 'the wrong algorithm'…"
},
// 38 more
};
The sidebar is rendered from this object. Chronological view sorts by year. By-category view groups by cat. Search filters by name/creator/category in one .filter() call. Adding a new algorithm is two things: add an INFO entry + add a run function. The sidebar updates itself.
Synchronized code highlighting via async/await
The algorithm functions are async. They call hl(line) to highlight a pseudocode line, then await tick() to pause. tick() returns a promise that either resolves on a timer (continuous mode) or on a button click (step mode):
const tick = () => {
if (stepMode) return new Promise(r => stepResolver = r);
return sleep(Math.max(8, 900 / Math.pow(speed, 1.6)));
};
async function bubbleSort() {
for (let i = 0; i < N-1; i++) {
hl(0); setVars({ i, pass: i+1 });
for (let j = 0; j < N-1-i; j++) {
hl(1); cmp++; await tick();
if (arr[j] > arr[j+1]) {
hl(3); bswap(j, j+1); swp++;
}
}
}
}
The same code structure works for line-by-line debugging and continuous playback. No state machine, no animation engine — just JavaScript's existing async primitives.
Live variable inspector
Each algorithm calls setVars({ key: value }) at meaningful moments. The function diffs against the current state and updates the right-panel display. Cost: ~15 lines of code. Result: the user watches i, j, arr[mid], dist[v], memo[i] change as the algorithm runs.
Where this approach breaks down
I'm not anti-framework. I'm anti-defaulting to a framework. Reach for the bigger toolkit when:
- You have routing across 20+ pages and shared state (use SvelteKit, Astro, or Next).
- Multiple engineers are working on the same surface (a framework is a forcing function for conventions).
- The app has heavy form state, validation, accessibility on every input — React Hook Form is worth it.
- You're building a real-time multi-user product. The single-file path doesn't scale to WebSocket sync, optimistic updates, conflict resolution.
But for this — a personal site with a few interactive demos — every framework decision would have been overhead. The constraint was the design.
What I'd do differently
- Move per-category CSS into
@layerblocks for tidier source diffs. - Split the algorithm implementations into separate
.jsfiles behind<script type="module">. Still no bundler — just native ESM. Would cut the HTML to ~600 lines. - Add a TypeScript declarations file (
.d.ts) for the INFO shape, even though the JavaScript stays plain JS. Editor autocomplete for free.
The takeaway
Static sites are having a moment again, and the reason is: the browsers got really good. fetch, Promise, IntersectionObserver, CSS Grid, matchMedia, requestAnimationFrame, View Transitions, dialog elements, container queries — all native. Most of what a framework gives you in 2026, you can get by reading MDN for a week.
If you're starting a project that fits in one HTML file, start there. Drop into a framework when the constraints actually break. The site I built in three days would have taken three weeks if I'd reached for Next.js first.