GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Gantt Chart JavaScript: 9 Self-Scheduling Views

In Gantt chart JavaScript, when a design draft slips three days, moving one bar makes the schedule lie.

Drawing Bars Is the Easy Part — a Schedule That Re-Seats Downstream Tasks When One Date Slips

Auto-plays · click a tile to jump to its section · all nine in one zip

A schedule chart lays each task on a date axis as a horizontal bar, its left edge at the start day and its length at the duration, then links the tasks with arrows so one picture shows who waits for whom. All nine scenes run the same project: ten tasks in BASE_TASKS, from Requirements through Design draft, Data design, UI build, Server build, Integration, Docs, QA review and Bug fixes to Launch. 05 multiplies every duration by 5 for a 105-day plan, 08 drops Launch and splits the other nine into three groups, and 09 swaps in 240 seeded tasks. Dates are plain integer day counts from day 0, and the month and day printed on screen, such as 3/11, come from a table of month lengths that calendarOf adds up, so the demo code holds no Date object anywhere.

The bars in the spring bar chart post grow to one value each, while a bar here takes two values from the date axis, a start and a length, and both get recalculated the moment a predecessor changes. Where the waterfall bridge in nine advanced chart types starts each bar at the height where the previous one ended, so the rises and falls add up in one line, the arrows in 02 carry a rule, that the next task cannot start before the previous one ends, and pick a route that respects it.

Weekly team reviews and launch planning

For a weekly team review, 01 and 06 pair well. 01 grows the bars in start order, so the first thing a viewer learns is what begins first, and 06 darkens hatching only on the tasks that have fallen behind plan once the today line passes them. In a meeting that sets the launch date, 03 does the talking: seven zero-slack tasks light up as one chain, which says "if any of these slips a day, launch slips a day" without a slide of text.

Staffing and change notices

A team with a fixed headcount reaches for 07 first. It stacks how many people each day ties up in a lower chart and, when a day goes over capacity, delays the task with the most slack one day at a time until the overflow is gone, with the finish date left where it was. On a page that shares a timeline with a client, 02 and 04 fit: an arrow answers "why does this task start next week", and a notice that the design is late can carry 04's scene of later tasks shifting step by step.

What Does a Simple Gantt Chart HTML Page Actually Have to Compute?

Every bar holds three numbers: the day it starts, how many days it takes, and how many days it can slip before the launch moves, which is its slack. People type the first two, but the third cannot be typed. Slack only comes out after following every task that hangs behind a bar all the way to the end, so changing one arrow can change the slack of a bar far down the chart.

Hand-placed coordinates go stale in a day

If you build a gantt chart html css mockup by writing each bar's left offset and width by hand, a one-day change to the design draft means retyping the coordinates of the six bars behind it. The nine scenes here store no coordinates. Each task carries four things, a name, a duration in days, the numbers of the tasks it waits for and a headcount, and the start day, slack and screen x are derived from those four on every pass.

Drawn on canvas, read from a hidden table

All nine scenes paint on a 2D canvas rather than building markup per bar. The likely reason is 09, where 240 bars move under a camera every frame, and one drawing routine can then serve every scene. A canvas is opaque to screen readers, so each scene also fills a hidden table with the same schedule in four columns: Task, Start, End and Slack.

Gantt Chart JavaScript: 9 Views — Spring Bars, Elbow Dependency Arrows, the Longest Path, Cascading Pushes, Anchored Zoom, Plan vs Actual, Workload Leveling, Collapsing Groups and a Sprint Hero

What a two-second capture of each scene recorded

Each scene was filmed at 480×300 for two seconds as 24 frames, and the figures compare every pair of neighboring frames, 23 comparisons per scene. 09, with 240 rows sliding under the camera, and 01, with ten bars growing at once, both changed more than half the frame, while 02 moves only a few thin lines, so it covered 2.3% yet changed in all 23 comparisons.

Scene Cumulative area Frames that moved Mean intensity
01 Spring bars 53.3% 13/23 27.4
02 Right-angle dependency arrows 2.3% 23/23 61.6
03 Longest path and slack tails 34.6% 23/23 32.0
04 Cascading push 40.3% 11/23 36.1
05 Anchored zoom 25.0% 16/23 48.3
06 Plan vs actual 14.0% 23/23 93.2
07 Workload leveling 10.8% 11/23 39.8
08 Collapsing groups 26.8% 14/23 56.0
09 Sprint hero 52.9% 23/23 59.7

