Gantt: custom bars, tooltips and menus

What the chart draws inside a bar, says on hover and offers on right-click are all yours to replace: two snippets, a menu builder, and a handful of CSS custom properties for the colours.

<script lang="ts">
  import { SvGrid, SvAvatar, type GridColumns, type MenuItem } from '@svgrid/grid'
  import { enableGanttView } from '@svgrid/enterprise'

  enableGanttView()

  type Task = {
    id: string
    name: string
    owner?: string
    status?: 'On track' | 'At risk' | 'Done'
    start: string
    end?: string
    progress?: number
    parentId?: string | null
    milestone?: boolean
    color?: string
  }

  const STATUS = { 'On track': '#2563eb', 'At risk': '#d97706', Done: '#16a34a' } as const

  const pad = (n: number) => String(n).padStart(2, '0')
  const isoDay = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
  const base = new Date()
  base.setHours(0, 0, 0, 0)
  base.setDate(base.getDate() - ((base.getDay() + 6) % 7))
  const at = (dayOffset: number) => {
    const d = new Date(base)
    d.setDate(d.getDate() + dayOffset)
    return isoDay(d)
  }

  let data = $state<Task[]>([
    { id: 'p1', name: 'Discovery', parentId: null, start: at(0) },
    { id: 't1', name: 'Interviews', parentId: 'p1', start: at(0), end: at(6), progress: 100, owner: 'Priya', status: 'Done', color: STATUS.Done },
    { id: 't2', name: 'Synthesis', parentId: 'p1', start: at(7), end: at(13), progress: 40, owner: 'Marco', status: 'At risk', color: STATUS['At risk'] },
    { id: 'p2', name: 'Build', parentId: null, start: at(14) },
    { id: 't3', name: 'Implementation', parentId: 'p2', start: at(14), end: at(27), progress: 10, owner: 'Sven', status: 'On track', color: STATUS['On track'] },
    { id: 'm1', name: 'Launch', parentId: null, start: at(28), milestone: true, color: '#f59e0b' },
  ])

  const columns: GridColumns<Task> = [
    { field: 'name', header: 'Task', width: 200 },
    { field: 'owner', header: 'Owner', width: 100 },
  ]

  const fmt = (d?: string) => (d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '')
</script>

The bar body

task is a snippet that receives the row and replaces the built-in label inside a task bar. It gets the bar's full width and height and clips its own overflow, so keep it to one line: an avatar, a name, a chip.

<script lang="ts">
  const cfg = {
    startField: 'start' as const, endField: 'end' as const, parentField: 'parentId' as const,
    progressField: 'progress' as const, milestoneField: 'milestone' as const, colorField: 'color' as const,
    task: bar,
  }
</script>

{#snippet bar(row: Task)}
  <span style="display:inline-flex; align-items:center; gap:6px; padding:0 6px; color:#fff; font-size:0.76rem; min-width:0">
    <SvAvatar name={row.owner ?? '?'} size={16} />
    <span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600">{row.name}</span>
    <span style="padding:1px 6px; border-radius:999px; background:rgba(255,255,255,.22); font-size:0.66rem">{row.status}</span>
  </span>
{/snippet}

<SvGrid {data} {columns} getRowId={(r) => r.id} gantt={cfg} />

Two things the snippet does not touch. A phase's summary bar keeps its plain label - the spine is a few pixels tall and has no room for a body - and a milestone has no body at all. The progress fill still draws under the snippet, so a bar with a body reads its percent the same way.

labelPosition is for the built-in label only: 'inside' (the default, with a fallback to the right of a bar too narrow for its text), always 'right', or 'none'.

The tooltip

tooltip: true shows the built-in hover card: title, dates, working days and percent, plus the resource, slack, baseline drift and constraint when those options are on. Pass a snippet instead for your own:

<script lang="ts">
  const cfg = {
    startField: 'start' as const, endField: 'end' as const, parentField: 'parentId' as const,
    progressField: 'progress' as const, milestoneField: 'milestone' as const, colorField: 'color' as const,
    tooltip: tip,
    tooltipDelay: 200,
  }
</script>

{#snippet tip(row: Task)}
  <div style="display:flex; flex-direction:column; gap:3px">
    <strong>{row.name}</strong>
    {#if row.milestone}
      <span style="font-size:0.74rem; opacity:.85">Milestone on {fmt(row.start)}</span>
    {:else if row.parentId}
      <span style="font-size:0.74rem; opacity:.85">{row.owner} - {row.status} - {fmt(row.start)} to {fmt(row.end)}</span>
      <span style="font-size:0.74rem; opacity:.85">{row.progress}% done</span>
    {/if}
  </div>
{/snippet}

<SvGrid {data} {columns} getRowId={(r) => r.id} gantt={cfg} />

The card is positioned above the bar and portalled to the body, so it is never clipped by the scroll container. tooltipDelay is the hover delay in ms (default 400). Omit tooltip and there is none.

The context menu

Right-click on a bar opens a menu of what the config makes possible: Edit with the drawer, Add subtask with onTaskAdd, Delete with onTaskDelete. taskMenu appends items of your own - return MenuItems, or undefined to add nothing for that row:

<script lang="ts">
  let last = $state('')
  const taskMenu = (row: Task): MenuItem[] | undefined => {
    if (!row.parentId) return undefined // nothing extra on a phase
    return [
      { label: `Open ticket for ${row.name}`, onSelect: () => (last = `ticket: ${row.name}`) },
      { label: 'Mark done', onSelect: () => { row.progress = 100; row.status = 'Done'; row.color = STATUS.Done } },
    ]
  }
</script>

<p style="margin:0 0 8px; font-size:0.8rem">Right-click a task. Last action: <code>{last || 'none'}</code></p>
<SvGrid {data} {columns} getRowId={(r) => r.id}
  gantt={{
    startField: 'start', endField: 'end', parentField: 'parentId',
    progressField: 'progress', milestoneField: 'milestone', colorField: 'color',
    taskMenu,
  }} />

With editable and onDependencyRemove, right-clicking an arrow offers to remove that link.

Colours and the CSS hooks

A bar takes its colour from colorField (per task) or color (all of them); the fill inside it is a darker shade of the same colour, and the summary spine a lighter one, so one field colours a whole phase. Colour by team, by status, by trade - the roadmap demo colours by status and keeps the team in the table:

Open the live example: Product roadmap - a year in quarters (Gantt)

The chrome reads the grid's --sg-* tokens, so a theme that restyles the table restyles the chart. A few Gantt-specific properties sit on top, set them on any ancestor:

Property Colours
--sv-gantt-accent The default bar colour when a task has none.
--sv-gantt-dep Dependency arrows.
--sv-gantt-dep-bad Violated links, a broken constraint's outline and pin, the late baseline ghost.
--sv-gantt-critical The critical path ring and its arrows.
.my-plan {
  --sv-gantt-critical: #b91c1c;
  --sv-gantt-dep: #64748b;
}

See also

Related articles

  • Status Badge Cells in SvGrid - Turn a plain text status column into colored, accessible pill badges without breaking sort, filter, or virtualization - using SvGrid's snippet cell renderer.
  • Sparkline Cells in a Svelte Data Grid - Show inline trend sparklines inside grid cells using SvGrid's built-in sparkline column property - no charting library needed, just a field that holds a number array.
  • Progress and Percentage Bar Cells in SvGrid - Build in-cell progress bars in your Svelte 5 data grid - with color thresholds, accessible markup, and sorting that still works.