Gantt Bars
A project timeline. A line sweeps across the schedule, each task’s bar extends behind it from its start to its end, and the arrows from the tasks it waits for draw in to meet it.
Research
Design
Build
Content
Testing
Launch
How to use it
- Paste the snippet just before
</head>. - Add
data-gantt-barsto a container with a height, with one child per task: its text is the name,data-startanddata-endwhere its bar runs (in any unit), anddata-afterthe tasks it waits for.
Options
Add any of these attributes to the same element.
data-after | On a task: the tasks it depends on, by name or number (1 = first), comma-separated. |
data-ticks | Comma-separated labels for each unit along the top, e.g. W1,W2,W3. |
data-color | On a task: its colour. Default a purple, pink, blue… set. |
data-duration | Time for the sweep across. Default 3200 ms. |
data-loop | Clear and sweep again every few seconds. |
Arrows that would have to go backwards route round underneath. Screen readers hear each task with its dates and what it waits for.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Gantt Bars: a project timeline. A line sweeps across the schedule and
// each task's bar extends behind it from its start to its end, while the
// arrows from the tasks it depends on draw in to meet it.
// Usage:
// <div data-gantt-bars data-ticks="W1,W2,W3,W4,W5,W6" style="height:240px">
// <div data-start="0" data-end="2">Research</div>
// <div data-start="2" data-end="4" data-after="Research">Design</div>
// <div data-start="4" data-end="6" data-after="Design">Build</div>
// </div>
// Each child is one task: its text is the name, data-start / data-end
// where its bar begins and ends, in any unit (days, weeks…), and
// data-after the task(s) it waits for, by name or number (1 = first),
// comma-separated.
// Optional: data-ticks (comma-separated labels for each unit along the
// top), data-color (on a task: its colour; default a purple, pink, blue…
// set), data-duration (ms for the sweep, default 3200), data-loop (clear
// and sweep again every few seconds).
var DEFAULTS = { duration: 3200, hold: 2600 };
var PALETTE = ['#a855f7', '#ec4899', '#0ea5e9', '#f59e0b', '#10b981', '#6366f1'];
var css =
'.gnt{--gnt-lw:clamp(62px,26%,128px);display:flex;flex-direction:column;font-size:12px}' +
'.gnt-head{display:flex;padding-left:var(--gnt-lw);font-size:10px;font-weight:600;opacity:.55;line-height:16px}' +
'.gnt-head span{flex:1;min-width:0;text-align:center;overflow:hidden;white-space:nowrap}' +
'.gnt-body{position:relative;flex:1;min-height:0;display:flex;flex-direction:column}' +
'.gnt-body::before{content:"";position:absolute;top:0;bottom:0;left:var(--gnt-lw);right:0;opacity:.09;pointer-events:none;' +
'background:linear-gradient(to right,currentColor 1px,transparent 1px) 0 0/calc(100% / var(--gnt-n)) 100%;border-right:1px solid currentColor}' +
'.gnt-row{position:relative;flex:1;min-height:0;display:flex;align-items:center}' +
'.gnt-row + .gnt-row{box-shadow:inset 0 1px rgba(127,127,127,.12)}' +
'.gnt-name{width:var(--gnt-lw);flex:none;padding-right:8px;box-sizing:border-box;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +
'.gnt-track{position:relative;flex:1;height:100%}' +
'.gnt-bar{position:absolute;top:22%;bottom:22%;width:0;border-radius:5px;background:var(--gnt-c);box-shadow:0 2px 6px -2px var(--gnt-c)}' +
'.gnt-body svg{position:absolute;top:0;left:var(--gnt-lw);width:calc(100% - var(--gnt-lw));height:100%;overflow:visible;pointer-events:none}' +
'.gnt-line{position:absolute;top:-4px;bottom:0;width:2px;margin-left:-1px;background:#f43f5e;border-radius:2px;opacity:0;pointer-events:none}' +
'.gnt-line::before{content:"";position:absolute;top:0;left:50%;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:inherit}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
function setup(el) {
var d = el.dataset;
var tasks = Array.prototype.slice.call(el.children).map(function (t, i) {
var s = +t.getAttribute('data-start') || 0;
return {
name: t.textContent.trim(), start: s, end: Math.max(s, +t.getAttribute('data-end') || s + 1),
after: t.getAttribute('data-after'), color: t.getAttribute('data-color') || PALETTE[i % PALETTE.length]
};
});
var ticks = d.ticks ? d.ticks.split(',') : [];
var total = Math.max(ticks.length, Math.max.apply(null, tasks.map(function (t) { return t.end; }).concat([1])));
// Dependencies: [from task, to task].
var links = [];
tasks.forEach(function (t, i) {
if (!t.after) return;
t.after.split(',').forEach(function (ref) {
ref = ref.trim();
var j = /^\d+$/.test(ref) ? +ref - 1 : tasks.map(function (x) { return x.name; }).indexOf(ref);
if (j >= 0 && j < tasks.length && j !== i) links.push({ from: j, to: i });
});
});
el.classList.add('gnt');
el.style.setProperty('--gnt-n', total);
el.setAttribute('role', 'img');
el.setAttribute('aria-label', 'Gantt chart. ' + tasks.map(function (t) {
var deps = links.filter(function (l) { return tasks[l.to] === t; }).map(function (l) { return tasks[l.from].name; });
return t.name + ': ' + (ticks[t.start] || 'from ' + t.start) + ' to ' + (ticks[t.end - 1] || t.end) + (deps.length ? ', after ' + deps.join(' and ') : '');
}).join('. '));
el.innerHTML =
(ticks.length ? '<div class="gnt-head" aria-hidden="true">' + ticks.map(function (k) { return '<span>' + esc(k) + '</span>'; }).join('') + '</div>' : '') +
'<div class="gnt-body" aria-hidden="true">' +
tasks.map(function (t) {
return '<div class="gnt-row"><span class="gnt-name">' + esc(t.name) + '</span><div class="gnt-track">' +
'<div class="gnt-bar" style="left:' + (t.start / total) * 100 + '%;--gnt-c:' + esc(t.color) + '"></div></div></div>';
}).join('') +
'<svg>' + links.map(function () {
return '<g><path pathLength="1" fill="none" stroke="currentColor" stroke-opacity=".55" stroke-width="1.5" stroke-linejoin="round" stroke-dasharray="1 1"/><path fill="currentColor" fill-opacity=".7"/></g>';
}).join('') + '</svg><div class="gnt-line"></div></div>';
var c = { el: el, tasks: tasks, links: links, total: total, p: 0 };
c.body = el.querySelector('.gnt-body');
c.bars = Array.prototype.slice.call(el.querySelectorAll('.gnt-bar'));
c.name = el.querySelector('.gnt-name');
c.line = el.querySelector('.gnt-line');
Array.prototype.slice.call(el.querySelectorAll('.gnt-body g')).forEach(function (g, i) {
links[i].path = g.firstChild; links[i].head = g.lastChild;
});
el._gnt = c;
layout(c);
if (window.ResizeObserver) new ResizeObserver(function () { layout(c); render(c); }).observe(el);
return c;
}
// The connector shapes, in pixels: out of the end of one bar and into the start of the next.
function layout(c) {
var lw = c.name ? c.name.offsetWidth : 0;
var w = c.body.clientWidth - lw, h = c.body.clientHeight / (c.tasks.length || 1);
var x = function (u) { return (u / c.total) * w; };
c.lw = lw; c.w = w;
c.links.forEach(function (l) {
var a = c.tasks[l.from], b = c.tasks[l.to];
var x1 = x(a.end), y1 = (l.from + 0.5) * h, x2 = x(b.start) - 1, y2 = (l.to + 0.5) * h;
var d;
if (x2 - x1 >= 14) d = 'M' + x1 + ' ' + y1 + 'H' + (x1 + 7) + 'V' + y2 + 'H' + x2;
else {
var yb = (l.from + (y2 > y1 ? 1 : 0)) * h;
d = 'M' + x1 + ' ' + y1 + 'H' + (x1 + 7) + 'V' + yb + 'H' + (x2 - 9) + 'V' + y2 + 'H' + x2;
}
l.path.setAttribute('d', d);
l.head.setAttribute('d', 'M' + x2 + ' ' + y2 + 'l-6 -3.5v7z');
});
}
// Everything at sweep position p (in units, 0 to total).
function render(c) {
var p = c.p;
c.tasks.forEach(function (t, i) {
var len = Math.max(0, Math.min(t.end, p) - t.start);
c.bars[i].style.width = (len / c.total) * 100 + '%';
});
c.links.forEach(function (l) {
var a = c.tasks[l.from], b = c.tasks[l.to];
var k = Math.max(0, Math.min(1, (p - a.end) / Math.max(0.4, b.start - a.end)));
l.path.style.strokeDashoffset = 1 - k;
l.head.style.opacity = k >= 0.98 ? 1 : 0;
});
c.line.style.left = c.lw + (p / c.total) * c.w + 'px';
}
function play(c) {
var d = c.el.dataset;
var duration = +d.duration || DEFAULTS.duration;
var st = { p: 0 };
c.p = 0; render(c);
anime.utils.set(c.bars, { opacity: 1 });
anime.animate(c.line, { opacity: [0, 1], duration: 250 });
anime.animate(st, {
p: c.total + 0.4, duration: duration, ease: 'linear',
onUpdate: function () { c.p = Math.min(c.total, st.p); render(c); },
onComplete: function () {
anime.animate(c.line, { opacity: 0, duration: 400 });
if (d.loop === undefined || d.loop === 'false') return;
setTimeout(function () {
var fade = c.bars.concat(c.links.map(function (l) { return l.path.parentNode; }));
anime.animate(fade, {
opacity: 0, duration: 450, ease: 'out(2)',
onComplete: function () {
c.p = 0; render(c);
anime.utils.set(fade, { opacity: 1 });
setTimeout(function () { play(c); }, 200);
}
});
}, DEFAULTS.hold);
}
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target._gnt);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-gantt-bars]').forEach(function (el) {
if (el._gnt) return;
var c = setup(el);
if (reduced()) { c.p = c.total; render(c); return; }
render(c);
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-gantt-bars data-ticks="W1,W2,W3,W4,W5,W6" style="height: 220px">
<div data-start="0" data-end="2">Research</div>
<div data-start="2" data-end="4" data-after="Research">Design</div>
<div data-start="4" data-end="6" data-after="Design">Build</div>
</div>