EmpireUI
Get Pro
← Blog7 min read#react#gantt-chart#project-timeline

Gantt Chart in React: Project Timeline without heavy libs

Build a fully functional React Gantt chart from scratch with Tailwind CSS — no heavyweight libraries, just clean components and real project timeline data.

Developer looking at a project timeline chart on a large monitor in a dark office

Why You Probably Don't Need a Gantt Library

Honestly, most Gantt chart libraries are complete overkill for what teams actually need. You install something like dhtmlx-gantt or react-gantt-task, pull in 200KB of bundled JavaScript, fight the documentation for two hours, and end up with something that looks nothing like your design system anyway.

The typical project timeline UI is, at its core, a grid. Rows are tasks. Columns are days or weeks. Each task gets a colored bar that spans from its start date to its end date. That's it. You don't need a library for that — you need maybe 150 lines of TypeScript and some Tailwind classes.

This guide builds a real Gantt component from scratch. It'll handle date math, render horizontal bars correctly, and support basic interactions. We're using React 18 and Tailwind v4.0.2. No external charting deps beyond what you already have.

Data Model and TypeScript Types

Start by defining the shape of a task. Keep it minimal — you can always expand later. The only fields you absolutely need are an id, a label, a start date, and an end date. Everything else is cosmetic.

export type GanttTask = {
  id: string;
  label: string;
  start: Date;
  end: Date;
  color?: string;
  group?: string;
};

export type GanttProps = {
  tasks: GanttTask[];
  startDate?: Date;
  endDate?: Date;
  columnWidth?: number; // px per day, default 32
};

The columnWidth prop is the single most important layout value. At 32px per day you get a readable timeline for a 30-day sprint without horizontal scroll. At 16px you can show a full quarter. Pass it as a prop so callers decide the density — don't hard-code it. If you're building a theme-aware UI with dark mode support, you'll also want to accept a className prop to let consumers override colors.

Date Math: The Part Everyone Gets Wrong

Before rendering anything, you need two helper functions: one that returns the total number of days in your timeline range, and one that converts a task's start date into a pixel offset from the timeline's left edge. Both are pure functions with zero dependencies.

const MS_PER_DAY = 1000 * 60 * 60 * 24;

function daysBetween(a: Date, b: Date): number {
  return Math.round((b.getTime() - a.getTime()) / MS_PER_DAY);
}

function taskOffset(timelineStart: Date, taskStart: Date, colW: number): number {
  const days = daysBetween(timelineStart, taskStart);
  return Math.max(0, days * colW);
}

function taskWidth(task: GanttTask, colW: number): number {
  const duration = daysBetween(task.start, task.end) + 1;
  return Math.max(colW, duration * colW); // minimum 1 column wide
}

The Math.max(0, ...) in taskOffset is non-negotiable. Without it, tasks that start before your visible timeline window will render with a negative left position and break the layout. Same logic applies to taskWidth — a zero-width bar is invisible and confusing. Always enforce a minimum of one column.

One thing that bites people: JavaScript's Date object uses local timezone for .getTime() when constructed from strings like "2026-11-17". If your server sends UTC dates and your user is in UTC-5, a task starting on Nov 17 might render on Nov 16. Parse dates explicitly with new Date(Date.UTC(y, m, d)) or use a tiny utility like date-fns/parseISO if you already have it.

Building the Gantt Component

Here's the full component. It generates a day-by-day header, then renders each task row with an absolutely-positioned bar. The outer container is overflow-x-auto so the timeline scrolls horizontally on small screens.

import React, { useMemo } from "react";
import type { GanttProps } from "./types";