01, 04, 05, 07 and 08 moved in only 11 to 16 of the comparisons. Each of those five grows, shifts, zooms or folds once on its timetable and then holds still until the next change (5.6 s for 01, 4.8 s for 04, 4.6 s for 08), so the back half of the capture appears to be a still frame. 02 with its flowing dashes, 03 with its sweep line and glow band, and 06 and 09 with a moving today line changed in all 23.

01Spring Bars That Grow in Start-Date Order on a Date-to-Pixel Scale

dayX puts a bar's left edge at 102 + day × 16, so Requirements stands at 102 px with a 48 px width and Server build at 198 px with 112 px, and the bars launch in start-day order (upper row first on a tie), 70 ms apart, from 0.25 s to 0.88 s. Each width grows from 0 to 1 on a spring with stiffness 170 and damping 14 (damping ratio 0.537), overshoots its length by 13.0% and stays inside ±1% from 650 ms after launch, while the shadow lifts up to 4 px the faster a bar grows, so each bar seems to rise and then settle. Someone opening a project dashboard learns which work starts first from the order the bars extend, before reading a single date.

Spring Bars That Grow in Start-Date Order on a Date-to-Pixel Scale
Open in a new tab

01 · Spring Bars That Grow in Start-Date Order on a Date-to-Pixel Scale — Measured area 53.288% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAR_STIFFNESSBAR_DAMPINGBAR_STAGGER_MS
    function dayX(d) { return PLOT_X + d * DAY_WIDTH; }
    // start-day rank — on the same day, the upper row goes first
    const rank01 = (function () {
        const idx = tasks01.map(function (_, i) { return i; });
        idx.sort(function (a, b) { return plan01.es[a] - plan01.es[b] || a - b; });
        const r = new Array(tasks01.length).fill(0);
        idx.forEach(function (i, k) { r[i] = k; });
        return r;
    })();
    const grow01 = tasks01.map(function () { return makeSpring(0); });
    function growAt(i) { return GROW_START_S + rank01[i] * BAR_STAGGER_MS / 1000; }
    function reset() { grow01.forEach(function (s) { snapSpring(s, 0); }); }
    function update(dt) {
        grow01.forEach(function (s, i) {
            s.to = sceneT >= growAt(i) && sceneT < SHRINK_AT_S ? 1 : 0;
            stepSpring(s, BAR_STIFFNESS, BAR_DAMPING, dt);
        });
    }
    function restPose() { grow01.forEach(function (s) { snapSpring(s, 1); }); }

02Dependency Arrows That Turn at Right Angles from Each Task's End

elbowRoute steps 7 px out from the predecessor's end, and when the successor starts at least 7 px further right than that point the arrow turns straight in with 4 points; otherwise it drops to the corridor below the predecessor's row, runs back left and enters with 6 points, which is what 6 of the 11 arrows do, drawn in deep purple. Arrows leave 80 ms apart and draw themselves out over 360 ms, then dashes flow along each finished route at 26 px per second with one dot at 70 px per second, so direction reads on every route from 41 to 221 px long. A page that shares a production timeline with a client can show what each task is waiting for with a single line.

Dependency Arrows That Turn at Right Angles from Each Task's End
Open in a new tab

02 · Dependency Arrows That Turn at Right Angles from Each Task's End — Measured area 2.342% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROUTE_STUBDASH_FLOW_SPEEDARROW_STAGGER_MS
    // From the predecessor end (ex, ey), step ROUTE_STUB to the right, then turn down. If the successor start (sx) lies right of that bend + ROUTE_STUB, drop straight down and turn right into it,
    // otherwise drop to the corridor just below the predecessor row (gapY), run back left, then drop ROUTE_STUB before the successor start and turn right into it
    const ROUTE_STUB = 7;
    function elbowRoute(ex, ey, sx, sy, gapY) {
        const x1 = ex + ROUTE_STUB;
        if (sx - ROUTE_STUB >= x1) {
            return [ex, ey, x1, ey, x1, sy, sx, sy];
        }
        const x2 = sx - ROUTE_STUB;
        return [ex, ey, x1, ey, x1, gapY, x2, gapY, x2, sy, sx, sy];
    }

03The Longest Path, Lit by a Forward and Backward Sweep for Zero Slack

