Branching Git Graph
A git history draws itself one commit at a time. Branches curve off the main line, run alongside it and curve back in when they’re merged, each in its own colour, and every branch’s name tag rides along on its newest commit. Write the history the way you’d type it: commit, branch, checkout, merge.
How to use it
- Paste the snippet just before
</head>. - Add
data-branching-git-graphto an empty element. It fills the width; its height follows the number of branches. - Write the history in
data-history, one step after another separated by commas:commit,branch name(create a branch and switch to it),checkout nameandmerge name(merge it into the branch you’re on).
Options
Add any of these attributes to the same element.
data-history | The steps to draw. Default: a feature branch and a quick fix, both merged back into main. |
data-main | Name of the branch you start on. Default main. |
data-colors | Branch colours, in the order the branches are made. Default #7c3aed,#ec4899,#0ea5e9,#f59e0b,#10b981. |
data-step | Time between commits. Default 420 ms. |
data-loop | Fade out and draw the history again. |
Each branch gets its own row below main, and rows are reused once a branch has been merged. Merge commits are drawn as rings. A branch merged back and never used again loses its tag, as if it had been deleted.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Branching Git Graph: a git history draws itself commit by commit.
// Branches curve off the main line, run alongside it and merge back in,
// with each branch's name tag riding along on its newest commit.
// Usage:
// <div data-branching-git-graph
// data-history="commit, commit, branch feature, commit, checkout main, commit, merge feature"></div>
// data-history is a list of git-style steps, separated by commas or new
// lines: "commit", "branch NAME" (create a branch and switch to it),
// "checkout NAME" (switch to a branch) and "merge NAME" (merge it into the
// current branch). The first branch is called main.
// Optional: data-colors (branch colours in the order they are created,
// default #7c3aed,#ec4899,#0ea5e9,#f59e0b,#10b981), data-main (name of the
// first branch, default main), data-step (ms between commits, default
// 420), data-loop (fade out and draw the history again).
var DEFAULTS = {
history: 'commit, commit, branch feature, commit, commit, checkout main, commit, branch fix, commit, checkout main, merge fix, checkout feature, commit, merge main, commit, checkout main, merge feature, commit',
colors: '#7c3aed,#ec4899,#0ea5e9,#f59e0b,#10b981', main: 'main', step: 420,
};
var NS = 'http://www.w3.org/2000/svg';
var css =
'.bgg{position:relative;display:block;width:100%;line-height:1.2}' +
'.bgg-svg{display:block;width:100%;overflow:visible}' +
'.bgg-dot{transform-box:fill-box;transform-origin:center}' +
'.bgg-tag{position:absolute;left:0;top:0;padding:.15em .55em;border-radius:.45em;color:#fff;font:600 .78em/1.2 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;box-shadow:0 .15em .5em rgba(30,10,60,.18);opacity:0;pointer-events:none}' +
'.bgg-tag::after{content:"";position:absolute;left:50%;bottom:-.3em;margin-left:-.3em;border:.3em solid transparent;border-bottom:0;border-top-color:inherit}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function mk(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
// Turn the steps into commits on branches.
function parse(text, mainName, colors) {
var branches = {}, order = [], commits = [];
function add(name, from) {
var b = { name: name, color: colors[order.length % colors.length], head: from, first: null, last: null, merged: null };
branches[name] = b;
order.push(b);
return b;
}
var cur = add(mainName, null);
text.split(/[,\n;]+/).forEach(function (raw) {
var w = raw.trim().split(/\s+/), cmd = (w[0] || '').toLowerCase(), arg = w[w.length - 1];
if (cmd === 'git') { w.shift(); cmd = (w[0] || '').toLowerCase(); }
if (cmd === 'commit') {
var c = { i: commits.length, branch: cur, parents: cur.head ? [cur.head] : [] };
commits.push(c);
if (!cur.first) cur.first = c;
cur.head = c; cur.last = c;
} else if (cmd === 'branch' || ((cmd === 'checkout' || cmd === 'switch') && /^-[bc]$/.test(w[1] || ''))) {
if (!branches[arg]) cur = add(arg, cur.head); else cur = branches[arg];
} else if (cmd === 'checkout' || cmd === 'switch') {
if (branches[arg]) cur = branches[arg];
} else if (cmd === 'merge' && branches[arg] && branches[arg] !== cur && branches[arg].head) {
var src = branches[arg], m = { i: commits.length, branch: cur, parents: [cur.head, src.head].filter(Boolean), merge: true, from: src };
commits.push(m);
if (!cur.first) cur.first = m;
cur.head = m; cur.last = m;
}
});
// A branch merged in and never touched again is finished: its tag goes.
commits.forEach(function (c) {
if (c.from && c.from !== order[0] && c.from.last.i < c.i &&
!commits.some(function (o) { return o.from === c.from && o.i > c.i; })) c.ends = true;
});
// Lanes: main on top, each branch on the first lane free for its span.
var lanes = [];
order.forEach(function (b, k) {
if (!b.first) { b.lane = -1; return; }
var start = b.first.parents[0] ? b.first.parents[0].i : b.first.i, end = b.last.i;
commits.forEach(function (c) {
if (c.from === b) end = Math.max(end, c.i);
});
b.span = [start, end];
var lane = k === 0 ? 0 : 1;
while (lanes[lane] && lanes[lane].some(function (sp) { return sp[0] <= end && start <= sp[1]; })) lane++;
(lanes[lane] = lanes[lane] || []).push([start, end]);
b.lane = lane;
});
return { commits: commits, branches: order.filter(function (b) { return b.first; }), lanes: lanes.length };
}
function build(el) {
var s = el._bgg, g = s.graph, fs = parseFloat(getComputedStyle(el).fontSize);
var W = el.clientWidth, n = g.commits.length;
var padL = fs * 1.4, padR = fs * 2.6, top = fs * 2.6, dy = fs * 3, r = Math.max(4, fs * 0.5);
var dx = n > 1 ? (W - padL - padR) / (n - 1) : 0;
var H = top + (g.lanes - 1) * dy + r + fs * 0.6;
if (s.svg) s.svg.remove();
s.tags.forEach(function (t) { t.remove(); });
s.svg = mk('svg', { class: 'bgg-svg', width: W, height: H, viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true' });
el.appendChild(s.svg);
var edges = mk('g', { fill: 'none', 'stroke-width': Math.max(2, fs * 0.28), 'stroke-linecap': 'round' }, s.svg);
var dots = mk('g', {}, s.svg);
function X(c) { return padL + c.i * dx; }
function Y(b) { return top + b.lane * dy; }
s.items = g.commits.map(function (c) {
var cx = X(c), cy = Y(c.branch), lines = [];
c.parents.forEach(function (p, k) {
var px = X(p), py = Y(p.branch), d, col;
if (py === cy) { d = 'M' + px + ' ' + py + 'H' + cx; col = c.branch.color; }
else if (k === 0) {
// Branching off: curve out in the first step, then run straight.
var bx = Math.min(cx, px + dx);
d = 'M' + px + ' ' + py + 'C' + (px + dx * 0.6) + ' ' + py + ' ' + (bx - dx * 0.6) + ' ' + cy + ' ' + bx + ' ' + cy + 'H' + cx;
col = c.branch.color;
} else {
// Merging in: run straight, curve in over the last step.
var ax = Math.max(px, cx - dx);
d = 'M' + px + ' ' + py + 'H' + ax + 'C' + (ax + dx * 0.6) + ' ' + py + ' ' + (cx - dx * 0.6) + ' ' + cy + ' ' + cx + ' ' + cy;
col = p.branch.color;
}
var path = mk('path', { d: d, stroke: col }, edges);
var len = path.getTotalLength();
path.setAttribute('stroke-dasharray', len + ' ' + len);
path.setAttribute('stroke-dashoffset', len);
path._bggLen = len;
lines.push(path);
});
var dot = mk('circle', {
class: 'bgg-dot', cx: cx, cy: cy, r: c.merge ? r * 1.05 : r,
fill: c.merge ? '#fff' : c.branch.color, stroke: c.merge ? c.branch.color : '#fff', 'stroke-width': c.merge ? r * 0.55 : r * 0.35, opacity: 0,
}, dots);
return { c: c, x: cx, y: cy, lines: lines, dot: dot };
});
s.tags = g.branches.map(function (b) {
var t = document.createElement('span');
t.className = 'bgg-tag';
t.setAttribute('aria-hidden', 'true');
t.textContent = b.name;
t.style.background = t.style.borderColor = b.color;
el.appendChild(t);
b._bggTag = t;
return t;
});
s.r = r;
s.W = W;
// Show what was already drawn before a resize.
for (var k = 0; k < s.k; k++) show(el, k, true);
}
function tagAt(el, b, item, instant) {
var s = el._bgg, t = b._bggTag;
var x = item.x - t.offsetWidth / 2, y = item.y - s.r - t.offsetHeight - 6;
x = Math.max(-4, Math.min(s.W - t.offsetWidth + 4, x));
if (instant || t.style.opacity !== '1') {
anime.utils.set(t, { x: x, y: y });
if (instant) { t.style.opacity = 1; return; }
anime.animate(t, { opacity: [0, 1], scale: [0.6, 1], duration: 380, ease: 'outBack(2)' });
} else {
anime.animate(t, { x: x, y: y, duration: 360, ease: 'out(3)' });
}
}
// Draw commit k: its incoming lines, then the dot, then move the tag.
function show(el, k, instant) {
var s = el._bgg, it = s.items[k];
if (!it) return;
var c = it.c;
if (instant) {
it.lines.forEach(function (l) { l.setAttribute('stroke-dashoffset', 0); });
it.dot.setAttribute('opacity', 1);
} else {
it.lines.forEach(function (l) {
anime.animate(l, { strokeDashoffset: [l._bggLen, 0], duration: Math.min(380, s.step * 0.75), ease: 'inOut(2)' });
});
it.dot.setAttribute('opacity', 1);
anime.animate(it.dot, { scale: [0, 1], duration: 420, delay: it.lines.length ? Math.min(300, s.step * 0.6) : 0, ease: 'outBack(3)' });
}
tagAt(el, c.branch, it, instant);
if (c.ends) {
// Merged and done with: its tag sinks away.
var t = c.from._bggTag;
if (instant) t.style.opacity = 0;
else anime.animate(t, { opacity: 0, y: '+=10', duration: 360, delay: 200, ease: 'in(2)' });
}
}
function run(el) {
var s = el._bgg;
clearTimeout(s.timer);
if (s.k < s.items.length) {
show(el, s.k, false);
s.k++;
s.timer = setTimeout(function () { run(el); }, s.step);
return;
}
if (el.dataset.loop === undefined) return;
s.timer = setTimeout(function () {
anime.animate(s.svg, { opacity: [1, 0], duration: 500, ease: 'in(2)' });
anime.animate(s.tags, { opacity: 0, duration: 400, ease: 'in(2)' });
s.timer = setTimeout(function () {
s.k = 0;
build(el);
s.timer = setTimeout(function () { run(el); }, 300);
}, 650);
}, 2400);
}
function setup(el) {
var d = el.dataset;
var colors = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var s = (el._bgg = { tags: [], k: 0, step: +d.step || DEFAULTS.step });
el.classList.add('bgg');
s.graph = parse(d.history || DEFAULTS.history, d.main || DEFAULTS.main, colors);
if (!el.hasAttribute('aria-label')) {
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Git history: ' + s.graph.commits.length + ' commits on ' +
s.graph.branches.map(function (b) { return b.name; }).join(', '));
}
build(el);
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(function () {
if (Math.abs(el.clientWidth - s.W) >= 1) build(el);
}).observe(el);
}
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
setTimeout(function () { run(e.target); }, 200);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-branching-git-graph]').forEach(function (el) {
if (el._bgg) return;
setup(el);
if (reduced()) {
el._bgg.k = el._bgg.items.length;
build(el);
} else io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-branching-git-graph
data-history="commit, commit, branch feature, commit, commit,
checkout main, commit, merge feature, commit"></div>