Sorting visualizers are a genre. This one is different in one way that matters: the algorithm code has zero knowledge of the DOM. Bubble, Insertion, Merge, and Quick Sort are plain generator functions that yield at every comparison and swap. The animation driver calls
.next()on a timer and updates bars. Changing "algorithm" means swapping the generator. Nothing else changes.
🌐 Demo: https://sen.ltd/portfolio/sort-visualizer/
📦 GitHub: https://github.com/sen-ltd/sort-visualizer
Why generators?
The usual approach: mix the algorithm and the DOM in a single function, using async/await + sleep() to pause.
// the common pattern — tightly coupled
async function bubbleSort(bars) {
for (let i = 0; i < bars.length - 1; i++) {
for (let j = 0; j < bars.length - 1 - i; j++) {
bars[j].style.background = "yellow"; // DOM inside sort logic
bars[j + 1].style.background = "yellow";
await sleep(delay);
if (height(bars[j]) > height(bars[j + 1])) {
swap(bars[j], bars[j + 1]);
await sleep(delay);
}
bars[j].style.background = "";
bars[j + 1].style.background = "";
}
}
}
Problems:
- Can't test the sort logic without a DOM.
- Changing animation speed or stopping mid-sort requires ugly
cancelledflags. - Adding a new algorithm means copy-pasting the
sleep+ color update infrastructure.
The generator approach separates concerns cleanly:
// sorters.js — no DOM, no timers
export function* bubbleSort(arr, colors, stats) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - 1 - i; j++) {
stats.comparisons++;
colors[j] = colors[j + 1] = "comparing";
yield; // pause here
if (arr[j] > arr[j + 1]) {
stats.swaps++;
colors[j] = colors[j + 1] = "swapping";
yield; // pause here
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
colors[j] = colors[j + 1] = "default";
}
colors[n - 1 - i] = "sorted";
}
colors[0] = "sorted";
}
The animation driver in index.html:
function startAnimation() {
const gen = ALGO_MAP[currentAlgo](array, colors, stats);
function step() {
const { done } = gen.next(); // advance one yield
render(); // paint whatever state we're in
if (!done) animId = setTimeout(step, delay());
else finishAnimation();
}
animId = setTimeout(step, delay());
}
delay() reads the speed slider at call time — so the user can change speed mid-sort and it takes effect immediately on the next step.
Stopping is trivial: clearTimeout(animId). No cancelled flag, no promises to abort.
What each algorithm yields
Here's what the color transitions look like per algorithm:
Bubble Sort
Classic two-pointer walk. The n - 1 - i position is guaranteed sorted after pass i.
for each pass i:
for j from 0 to n-2-i:
mark [j, j+1] as comparing → yield
if out of order:
mark [j, j+1] as swapping → yield → swap
reset [j, j+1] to default
mark [n-1-i] as sorted
O(n²) comparisons. O(n²) swaps worst case. Already-sorted input: 0 swaps, but still O(n²) comparisons.
Insertion Sort
Pick each element, shift right to find its place.
export function* insertionSort(arr, colors, stats) {
const n = arr.length;
for (let i = 1; i < n; i++) {
const key = arr[i];
colors[i] = "comparing";
yield;
let j = i - 1;
while (j >= 0) {
stats.comparisons++;
colors[j] = "comparing";
yield;
if (arr[j] > key) {
stats.swaps++;
colors[j + 1] = "swapping";
arr[j + 1] = arr[j]; // shift right
yield;
colors[j + 1] = "default";
colors[j] = "default";
j--;
} else {
colors[j] = "sorted";
break;
}
}
arr[j + 1] = key;
for (let k = 0; k <= i; k++) colors[k] = "sorted"; // ← important
yield;
}
}
Color tracking gotcha: during the shift phase, multiple positions change from their original values. If you mark individual positions "sorted" during the shift (rather than after placing the key), you get positions that are colored "sorted" but don't yet hold their final value. The fix: re-mark the full sorted prefix [0..i] after each outer iteration.
O(n²) worst case, but O(n) on nearly-sorted input — good for "almost sorted" real-world data.
Merge Sort
Divide-and-conquer with a temp buffer.
function* _merge(arr, colors, stats, l, m, r) {
const left = arr.slice(l, m + 1); // temp copy
const right = arr.slice(m + 1, r + 1);
let i = 0, j = 0, k = l;
while (i < left.length && j < right.length) {
stats.comparisons++;
colors[k] = "comparing";
yield;
if (left[i] <= right[j]) { arr[k] = left[i++]; }
else { stats.swaps++; arr[k] = right[j++]; }
colors[k] = "swapping";
yield;
colors[k++] = "default";
}
// drain remainder ...
}
Delegating recursion through generators requires yield*:
function* _mergeHelper(arr, colors, stats, l, r) {
if (l >= r) return;
const m = (l + r) >> 1;
yield* _mergeHelper(arr, colors, stats, l, m); // delegate
yield* _mergeHelper(arr, colors, stats, m + 1, r); // delegate
yield* _merge(arr, colors, stats, l, m, r); // delegate
}
yield* propagates each yield up the call chain — the outer animation driver receives them all as if they came from one flat generator.
O(n log n) always. Extra O(n) space for the temp arrays.
Quick Sort
Lomuto partition scheme: pick the last element as pivot.
function* _partition(arr, colors, stats, low, high) {
const pivot = arr[high];
colors[high] = "pivot"; // orange color signals the pivot
let i = low - 1;
for (let j = low; j < high; j++) {
stats.comparisons++;
colors[j] = "comparing";
yield;
if (arr[j] <= pivot) {
i++;
if (i !== j) {
stats.swaps++;
colors[i] = colors[j] = "swapping";
[arr[i], arr[j]] = [arr[j], arr[i]];
yield;
colors[i] = "default";
}
}
colors[j] = "default";
}
// place pivot at i+1
// ...
return i + 1;
}
The pivot color (orange) makes it visually obvious which element is partitioning the array.
O(n log n) average, O(n²) worst case (already-sorted or all-equal input with Lomuto). Each iteration n - 1 - i elements are guaranteed sorted, so we mark colors[pivotPos] = "sorted" immediately after partition.
Testing without a DOM
Because generators are pure functions over plain arrays, the test suite is straightforward:
import { bubbleSort, insertionSort, mergeSort, quickSort, runSort } from "../sorters.js";
function run(algo, input) {
const arr = [...input];
const colors = new Array(arr.length).fill("default");
const stats = { comparisons: 0, swaps: 0 };
runSort(algo(arr, colors, stats));
return { arr, colors, stats };
}
test("bubbleSort sorts a random array", () => {
const { arr } = run(bubbleSort, [5, 3, 8, 1, 9, 2, 7, 4, 6]);
assert.deepEqual(arr, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test("all bars end as sorted", () => {
for (const algo of [bubbleSort, insertionSort, mergeSort, quickSort]) {
const { colors } = run(algo, [3, 1, 4, 1, 5, 9, 2, 6]);
assert.ok(colors.every(c => c === "sorted"));
}
});
runSort just drains the generator:
export function runSort(gen) {
while (!gen.next().done) {}
}
46 tests, zero mocking, no jsdom.
What you can see in the visualizer
At speed 1 (slowest), watch:
- Bubble: the sorted green grows from the right one element per pass.
- Insertion: the sorted green grows from the left as each key finds its place.
- Merge: wide orange bands appear at the merge boundaries; yellow comparisons happen within small segments.
- Quick: an orange pivot jumps around; after partition, its final position lights up green and never moves again.
At speed 5 (fastest), Bubble on 120 elements becomes a blur while Merge and Quick finish in a fraction of the time — a direct visual comparison of O(n²) vs O(n log n).
TL;DR
-
Generator +
yieldseparates algorithm logic from animation — no DOM in the sort code, no sort code in the animation driver. -
yield*delegates yields through recursive calls, keeping the driver simple. -
Speed changes take effect immediately because
delay()reads the slider on eachsetTimeoutcallback. -
Stop is
clearTimeout— no cancellation flags needed. - Color tracking in insertion sort requires re-marking the full sorted prefix after each placement, not individual positions during the shift.
- Tests run in Node without any browser or mock DOM.
Source: https://github.com/sen-ltd/sort-visualizer — MIT, ~200 lines of JS (sorters) + ~150 lines of UI, 46 unit tests, zero dependencies, no build step.
🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)