Tasks are computed in dependency order, one every 85 ms from 0.25 s, each bar springing in while a vertical sweep line stops at its earliest finish; from 1.25 s the line runs back toward each latest start and a slack tail springs out (stiffness 120, damping 13, ratio 0.593) with a label such as "Slack 11d", all done by 2.1 s. Then the seven zero-slack tasks, Requirements, Data design, Server build, Integration, QA review, Bug fixes and Launch, darken their outlines and connecting arrows in deep purple while a bright band runs along that path at 9 days per second, and the Docs tail overshoots to 12.03 days before resting at 11. On a launch-date review, this is the frame that shows which tasks cannot slip without moving the finish.

The Longest Path, Lit by a Forward and Backward Sweep for Zero Slack
Open in a new tab

03 · The Longest Path, Lit by a Forward and Backward Sweep for Zero Slack — Measured area 34.583% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWEEP_STEP_MSTAIL_STIFFNESSGLOW_SPEED_DAYS
        for (const i of order) {
            let s = minStart ? minStart[i] : 0;
            for (const p of tasks[i].deps) {
                s = Math.max(s, ef[p]);
            }
            es[i] = s;
            ef[i] = s + tasks[i].dur;
        }
        let end = 0;
        for (const i of order) {
            end = Math.max(end, ef[i]);
        }
        const succ = successorsOf(tasks);
        const ls = new Array(n).fill(0);
        const lf = new Array(n).fill(0);
        for (let k = order.length - 1; k >= 0; k -= 1) {
            const i = order[k];
            let f = end;
            for (const j of succ[i]) {
                f = Math.min(f, ls[j]);
            }
            lf[i] = f;
            ls[i] = f - tasks[i].dur;
        }
        const slack = es.map(function (s, i) { return ls[i] - s; });

04Later Tasks That Shift in Turn Along the Arrows When One Slips

At 0.3 s the design draft gains 3 days and pushFrom walks the arrows breadth first, moving UI build and Docs by 3 days, then Integration, QA review, Bug fixes and Launch by 2 days each, six tasks across five steps. Integration also waits for Server build, which ends on day 13, so the one spare day UI build had absorbs one of the three lost days and Launch moves from Day 22 to Day 24 on screen (day 21 to day 23 counted from 0); each bar's start and length spring (stiffness 210, damping 19, ratio 0.656) from step × 60 ms, the last one leaving at 0.6 s, and the Launch diamond settles for good at 1.037 s. Old positions stay as dashed outlines, the three added days are hatched in red, and the status line reads "Design draft slips 3d → 6 pushed · launch Day 22 → Day 24", the kind of notice a client page needs when a draft runs late.

Later Tasks That Shift in Turn Along the Arrows When One Slips
Open in a new tab

04 · Later Tasks That Shift in Turn Along the Arrows When One Slips — Measured area 40.26% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

PUSH_STAGGER_MSPUSH_STIFFNESSSLIP_DAYS
        const succ = successorsOf(tasks);
        const out = starts.slice();
        const depth = tasks.map(function () { return -1; });
        depth[from] = 0;
        const queue = [from];
        let head = 0;
        // cap the number of dequeues so the loop ends even when there is a cycle
        const limit = tasks.length * tasks.length + 1;
        while (head < queue.length && head < limit) {
            const i = queue[head];
            head += 1;
            for (const j of succ[i]) {
                let need = 0;
                for (const p of tasks[j].deps) {
                    need = Math.max(need, out[p] + tasks[p].dur);
                }
                if (need > out[j]) {
                    out[j] = need;
                    depth[j] = Math.max(depth[j], depth[i] + 1);
                    queue.push(j);
                }
            }
        }
        return { starts: out, depth: depth };

05A Timescale That Zooms Across Days, Weeks and Months Around a Fixed Date

Every duration is multiplied by 5 for a 105-day plan, and dayX measures from the anchor date instead of the left edge, 300 + (day − 52) × day width, so the anchor 5/2 held a screen x error of 0 px across four seconds while the day width went from 26 to 2.2 px. The width is interpolated on a log scale, exp of a blend between ln 26 and ln 2.2, so at the halfway point it is 7.56 px, the geometric mean of the two ends, where a straight blend would give 14.1 px; the day, week and month tick layers cross-fade by width, weeks passing half opacity at 17.92 px and months at 4.49 px. A report that switches between a two-week view and a quarter view keeps the reader pinned on the date they were looking at.

A Timescale That Zooms Across Days, Weeks and Months Around a Fixed Date
Open in a new tab

