Gantt
Native SVG Gantt with deps, milestones, today, weekends, % complete.
Live demo
01Project schedule
Multi-stream plan with dependencies, two milestones, weekends shaded, today marker live.
- Discovery & research Roxy
- Kick-off milestone
- Design system James
- Build front-end Fletcher
- Build API Viola
- Integrate & QA
- Launch
Last clicked: β
Sprint board
Tighter cadence β Spec β Impl β QA β Release with milestones either end.
- Spec & approvals
- Spec sign-off
- Implementation
- QA pass
- Release
Release train
Smaller schedule, no dependency arrows β just the bars and milestones.
- Plan release notes
- Cut RC build
- Bake & smoke tests
- Ship to production
Interactive playground
Toggle features and re-size the day width to see the chart adapt.
- Discovery & research Roxy
- Kick-off milestone
- Design system James
- Build front-end Fletcher
- Build API Viola
- Integrate & QA
- Launch
Implementation
02<script lang="ts">
import Gantt from '$lib/components/Gantt.svelte';
import type { GanttTask } from '$lib/types';
β
const tasks: GanttTask[] = [
{ id: 'spec', name: 'Spec', start: '2026-05-01', end: '2026-05-05', progress: 100 },
{ id: 'kick', name: 'Kick-off', start: '2026-05-06', end: '2026-05-06', isMilestone: true, dependencies: ['spec'] },
{ id: 'build', name: 'Build', start: '2026-05-07', end: '2026-05-15', progress: 35, dependencies: ['kick'] },
{ id: 'qa', name: 'QA', start: '2026-05-14', end: '2026-05-18', dependencies: ['build'] },
{ id: 'launch', name: 'Launch', start: '2026-05-20', end: '2026-05-20', isMilestone: true, dependencies: ['qa'] }
];
</script>
β
<Gantt {tasks} showDependencies showProgress />Gantt is a dependency-free SVG chart. It computes day offsets from the earliest task start, places each task as a rect (or diamond for milestones), and draws elbow-routed arrows between dependent tasks. Today, weekends, and progress are all derived state β change tasks and the chart re-renders without any imperative drawing code.
Logic explainer
03What Does It Do? (Plain English)
Gantt draws a project schedule as a row of horizontal bars on a date axis. Each bar is one task; its position shows when it starts, its length shows how long it takes, and a darker overlay shows how much is done. Diamonds mark instant events ("kick-off", "ship"). Arrows between bars show what blocks what. A dashed red line marks today.
Think of it as a calendar laid on its side: instead of one row per day, you get one row per task, and the days run left-to-right. It's the picture project managers reach for to answer "what are we working on, what's next, and what's stuck waiting on something else?"
How It Works (Pseudo-Code)
state:
tasks = props.tasks
chartStart = startDate ?? min(task.start)
chartEnd = endDate ?? max(task.end)
totalDays = days(chartStart, chartEnd) + 1
today = startOfDay(now)
derived layout per task:
startOffset = days(chartStart, task.start)
span = days(task.start, task.end) + 1
x = startOffset * dayWidth
y = rowIndex * rowHeight + 6
width = span * dayWidth - 4
if task.isMilestone:
render diamond at (x + dayWidth/2, y + rowHeight/2)
else:
render bar(x, y, width, rowHeight - 12)
if task.progress > 0:
render progress overlay(x, y, width * progress/100)
derived weekend bands:
for each day in [chartStart .. chartEnd]:
if day is Sat or Sun: shade column
derived dependency arrows:
for each task t with t.dependencies = [a, b, β¦]:
for each dep id in t.dependencies:
from = layoutById[dep] (right edge)
to = layoutById[t.id] (left edge)
draw elbow path: from β mid β to (with arrowhead)
events:
on bar/diamond click: onTaskClick(task)
on bar/diamond Enter or Space when role=button: same
on Tab into chart-scroll: focus the scroll viewport so arrow keys can pan itThe Core Concept: Day-Based Pixel Math, Once
Gantt charts are deceptive: they look like they need a charting library because there are bars, axes, headers, arrows, today markers, and dependencies. But every visual element on the chart can be computed from a single conversion β how many days from the chart's start is this date? β multiplied by dayWidth.
function dayOffset(date) {
return days(chartStart, startOfDay(date));
}
x = dayOffset(task.start) * dayWidth;
w = (days(task.start, task.end) + 1) * dayWidth;That's it. Headers, weekend bands, today line, milestone diamonds, and dependency arrow endpoints are all just dayOffset(date) * dayWidth plus a small margin. Because the chart renders to SVG, sub-pixel positioning is exact and there's no need to round to grid lines or recalculate on resize β the SVG just scales the underlying viewport.
The only thing the layout pass needs besides the day math is the row index, which is the task's position in the input array (tasks[i] β row i). So the entire layout is O(tasks), and rebuilding it when tasks changes is a single map. The $derived runes wire that up: change a task's start date and the affected bar, its weekend backdrop, and any arrows pointing to or from it move in the same render.
This is why a native SVG Gantt is small β about 320 lines of script β even though it shows weekends, today, milestones, % complete, and elbow-routed dependency arrows. Most of the apparent complexity collapses into the same dayOffset * dayWidth expression repeated in different contexts.
Dependency Arrows: Elbow Routing
Each task.dependencies entry produces an SVG <path> with finish-to-start semantics: it starts at the right edge of the dependency's bar and ends at the left edge of the dependent's bar. Direct lines look like spaghetti as soon as two arrows cross; the standard fix is the same one circuit-board layout tools use β elbow routing.
M startX startY β right edge of dep bar
L startX + turn startY β step right by `turn`
L startX + turn midY β step vertically to the midpoint
L endX - turn midY β step horizontally
L endX - turn endY β step vertically again
L endX endY β finish at the dependent's left edgeturn is max(8, dayWidth/2) so arrows don't visually merge when dayWidth is small. The arrowhead is an SVG <marker> with orient="auto-start-reverse" so it always points along the path direction β which means the same arrowhead works whether the dependent task is above, below, or to the right of the dependency.
Cycles aren't validated; if a consumer hands in A depends on B; B depends on A, both arrows render and the chart simply looks busy. Validation belongs at the data layer, not the renderer.
State Flow Diagram
βββββββββββββββββββββββββββ
β tasks (props) β
ββββββββββ¬βββββββββββββββββ
β
ββββββββββββββΌβββββββββββββ
β $derived chartStart β min(task.start) unless prop
β $derived chartEnd β max(task.end) unless prop
β $derived totalDays β end - start + 1
ββββββββββββββ¬βββββββββββββ
β
ββββββββββββββββββββββΌββββββββββββββββββββββ
β β β
ββββββΌβββββ βββββββΌβββββ ββββββββΌββββββ
β layouts β β weekend β β dayTicks β
β (1/task)β β bands β β (1/day) β
ββββββ¬βββββ ββββββββββββ βββββββββββββββ
β
ββββββΌββββββββββββββ
β dependencyArrows β one per (task, dep) pair
ββββββ¬ββββββββββββββ
β
βΌ
SVG render: weekends β row lines β ticks β today β arrows β bars/diamonds
β
βΌ
click / Enter / Space β onTaskClick(task)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
tasks |
GanttTask[] |
required | Rows in display order. |
startDate |
string | Date |
min(task.start) |
Earliest date the chart covers. |
endDate |
string | Date |
max(task.end) |
Latest date the chart covers. |
dayWidth |
number |
32 |
Pixels per day on the timeline. |
rowHeight |
number |
36 |
Pixel height of one task row. |
labelWidth |
number |
220 |
Pixel width of the labels column. |
showWeekends |
boolean |
true |
Shade Saturdays and Sundays. |
showToday |
boolean |
true |
Vertical line + label at today's date. |
showDependencies |
boolean |
true |
Render finishβstart arrows between tasks. |
showProgress |
boolean |
true |
Overlay a darker bar showing percent complete. |
dateFormat |
'short' | 'long' | (d: Date) => string |
'short' |
Header/label date format. |
onTaskClick |
(task: GanttTask) => void |
β | Fires on bar/diamond click or Enter/Space when focused. |
ariaLabel |
string |
'Gantt chart' |
Wrapper aria-label, combined with task-count summary. |
class |
string |
'' |
Extra classes on the outer container. |
Edge Cases
| Situation | Behaviour |
|---|---|
tasks is empty |
Renders an empty chart with the labels column header and an aria-label of "Empty Gantt chart". No SVG body content. |
task.end is before task.start |
Span clamps to a minimum of one day so the bar is still visible; the renderer doesn't validate ordering β that's the data layer's job. |
task.dependencies references a missing id |
The arrow is silently skipped β it can't be drawn without an endpoint. The task itself still renders. |
Today is outside [chartStart, chartEnd] |
Today marker is suppressed. The dashed line is only useful when in range. |
task.isMilestone === true and end > start |
The end is ignored β milestones render as a single diamond at start. Use a regular bar if you need a duration. |
task.progress is missing or 0 |
No progress overlay, even with showProgress=true. |
task.progress > 100 or < 0 |
Clamped into [0, 100] before drawing. |
User has prefers-reduced-motion: reduce |
Hover/focus brightness transitions on bars and milestones are disabled; everything else renders the same way. |
Two tasks share an id |
The second wins inside layoutById, so dependency arrows targeting the duplicate land on whichever rendered last. Keep ids unique β there's no warning. |
| Mobile viewport (β€ 640px) | Labels column shrinks to 140px and the timeline area horizontally scrolls inside the chart wrapper rather than the page. Page never gets a horizontal scrollbar from the chart. |
onTaskClick not supplied |
Bars and diamonds render as decorative SVG with role="presentation" β no Tab focus, no Enter/Space handling. |
Dependencies
- Svelte 5.x β
$state,$derived,$props,$bindableare required for the layout reactivity. svelte/reactivityβ none used directly; layouts are derived fromtasksvia$derived.- Zero external runtime dependencies. Pure SVG, scoped CSS, no charting libraries.
File Structure
src/lib/components/Gantt.svelte # the component itself
src/lib/components/Gantt.md # this file (rendered inside ComponentPageShell)
src/lib/components/Gantt.test.ts # vitest unit + behaviour tests
src/lib/types.ts # GanttTask + GanttProps + GanttTaskRow
src/lib/constants.ts # FALLBACK_GANTT sample schedule
src/routes/gantt/+page.svelte # demo page (variants + interactive playground)API
04| Prop | Type | Default | Description |
|---|---|---|---|
tasks | GanttTask[] | required | Rows in display order. |
startDate | string | Date | min(task.start) | Earliest date the chart covers. |
endDate | string | Date | max(task.end) | Latest date the chart covers. |
dayWidth | number | 32 | Pixels per day on the timeline. |
rowHeight | number | 36 | Pixel height of a task row. |
labelWidth | number | 220 | Pixel width of the labels column. |
showWeekends | boolean | true | Shade Saturdays and Sundays. |
showToday | boolean | true | Vertical line + label at today. |
showDependencies | boolean | true | Render finishβstart arrows. |
showProgress | boolean | true | Overlay percent-complete shading. |
dateFormat | 'short' | 'long' | (d) => string | 'short' | Header/label date format. |
onTaskClick | (task) => void | β | Receives the clicked task. |
ariaLabel | string | 'Gantt chart' | Wrapper aria-label. |