export function GanttChart({
  tasks,
  startDate,
  endDate,
  columnWidth = 32,
}: GanttProps) {
  const rangeStart = startDate ?? tasks.reduce(
    (min, t) => t.start < min ? t.start : min,
    tasks[0].start
  );
  const rangeEnd = endDate ?? tasks.reduce(
    (max, t) => t.end > max ? t.end : max,
    tasks[0].end
  );

  const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
  const totalWidth = totalDays * columnWidth;

  const days = useMemo(() => {
    return Array.from({ length: totalDays }, (_, i) => {
      const d = new Date(rangeStart);
      d.setDate(d.getDate() + i);
      return d;
    });
  }, [rangeStart, totalDays]);

  return (
    <div className="overflow-x-auto border border-white/10 rounded-xl">
      {/* Header */}
      <div
        className="flex border-b border-white/10 bg-white/5"
        style={{ width: totalWidth }}
      >
        {days.map((d) => (
          <div
            key={d.toISOString()}
            className="shrink-0 text-center text-xs text-white/40 py-2 border-r border-white/5"
            style={{ width: columnWidth }}
          >
            {d.getDate()}
          </div>
        ))}
      </div>

      {/* Task rows */}
      {tasks.map((task) => (
        <div
          key={task.id}
          className="relative flex items-center border-b border-white/5 h-12"
          style={{ width: totalWidth }}
        >
          {/* Day grid lines */}
          {days.map((d) => (
            <div
              key={d.toISOString()}
              className="absolute top-0 bottom-0 border-r border-white/5"
              style={{ left: daysBetween(rangeStart, d) * columnWidth }}
            />
          ))}

          {/* Task bar */}
          <div
            className="absolute h-6 rounded-md flex items-center px-2 text-xs font-medium text-white truncate"
            style={{
              left: taskOffset(rangeStart, task.start, columnWidth),
              width: taskWidth(task, columnWidth),
              background: task.color ?? "rgba(139,92,246,0.75)",
              backdropFilter: "blur(4px)",
            }}
            title={task.label}
          >
            {task.label}
          </div>
        </div>
      ))}
    </div>
  );
}

The background: rgba(139,92,246,0.75) default gives you a readable purple with slight transparency — it looks good over the grid lines and reads clearly in both light and dark contexts. If you want bars to adapt to your design system, wire up the color prop to whatever token system you're using. For glassmorphism-style UIs, bump the blur to blur(8px) and drop the opacity to 0.5 — there's a deep dive on that effect in what is glassmorphism.

Adding a Fixed Label Column

The component above works, but on a real project you need task labels visible even when the timeline scrolls. The fix is a sticky left column. Wrap the whole chart in a flex container: the label column is sticky left-0 z-10, and the timeline scrolls independently to its right.

<div className="flex">
  {/* Sticky labels */}
  <div className="sticky left-0 z-10 bg-neutral-950 border-r border-white/10 w-48 shrink-0">
    <div className="h-10 border-b border-white/10" /> {/* header spacer */}
    {tasks.map((task) => (
      <div
        key={task.id}
        className="h-12 flex items-center px-3 border-b border-white/5 text-sm text-white/70 truncate"
      >
        {task.label}
      </div>
    ))}
  </div>

  {/* Scrollable timeline */}
  <div className="overflow-x-auto flex-1">
    {/* ...GanttChart content without labels... */}
  </div>
</div>

The bg-neutral-950 on the sticky column is load-bearing. Without a solid background, the scrolling timeline bleeds through the label column and the text becomes unreadable. You need the same background color as your page. If your app supports light mode, swap this to a conditional class via your theme system.

This two-column layout pattern comes up a lot in data-heavy UIs. If you're building anything with notification feedback on top of this — say, a task completion toast — check out how Empire UI handles toast notifications in React for a drop-in solution.

Today Marker and Weekend Highlighting

A Gantt without a "today" line is half-useful. Add a thin vertical marker at the current date. Calculate its left offset exactly like you do for tasks — daysBetween(rangeStart, today) * columnWidth. Render it as an absolute div with width: 2px and a bright color like #f43f5e so it's unmistakable.

Weekend highlighting is similarly straightforward. In your day loop, check d.getDay() === 0 || d.getDay() === 6. If true, render a background stripe on that column: background: rgba(255,255,255,0.03). Subtle enough not to distract, obvious enough that users understand why some cells look different. The 8px gap you naturally get from py-2 on the header cells keeps the stripe from running edge-to-edge and looking harsh.

Do you actually need drag-to-reschedule? That's where most teams go wrong — they add drag behavior too early and end up with a mess of mouse event handlers before the basic rendering even works. Get the static view right first. Drag interactions can always be layered on with @dnd-kit/core later without touching the core layout logic.

Performance: Don't Render 365 Day Columns

If you're rendering a full-year timeline at 32px per column, that's 11,680px wide and 365 day-divider elements per row. For 20 tasks you're looking at 7,300 DOM nodes just for the grid lines. The browser handles it fine initially, but scroll performance degrades fast.

The fix is windowing. Only render the day columns that are actually visible in the scroll viewport. You can implement a basic version with an IntersectionObserver or by tracking the scroll position and slicing the days array. A 1200px viewport at 32px per column means you only ever need to render about 40 columns — the rest are just empty space.

For timeline data that changes frequently (live project updates via WebSocket, say), wrap the task computation in useMemo and the day array in a separate memo. The date math is cheap, but re-running it on every keystroke in a parent form is wasteful. If you're chasing bundle size, the entire component with helpers comes in under 4KB minified — no tree-shaking gymnastics needed. For more patterns on keeping React fast, react performance guide covers the memoization traps worth avoiding.