05 · A Timescale That Zooms Across Days, Weeks and Months Around a Fixed Date — Measured area 24.965% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

DAY_W_NEARDAY_W_FARANCHOR_DAY
    // log interpolation — blend ln(day width) linearly, then convert back
    function zoomWidth(p) { return Math.exp(lerp(Math.log(DAY_W_NEAR), Math.log(DAY_W_FAR), p)); }
    function zoomProgress(t) {
        if (t < ZOOM_IN_AT_S) {
            return smooth((t - ZOOM_OUT_AT_S) / ZOOM_S);
        }
        return 1 - smooth((t - ZOOM_IN_AT_S) / ZOOM_S);
    }
    function dayX(d) { return ANCHOR_X + (d - ANCHOR_DAY) * dayW05; }
    function xDay(x) { return ANCHOR_DAY + (x - ANCHOR_X) / dayW05; }
    // layer opacity — how strongly each of the three overlapping layers shows at one day width
    function layersAt(w) {
        return {
            day: smooth((w - DAY_TICK_FADE[0]) / (DAY_TICK_FADE[1] - DAY_TICK_FADE[0])),
            week: smooth((w - WEEK_TICK_FADE[0]) / (WEEK_TICK_FADE[1] - WEEK_TICK_FADE[0])) * (1 - smooth((w - WEEK_TICK_OUT[0]) / (WEEK_TICK_OUT[1] - WEEK_TICK_OUT[0]))),
            month: 1 - smooth((w - MONTH_TICK_FADE[0]) / (MONTH_TICK_FADE[1] - MONTH_TICK_FADE[0]))
        };
    }

06Plan vs Actual Bars That a Moving Today Line Reveals as Behind

The today line starts at day −0.5 and advances 6.5 days per second from 0.25 s, and for each task expectedAt is how far the plan says it should be while actualAt scales elapsed days by a per-task pace, 0.5 for Docs and 0.6 for the design draft. The gap divided by 0.3 sets the hatch density between the actual end and the expected end, tightening line spacing from 7 to 2.5 px and raising opacity from 0.35 to 0.95, and a label such as "50% behind" appears once density passes 0.15; the largest gaps were Docs at 50% and the design draft at 40%. Plan bars sit underneath as a faint shadow shifted 4 px right and down, which suits a status page where the plan should stay visible but quiet.

Plan vs Actual Bars That a Moving Today Line Reveals as Behind
Open in a new tab

06 · Plan vs Actual Bars That a Moving Today Line Reveals as Behind — Measured area 14.033% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ACTUAL_PACELAG_FULLPLAN_SHADOW_OFFSET
    function expectedAt(i, today) {
        const t = tasks06[i];
        if (t.dur === 0) {
            return today >= sweep06.es[i] ? 1 : 0;
        }
        return clamp((today - sweep06.es[i]) / (sweep06.ef[i] - sweep06.es[i]), 0, 1);
    }
    function actualAt(i, today) {
        const t = tasks06[i];
        if (t.dur === 0) {
            return today >= sweep06.es[i] ? 1 : 0;
        }
        return clamp((today - sweep06.es[i]) / t.dur * ACTUAL_PACE[i], 0, 1);
    }
    // lag → hatch density 0 to 1
    function lagLevel(i, today) { return clamp((expectedAt(i, today) - actualAt(i, today)) / LAG_FULL, 0, 1); }

07Workload Leveling That Trims Days Over the Daily Capacity

levelSteps finds the first day over capacity and, among the tasks that start on that day, delays the one with the most slack by a day, then lets pushFrom shift what follows, repeating until no day is over or 40 steps are used. With a capacity of 3, three days carried 4 people, so Docs, the task with 11 days of slack, moved five times, one day per step, and the peak fell to 3 while the total stayed at 21 days. Steps land every 150 ms from 0.3 s and finish by 1.05 s, with bar positions and the lower chart springing (stiffness 260, damping 20), the moved bar outlined in deep purple that fades, and the share above the "Capacity 3" line in red, a view a team lead can show when explaining why one task moved.

Workload Leveling That Trims Days Over the Daily Capacity
Open in a new tab

07 · Workload Leveling That Trims Days Over the Daily Capacity — Measured area 10.849% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

