DEV Community

Cover image for Rendering 50,000 Gantt rows in Vue without a virtual list library
Timur Kutumbayev
Timur Kutumbayev

Posted on

Rendering 50,000 Gantt rows in Vue without a virtual list library

I build admin apps with Vue 3, shadcn-vue and Tailwind. When one of them needed a Gantt chart, I could not find one that fit that setup: the popular Vue package has not had a commit since 2024, others pull in a whole UI kit, and the commercial ones keep the useful parts behind a license and style everything their own way.

So I wrote ganttkit. It has two parts: a headless useGantt composable on npm, and a set of shadcn-vue components that you copy into your project and change like your own code. The demo renders 10,000 rows by default and you can switch it to 50,000.

ganttkit demo: auto-shift, a new link, zoom and a fast scroll through 10,000 rows, then the dark theme and the resource view with a tooltip, the right-click menu and a drag to another person

The core depends on Vue and @internationalized/date, nothing else. There is no virtual list library in it. This post is about the few decisions that made that possible.

One scroll container instead of two

A Gantt chart is a table with a frozen first column and a frozen header. The common way to build it is two panes: a task list on the left, a timeline on the right, each with its own scrollbar. Then you keep them in sync: listen to scroll on one and set scrollTop on the other.

That always lags. The second pane gets its new position one event later than the first, and with momentum scrolling on a trackpad you can see the rows on the left and the bars on the right drift apart for a moment.

ganttkit has one scroll container. The header and the sidebar are position: sticky inside it:

<div class="overflow-auto">                        <!-- the only scroller -->
  <div class="sticky top-0 z-30 flex">             <!-- header row -->
    <div class="sticky left-0 z-10">Task</div>    <!-- top-left corner -->
    <div>timeline scale</div>
  </div>
  <div class="flex">
    <div class="sticky left-0 z-20">row labels</div>
    <div class="relative" :style="{ width, height }">bars, links, grid</div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The browser scrolls everything in one go, so there is nothing to keep in sync and nothing to drift. The corner cell is sticky in both directions and sits above both.

Render a window, but only change it in chunks

Virtualization itself is simple. Rows have a known height, so the rows on screen are floor(scrollTop / rowHeight) to ceil((scrollTop + viewportHeight) / rowHeight). Render those, put each one at translateY(top) inside a spacer as tall as all the rows, done.

The catch is that this range changes on almost every scroll event. In Vue that means a new array, a new render of the list and a diff, 60 times a second, even when the user moved by 20 pixels and the same rows are still on screen.

So the window snaps to chunks. Rows are grouped by 10, and the rendered range is the chunks on screen plus one extra chunk above and below:

export function chunkedRange(offset: number, size: number, itemSize: number, count: number, chunk: number) {
  const first = Math.max(0, Math.floor(offset / itemSize))
  const last = Math.ceil((offset + size) / itemSize)
  const start = Math.min(count, Math.max(0, (Math.floor(first / chunk) - 1) * chunk))
  const end = Math.min(count, (Math.ceil(last / chunk) + 1) * chunk)
  return { start, end: Math.max(start, end) }
}
Enter fullscreen mode Exit fullscreen mode

The result only changes when the scroll crosses a chunk boundary. To make Vue notice that, the window is a computed that receives its previous value (Vue 3.4+) and returns the old object when nothing changed:

const rowWindow = computed((previous) => {
  const next = chunkedRowRange(rowOffsets.value, scrollTop.value, bodyHeight.value, ROW_CHUNK)
  return sameRange(previous, next) ? previous : next
})
Enter fullscreen mode Exit fullscreen mode

When computed returns the same object, everything that depends on it (visible rows, bars, link paths) does not run again. Most scroll events end with the scrollTop ref changing and nothing else. The horizontal axis works the same way with 600 px chunks, which keeps the timeline scale and grid lines cheap too.

Since 0.3 rows can have different heights: in the resource view a row grows when bookings overlap. The row tops then live in a Float64Array of prefix sums, and the first visible row is a binary search instead of a division. Same chunks, same trick.

Do not hand Vue a new object if nothing changed

Bars are recomputed when the window moves. Most of them are exactly where they were, but each one is a fresh object, so Vue updates every bar component even though its props did not really change.

The fix is to remember the previous bars by id and reuse the old object when every field is equal:

for (let i = 0; i < bars.length; i++) {
  const previous = previousBars.get(bars[i].id)
  if (previous && sameBar(previous, bars[i])) bars[i] = previous
  next.set(bars[i].id, bars[i])
}
previousBars = next
Enter fullscreen mode Exit fullscreen mode

Same object in, Vue sees unchanged props and skips that component. In the resource view with 10,000 people this cut a scroll step from 11.1 ms to 8.6 ms on my machine.

The numbers

I measured the live demo in headless Chromium on an M1 Pro: 300 scroll steps of 3 rows each, timing from the scroll event until Vue has updated and layout is done.

rows DOM nodes in the chart median step 95th percentile
1,000 891 0.2 ms 7.4 ms
10,000 891 0.2 ms 6.9 ms
50,000 891 0.2 ms 6.8 ms

The median is the step that stays inside a chunk: Vue does almost nothing. The 95th percentile is a step that crosses a chunk and re-renders the window. Neither depends on the number of rows, and the DOM stays under 1,000 nodes at any size.

Time is linear, the calendar is not

Inside the core every date is a number of milliseconds since the epoch. That keeps the geometry trivial:

const timeToX = (ms: number) => (ms - origin) / msPerPx
Enter fullscreen mode Exit fullscreen mode

But "one day later" is not ms + 86_400_000. On a daylight saving change a day is 23 or 25 hours, and a task snapped to midnight would end up at 23:00 or 01:00. So everything that is about the calendar (snapping a dragged bar to a day, the ticks of the scale, adding a week) is done in the chart's time zone with @internationalized/date:

export function addUnits(ms: number, unit: TimeUnit, amount: number, timeZone: string): number {
  if (unit === 'hour') return ms + amount * HOUR
  const zoned = fromAbsolute(ms, timeZone)
  switch (unit) {
    case 'day':
      return zoned.add({ days: amount }).toDate().getTime()
    // week, month, quarter, year the same way
  }
}
Enter fullscreen mode Exit fullscreen mode

I picked that library because shadcn-vue projects already have it: reka-ui, which shadcn-vue is built on, depends on it.

The same idea fixes date-only strings. new Date('2026-03-02') is midnight UTC, which is still March 1 in New York. ganttkit reads '2026-03-02' as midnight in the chart's timeZone. Thousands of rows usually share a few hundred distinct dates, so parsed strings are cached.

Edits come back in your format

The core never changes your data. When a bar is dragged it reports the change, and the new dates come back in the same shape you passed in:

export function formatLike(original: DateInput, ms: number, timeZone: string): DateInput {
  if (typeof original === 'number') return ms
  if (original instanceof Date) return new Date(ms)
  if (DATE_ONLY.test(original)) return toCalendarDate(fromAbsolute(ms, timeZone)).toString()
  if (!HAS_OFFSET.test(original)) return toCalendarDateTime(fromAbsolute(ms, timeZone)).toString()
  return new Date(ms).toISOString()
}
Enter fullscreen mode Exit fullscreen mode

If your API sends start: '2026-03-02', the item-change event gives you '2026-03-02', not a Date and not a timestamp. You can send it back to the server without converting anything. With v-model:items you do not even handle the event.

Where the headless part ends

useGantt knows the data, the zoom, the scroll position and the viewport size. It returns positioned rows, bars, scale ticks and link paths, and it handles dragging, keyboard editing and undo. It never creates an element.

The components are plain Vue templates with Tailwind classes:

npx shadcn-vue add @ganttkit/gantt
Enter fullscreen mode Exit fullscreen mode

The files land in components/ui/gantt. If you want a different bar, a progress label or your own tooltip, you edit the file or use a slot. Nothing to override, no theme API to learn.

I considered canvas and dropped it early. It would be faster for huge charts, but it breaks exactly what makes the component useful in a shadcn-vue app: Tailwind classes, slots, focus and screen readers. With the chunked window, DOM is fast enough.

What is new in 0.3

The same core now runs a resource view: people (or rooms, machines) are rows, bookings are items.

  • Overlapping bookings stack into lanes and the row grows.
  • Time where someone is booked beyond capacity is shaded red.
  • A booking moves to another person by dragging, with Alt + Up/Down, or from a right-click menu with search.
  • With auto-shift, dependent tasks move forward when an edit breaks a link, and the whole change is one undo step.

Try it in the resource view of the demo.

Try it

If you have a Gantt or a scheduler in a Vue app, I would like to hear what is missing. Issues and Discussions are open, and two features in 0.2 came straight from comments.

Top comments (0)