Putting It All Together in a Real Project

Here's what a realistic usage looks like with static data:

import { GanttChart } from "@/components/GanttChart";

const tasks = [
  {
    id: "1",
    label: "Discovery & Scoping",
    start: new Date(2026, 10, 1),
    end: new Date(2026, 10, 7),
    color: "rgba(99,102,241,0.8)",
  },
  {
    id: "2",
    label: "Design System Setup",
    start: new Date(2026, 10, 5),
    end: new Date(2026, 10, 14),
    color: "rgba(236,72,153,0.8)",
  },
  {
    id: "3",
    label: "API Integration",
    start: new Date(2026, 10, 10),
    end: new Date(2026, 10, 21),
    color: "rgba(34,197,94,0.8)",
  },
  {
    id: "4",
    label: "QA & Staging Deploy",
    start: new Date(2026, 10, 18),
    end: new Date(2026, 10, 28),
    color: "rgba(251,146,60,0.8)",
  },
];

export default function ProjectPage() {
  return (
    <div className="p-8 bg-neutral-950 min-h-screen">
      <h1 className="text-2xl font-bold text-white mb-6">Q4 Sprint Timeline</h1>
      <GanttChart tasks={tasks} columnWidth={40} />
    </div>
  );
}

That columnWidth={40} gives you slightly more breathing room per day — useful when task labels are long. The rgba colors with 0.8 opacity mean bars that overlap slightly remain distinguishable, which matters when you start grouping tasks by phase. You can extend the component to render a group header row by checking task.group and inserting a separator row whenever the group name changes.

The full component is around 120 lines of TypeScript with no external dependencies beyond React and Tailwind. That's the whole point — your bundle doesn't bloat, your design system stays intact, and you control every pixel. When you're ready to add more interactive data components alongside this, tailwind vs css modules is worth reading to decide your long-term styling approach.

FAQ

Can this Gantt component handle thousands of tasks without freezing?

Not out of the box. The current implementation renders every task row and every day column in the DOM. For more than ~100 tasks you'll want to add virtual scrolling on the Y axis — react-virtual or TanStack Virtual work well for this. Windowing the X axis (visible day columns only) handles the horizontal scale problem. Together, those two changes get you to thousands of rows without issues.

How do I add drag-to-resize task bars?

Use @dnd-kit/core with a custom DragOverlay. Attach a drag handle to the right edge of each bar (a 6px-wide absolutely positioned div with cursor: col-resize). On drag, calculate the new end date from the pixel delta divided by columnWidth, round to the nearest integer, then call your state update. Keep the original dates in a ref during drag so you're not recalculating from stale state.

Why are my task bars rendering on the wrong day?

Almost always a timezone issue. JavaScript Date objects constructed from ISO strings like '2026-11-17' are parsed as UTC midnight, but .getTime() returns milliseconds in local time. The safest fix is to always construct dates with new Date(Date.UTC(year, month - 1, day)) and do all comparisons in UTC. If your input is already Date objects from a date picker, this usually isn't an issue.

How do I make the Gantt work with React Query or SWR data?

The component just accepts a GanttTask[] prop, so it works with any data source. Fetch with useQuery or useSWR, map the response to GanttTask objects (converting date strings to Date objects in the mapping step), and pass the result to the component. Add a loading skeleton at the container level — a few gray bars at fixed widths — to avoid layout shift while data loads.

Can I export the Gantt as a PNG or PDF for stakeholder reports?

Yes. The cleanest approach is html2canvas on the chart container. Call html2canvas(ref.current, { scale: 2 }) to get a 2x resolution image that looks sharp in reports. For PDF, pipe the canvas output into jsPDF. One catch: html2canvas doesn't handle CSS backdrop-filter well — if you're using blur effects on the bars, replace them with solid colors before capturing, then restore afterward.

Is there a way to show task dependencies (arrows between bars)?

You'll need an SVG overlay positioned absolute over the chart. For each dependency pair, calculate the start and end x/y coordinates from the task positions and draw an SVG path with a marker-end arrowhead. The math is straightforward: the source x is taskOffset + taskWidth, the target x is the dependent task's taskOffset. Keep the SVG in a separate layer (z-index above the grid, below task labels) so it doesn't interfere with interactions.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Charts in React with Recharts: Line, Bar, Pie, ResponsiveuseReducer Patterns: Complex State Without a State ManagerGlassmorphism Analytics Chart: Data Viz with Blur BackgroundsSortable Table in React: Column Sort, Filter, Pagination