DAY_CAPACITYLEVEL_MAX_STEPSLEVEL_STEP_MS
            // slack for the current layout — sweep both ways while keeping the set start days
            const sw = sweepBoth(tasks, order, starts);
            let pick = -1;
            // candidates are only tasks that START on that day — tasks already under way are left alone. The first overbooked day carries more work than the day before, so at least one task starts that day,
            // and with started tasks as candidates too, overlapping tasks trade slack and take turns slipping a day, leapfrogging endlessly (at capacity 2, all 40 steps used and still 5 overbooked days)
            tasks.forEach(function (t, i) {
                if (t.load === 0 || starts[i] !== day) {
                    return;
                }
                if (pick < 0 || sw.slack[i] > sw.slack[pick] || (sw.slack[i] === sw.slack[pick] && i > pick)) {
                    pick = i;
                }
            });
            if (pick < 0) {
                break;
            }
            const nudged = starts.slice();
            nudged[pick] += 1;
            starts = pushFrom(tasks, nudged, pick).starts;
            const after = dailyLoad(tasks, starts, span + 1);
            steps.push({ starts: starts.slice(), moved: pick, day: day, over: overflowDays(after, cap) });

08Collapsing Groups Where Subtasks Merge into One Summary Bar

Three groups, Planning (Requirements, Design draft, Data design), Build (UI build, Server build, Integration) and Review (Docs, QA review, Bug fixes), each summarize the earliest start to the latest finish of their children, days 0–7, 6–16 and 7–21. Build folds at 0.3 s and Planning at 0.65 s on a spring with stiffness 150 and damping 15 (overshoot 8.2%), child bars slide into their summary bar and shrink to its height, and layoutTargets counts each folding row as ROW_H × (1 − target) tall so the rows below spring upward, the Review header climbing 102 px and holding still after 1.104 s. Review stays open and simply rises by the height the two groups above gave up, which is what a plan with many workstreams needs when someone asks for a one-line summary per team.

Collapsing Groups Where Subtasks Merge into One Summary Bar
Open in a new tab

08 · Collapsing Groups Where Subtasks Merge into One Summary Bar — Measured area 26.79% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

COLLAPSE_STIFFNESSROW_STIFFNESSGROUP_BAR_H
    // target y — child rows of a folding group count as height 0 (based on the target fold)
    function layoutTargets() {
        const out = [];
        let y = ROWS_TOP;
        rows08.forEach(function (r) {
            out.push(y);
            const h = r.kind === 0 ? ROW_H : ROW_H * (1 - fold08[r.group].to);
            y += h;
        });
        return out;
    }
    function reset() {
        fold08.forEach(function (s) { snapSpring(s, 0); });
        rows08.forEach(function (_, i) { snapSpring(rowY08[i], ROWS_TOP + i * ROW_H); });
    }
    function update(dt) {
        fold08.forEach(function (s, gi) {
            s.to = sceneT >= COLLAPSE_AT_S[gi] && sceneT < EXPAND_AT_S[gi] ? 1 : 0;
            stepSpring(s, COLLAPSE_STIFFNESS, COLLAPSE_DAMPING, dt);
        });
        const ty = layoutTargets();
        rowY08.forEach(function (s, i) { s.to = ty[i]; stepSpring(s, ROW_STIFFNESS, ROW_DAMPING, dt); });
    }

09A Sprint Hero Where the Camera Trails the Today Line to Each Milestone

The 240 tasks come from makeRng with seed 481, arranged as ten sprints of 24 tasks that each end in a "Sprint {n} due" milestone, and 75 of them reveal extra days only after they start, 147 days in total, so the planned end on day 486 stretches to 568. Each time the integer day changes, recompute swaps in actual durations for started tasks and runs sweepBoth twice, 64 times over 20 seconds, while the camera trails the today line with follow at rate 2.2 (63% of the way in 0.455 s) and only rows inside the view get drawn, 14 to 15 of 240. A hero for a sprint review page can open on this scene and leave the full plan in the hidden table.

A Sprint Hero Where the Camera Trails the Today Line to Each Milestone
Open in a new tab

09 · A Sprint Hero Where the Camera Trails the Today Line to Each Milestone — Measured area 52.882% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CAMERA_FOLLOW_RATETODAY_DAYS_PER_SRING_SPEED
    // re-sweep with actual durations each time the day changes — only when the integer day changes, so 240 tasks are not recounted every frame
    function recompute(day) {
        let base = plan09;
        // whether a task has started depends on the recomputed start days, so run it twice to settle
        for (let k = 0; k < 2; k += 1) {
            const ts = knownTasks(day, base);
            base = sweepBoth(ts, order09, null);
            dur09 = ts.map(function (t) { return t.dur; });
        }
        now09 = base;
    }

