Gantt

Native SVG Gantt with deps, milestones, today, weekends, % complete.

Live demo

01
Demo Fixture Data

Project schedule

Multi-stream plan with dependencies, two milestones, weekends shaded, today marker live.

Task
  • Discovery & research Roxy
  • Kick-off milestone
  • Design system James
  • Build front-end Fletcher
  • Build API Viola
  • Integrate & QA
  • Launch
4567891011121314151617181920212223242526272829303112May 2026Jun 2026TODAY

Last clicked: β€”

Sprint board

Tighter cadence β€” Spec β†’ Impl β†’ QA β†’ Release with milestones either end.

Task
  • Spec & approvals
  • Spec sign-off
  • Implementation
  • QA pass
  • Release
8910111213141516171819202122May 2026TODAY

Release train

Smaller schedule, no dependency arrows β€” just the bars and milestones.

Task
  • Plan release notes
  • Cut RC build
  • Bake & smoke tests
  • Ship to production
910111213141516171819May 2026TODAY

Interactive playground

Toggle features and re-size the day width to see the chart adapt.

Task
  • Discovery & research Roxy
  • Kick-off milestone
  • Design system James
  • Build front-end Fletcher
  • Build API Viola
  • Integrate & QA
  • Launch
4567891011121314151617181920212223242526272829303112May 2026Jun 2026TODAY

Implementation

02
Gantt.svelte
<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

03

What 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 it

The 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 edge

turn 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, $bindable are required for the layout reactivity.
  • svelte/reactivity β€” none used directly; layouts are derived from tasks via $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
PropTypeDefaultDescription
tasksGanttTask[]requiredRows in display order.
startDatestring | Datemin(task.start)Earliest date the chart covers.
endDatestring | Datemax(task.end)Latest date the chart covers.
dayWidthnumber32Pixels per day on the timeline.
rowHeightnumber36Pixel height of a task row.
labelWidthnumber220Pixel width of the labels column.
showWeekendsbooleantrueShade Saturdays and Sundays.
showTodaybooleantrueVertical line + label at today.
showDependenciesbooleantrueRender finish→start arrows.
showProgressbooleantrueOverlay percent-complete shading.
dateFormat'short' | 'long' | (d) => string'short'Header/label date format.
onTaskClick(task) => voidβ€”Receives the clicked task.
ariaLabelstring'Gantt chart'Wrapper aria-label.