Dates to Pixels, Arrows to Right-Angle Routes, Slack from a Forward and Backward Sweep — the Math Behind One Schedule

One multiplication from day to pixel

Screen x is the plot's left edge plus day × day width. In 01 that is 102 + day × 16, which puts Launch, 21 days after the start date (Day 22 on screen), at 438 px, and 05 only swaps the left edge for the anchor date, 300 + (day − 52) × width. The formula stays this short because days are integers: start, length and slack are whole day counts, so bar ends land exactly on day boundaries and the "Day {n}" text in the table has no room to drift from its bar.

Why ordering comes first

A successor's start cannot be computed before its predecessors are known. So every scene first runs sortByDeps, which takes out tasks whose count of incoming arrows (waiting) is 0, lowers that count by 1 for each successor as it goes, and puts whatever it cannot take out into stuck.

    function sortByDeps(tasks) {
        const succ = successorsOf(tasks);
        const waiting = tasks.map(function (t) { return t.deps.length; });
        const ready = [];
        waiting.forEach(function (w, i) { if (w === 0) {
            ready.push(i);
        } });
        const order = [];
        while (ready.length) {
            ready.sort(function (a, b) { return a - b; });
            const i = ready.shift();
            order.push(i);
            succ[i].forEach(function (j) { waiting[j] -= 1; if (waiting[j] === 0) {
                ready.push(j);
            } });
        }
        const stuck = [];
        waiting.forEach(function (w, i) { if (w > 0) {
            stuck.push(i);
        } });
        return { order: order, stuck: stuck };
    }

Slack is the gap between the two sweeps

In a gantt chart js example you write yourself, the lines worth the most are the two loops of sweepBoth shown under 03. Walking in sorted order, the earliest start is the latest finish among predecessors; walking back, the latest start is the earliest successor start minus the duration. For the ten tasks that gives earliest starts [0, 3, 3, 7, 6, 13, 7, 16, 19, 21] and latest starts [0, 4, 3, 8, 6, 13, 18, 16, 19, 21], and their difference, [0, 1, 0, 1, 0, 0, 11, 0, 0, 0], is the slack: the design draft and UI build can each slip a day and Docs eleven days without moving the 21-day finish.

Pushing and leveling reuse the same math

04's cascade and 07's leveling add no new calculation. Both shift successors with pushFrom, and 07 calls sweepBoth with the current starts to get the slack of that layout before choosing which task to delay. 09 does the same, running sweepBoth twice each time the day changes, so one forward-and-backward sweep is the shared engine under all nine scenes.

A Stiffer Spring, a Slower Push, a Smaller Daily Capacity — One Value That Changes How the Schedule Feels

Variant Value changed Feel
Stiffer spring 01 BAR_STIFFNESS 170 → 320 damping ratio 0.537 → 0.391, overshoot 13.0% → 25.7%, first arrival 187 ms → 112 ms, inside ±1% for good 650 ms → 621 ms
Slower push 04 PUSH_STAGGER_MS 60 → 160 last departure 0.6 s → 1.1 s, Launch diamond settled 1.037 s → 1.537 s, six pushed tasks and Day 24 launch unchanged
Smaller capacity 07 DAY_CAPACITY 3 → 2 overbooked days 3 → 5, delays 5 → 23, total 21 days → 27 days

Stiffer, yet bouncier bars

The damping ratio is 14 ÷ (2√stiffness), so raising stiffness to 320 with damping left at 14 drops it to 0.391. The bar first reaches its length 75 ms sooner, but the overshoot nearly doubles, so instead of calming down it bounces harder and settles at almost the same moment. To make it bounce less, raise damping along with stiffness.

A push you can follow bar by bar

At 160 ms, UI build → Integration → QA review → Bug fixes → Launch move one at a time, so the path of the spread is easy to trace. The new plan takes 0.5 s longer to settle, so the slower setting suits a meeting screen where someone is talking through the change.

A capacity that runs out of slack

At a capacity of 2, the 11 days of slack in Docs are not enough to clear the overflow. Over 23 delays UI build gets pushed as well, the final starts become [0, 3, 3, 13, 6, 18, 24, 21, 24, 27], and the whole plan grows by 6 days. Leveling protects the finish date only while slack lasts.

Where the Schedule Breaks: Circular Dependencies, Day Math on a Time-Zone Shift Day, and Hundreds of Rows

When the design draft waits on QA review

From 4.4 s to 6.6 s, 02 feeds sortByDeps a copy with one extra dependency that makes the design draft wait on QA review. Only Requirements, Data design and Server build get in line, and seven tasks stay in stuck: the loop itself is four tasks, Design draft → UI build → Integration → QA review, while Docs, Bug fixes and Launch hang downstream of it and cannot line up either. You can rebuild this loop yourself in 02 of the download, and the zip opens with dvx72mdg. Hand only the sorted list to the sweeps and those seven keep a start of 0 with no warning at all. 02 checks stuck and, when it is not empty, hatches those tasks, breaks their dashed arrows with an ✕ and shows "Circular dependency: 7 tasks could not be ordered" in the status line, while pushFrom caps its dequeues at n² + 1 so it finishes even with a loop. To build a different loop on purpose, change the two numbers in LOOP_EDGE. 07 avoids a quieter kind of endless loop: according to a comment in its source, if tasks already under way were candidates as well, overlapping tasks would keep trading slack and slipping in turn, using all 40 steps at a capacity of 2 and still leaving 5 overbooked days.

Ten days that contain a one-hour clock shift

Subtract two outside timestamps in hours and divide by 24, and if a day between day 10 and day 20 had its clock moved forward an hour, 239 hours have passed and 239 ÷ 24 = 9.958. Rounding down gives 9, a day short, and a clock moved back gives 10.042 instead. Rounding with hoursToDayIndex turns both into 10. The demo adds and subtracts whole day counts from the start, so this trap has no way in; hoursToDayIndex is only defined for the day outside timestamps arrive, and the demo does not call it.

Drawing 14 to 15 of 240 rows

09 computes first row = floor((camY + header height) ÷ row height) and last row = floor((camY + view height) ÷ row height) and draws bars, names and arrows only between them; over 20 seconds that came to 14 to 15 of 240 rows, reported at the top right as "Rows drawn a / total b". Arrows are skipped too when their predecessor sits more than 3 rows above the view. The re-sweep runs only when the integer day changes, 64 times in 20 seconds, where running it every frame would come to roughly 1,200. To push the row count, raise HERO_TASKS in 09 of the download.

Readable as a Table, Not Just Bars — a Task List Table, a Pause Button and Reduced-Motion Accessibility

The task table behind each canvas

Each of the nine canvases has role="img" and an aria-label describing the scene, and fillTable fills the hidden <table> beside it with a caption, column headers (Task, Start, End, Slack) and row headers. 09 lists all 240 rows in its table, so a screen reader gets the whole plan even though the canvas shows 14 to 15 rows.

The pause button and focus ring

Every scene has one <button> with aria-pressed that pauses and resumes on click, Enter or Space, and keyboard focus brings a 3 px dark outline through :focus-visible. In 02, 04 and 07, the scenes with a status line, that line stays aria-live="off" during the demo and switches to polite only after the first press, so the self-running scenes do not interrupt a screen reader every few seconds.

What reduced motion leaves on screen

With prefers-reduced-motion: reduce, all nine scenes stop the loop and restPose draws one final layout: 01 shows fully grown bars, 04 the schedule after the push, 07 the leveled chart and 08 a summary with Planning and Build folded and Review open. Changing the setting mid-scene is picked up at once by a change listener, and when the tab is hidden a visibilitychange handler stops the requestAnimationFrame loop; on return, one frame's time is capped at 0.05 s so the bars do not leap ahead.

Questions to Answer Before Building Your Own Gantt Chart for the Web

Can I put this gantt chart schedule example straight into my own page?

Yes. Each scene's script lives inside its single index.html and loads no library, CDN or image file, so unzip it, paste it into the page you want, and it runs right there.

Where do I change task names and durations for my team?

Edit each line of BASE_TASKS: name (task name), dur (days), deps (predecessor numbers) and load (headcount). Start days, slack and screen positions are recomputed from those values, so no coordinates need touching.

Does the text stay sharp on a narrow phone screen?

The stage stays within 92% of the viewport width at a 480:300 ratio, and the canvas multiplies its pixels by the device pixel ratio (capped at 2), so text and lines stay crisp on small screens. For a plan with many tasks, pair it with the 09 approach of drawing only the visible rows.

Enter the archive password

The password is inside this article. You will find it as you read.