DEV Community

Cover image for Creating a Gantt chart with vanilla JavaScript
Bryntum for Bryntum

Posted on • Updated on • Originally published at bryntum.com

Creating a Gantt chart with vanilla JavaScript

A Gantt chart is a really handy project management tool for coordinating complex schedules for large teams or multiple teams. While many companies use Excel to build Gantt charts, using a Gantt chart that is JavaScript-based has a couple of advantages over spreadsheets, for example, you can:

  • Easily integrate your chart into existing project management dashboards.
  • Share your chart online.
  • Customize your chart to suit your needs.

Gantt charts are surprisingly complex, so if you’re looking to build your own for production use, this guide will only show you the first steps. If you instead prefer to buy a battle-tested off-the-shelf version, take a look at our commercial Bryntum Gantt Chart.

Let’s take a look at how you can make a basic drag-and-drop Gantt chart component using vanilla JavaScript. You can find all the code used below in our GitHub repository.

The basic Gantt chart we will create will have the following features:

  • The user can select the planning time period by selecting a start date and an end date.
  • We’ll be able to add, delete, and edit tasks.
  • Task durations can be added and deleted, and we’ll be able to edit them using drag and drop.

You can see a live example of the final Gantt chart here. Once you're done, you'll have a Gantt chart that looks like the one shown below.

Getting started

We’ll create our Gantt chart using only HTML, CSS and JavaScript.

The CSS will be defined in JavaScript files. We will use JavaScript modules to split the code into separate modules that can be imported and exported, so we’ll need a local HTTP server to run the code. We need to do this because JavaScript modules follow the same-origin policy, which means that you cannot import modules from your file system by default. To get a local server with live reload, you can install the npm Live Server package. If you are using VS Code, you can install the Live Server extension.

If you want to do this tutorial in the browser with minimal setup, you can make use of Replit, which is an online integrated development environment (IDE). You can check their documentation for how to create a project.

Let’s get an overview of how the app will be structured and create the basic app and the building blocks of our component.

Folder structure

Everything that can be exported using the export statement is considered a component. The index.html file will only import a single JavaScript file, script.js, which is a module. This will be the single entry point to our application. The Gantt chart component is imported into script.js.

We will break the Gantt component up into four separate components:

  • The main module, ganttChart.js,
  • A module for the HTML, htmlContent.js,
  • A module for the CSS, style.js, and
  • A module for some utility functions, utils.js.

A utility function is a generic function that performs a task that we could reuse in another project. We will create utility functions for date calculations and date formatting.

Create the following files and folders:

|__ index.html
|__ script.js
|__ components 
    |__ ganttChart
        |__ ganttChart.js
        |__ htmlContent.js
        |__ style.js
        |__ utils.js</code></pre>
Enter fullscreen mode Exit fullscreen mode

Basic app structure

Before we make the Gantt chart component, let’s set up the app that will render the Gantt chart. Add the following lines to the index.html file:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="./script.js" type="module" defer></script>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Montserrat:wght@200&display=swap"
      rel="stylesheet"
    />
    <title>Creating a Gantt Chart component with Vanilla JavaScript</title>
  </head>
  <body>
    <div role="gantt-chart"></div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

The type=module attribute of the script.js file that we embed into our HTML document allows us to use module features in our script. In the <body> tag there is a single <div> with a role of "gantt-chart". We use this to identify the HTML element that will be used to create our Gantt chart component.

Now add the following lines to the script.js file:

import { GanttChart } from "./components/ganttChart/ganttChart.js";

// can have multiple instances of Gantt chart
document.addEventListener("DOMContentLoaded", () => {
  // get data - could get from server
  const tasks = [
    { id: 1, name: "Task 1" },
    { id: 2, name: "Task 2" },
    { id: 3, name: "Task 3" },
    { id: 4, name: "Task 4" },
    { id: 5, name: "Task 5" },
    { id: 6, name: "Task 6" },
    { id: 7, name: "Task 7" },
    { id: 8, name: "Task 8" },
  ];

  const taskDurations = [
    {
      id: "1",
      start: new Date("2022/1/2"),
      end: new Date("2022/1/8"),
      task: 1,
    },
    {
      id: "2",
      start: new Date("2022/1/10"),
      end: new Date("2022/1/15"),
      task: 2,
    },
    {
      id: "3",
      start: new Date("2022/1/11"),
      end: new Date("2022/1/18"),
      task: 4,
    },
  ];

  const ganttCharts = document.querySelectorAll("[role=gantt-chart]");
  ganttCharts.forEach(ganttChart => {
    new GanttChart(ganttChart, tasks, taskDurations);
  });
});
Enter fullscreen mode Exit fullscreen mode

We import GanttChart, which will be the constructor function that creates our Gantt chart. Once the HTML content is loaded and parsed, we create our Gantt chart. You can create multiple instances of the Gantt chart if you need to.

We hard code the tasks and taskDurations data objects for the Gantt chart. You could get this data from your backend using an HTTP request. The taskDurations indicate the duration of a task. They have a task property, which specifies the task it is associated with, as indicated by the task id property. They also have start and end date properties, which indicate the task duration. These are JavaScript Date objects.

For each HTML element on our page that has a role of "gantt-chart", we create a Gantt chart using the GanttChart constructor function. We pass in the ganttChart DOM element object, as well as the task and task duration data. This function creates the content of the Gantt chart component.

HTML structure

Now let’s describe the Gantt chart component. The following diagram describes the Gantt chart layout. The numbered blocks represent the <div> containers for each part of the Gantt chart. They each have a unique id.

Image description

Let’s add the HTML needed for our Gantt Chart component. Add the following lines to the htmlContent.js file:

import { cssStyles } from "./style.js";

export function createHtmlContentFragment() {
  const content = `
  <style>
    ${cssStyles()}
  </style>

  <div id="gantt-container">

      <div class="title">
        <h1> Gantt Tracker</h1>
      </div>

      <div id="gantt-grid-container">
        <div id="gantt-grid-container__tasks"></div>
        <div id="gantt-grid-container__time"></div>
      </div>

      <div id="add-forms-container">

          <div class="inner-form-container">

                <form id="add-task">
                  <h1>Add Task</h1>                  
                  <div><input placeholder="Add task name" type="text"></div>
                  <button type="submit">
                    Add
                  </button>
                </form>



                <form id="add-task-duration">
                    <h1>Add Task duration</h1>
                    <div class="inner-form-container">
                        <fieldset >
                          <label for="select-task">Which task?</label>
                          <select id="select-task" name="select-task"></select>
                        </fieldset>
                        <fieldset id="date" >
                          <label for="start-date">Start date:</label>
                          <input type="date" id="start-date" name="start-date"
                              value="2022-01-01"
                              min="2022-01-01" max="2050-12-31"
                          >

                          <label for="end-date">End date:</label>
                          <input type="date" id="end-date" name="end-date"
                            value="2022-01-03"
                            min="2022-01-01" max="2050-12-31"
                          >
                        </fieldset>
                    </div>
                    <button>
                      Add
                    </button>
                </form>

            </div>

          <div class="tracker-period">                 
              <h1 >Tracker Period</h1>
              <div>
                  <div id="settings">
                      <fieldset id="select-from">
                          <legend>From</legend>
                          <select id="from-select-month" name="from-select-month"></select>
                          <select id="from-select-year" name="from-select-year"></select>
                      </fieldset>

                      <fieldset id="select-to">
                          <legend>To</legend>
                          <select id="to-select-month" name="to-select-month"></select>
                          <select id="to-select-year" name="to-select-year"></select>
                      </fieldset>
                  </div>
              </div>
           </div>
      <div>
  </div>
  `;

    // turn the HTML string into a document fragment
    const contentFragment = document
        .createRange()
        .createContextualFragment(content);
    return contentFragment;
}
Enter fullscreen mode Exit fullscreen mode

We import our CSS styles as a string from style.js, which we will define in the next section. We create an HTML string and store it in the content variable. We will go through the specifics of the HTML and CSS as we go through the tutorial. We convert the HTML string into DOM nodes by creating a DocumentFragment that is stored in the contentFragment variable. This document fragment is made up of DOM nodes, and is separate from the document DOM tree. It will not be displayed on the page until we add it to the document DOM. The createHtmlContentFragment function returns the contentFragment variable. Later, we will add this document fragment as a child to the Gantt chart DOM element to put it on the page.

Adding styling with CSS

Now let’s add the basic styling for our Gantt chart component. Add the following lines to the styles.js file:

export function cssStyles() {
  const CELL_HEIGHT = 40;
  const outlineColor = "#e9eaeb";

  return `
    * {
        box-sizing: border-box;
        margin: 0;
    }

    html {
      font-family: 'Montserrat', sans-serif;
    }

    h1 {
      font-size: 1.5rem;
    }

    fieldset {
        border:none;
        padding: 0.5rem;
    }

    fieldset label {
      margin-right: 10px;
    }

    #date > label:nth-child(3) {
      margin-left: 10px;
    }

    form button {
      width: 70px;
      height: 50px;
    }

    select {
      font-size: 1.2rem;
      padding: 0.2rem 0.2rem;
      box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.05);
    }

    input {
      font-family: 'Montserrat', sans-serif;
      height: 100%;
      padding: 10px 5px;
      border: 1px solid #EDEDED;
      border-radius: 5px;
      transition: 0.2s ease-out;
    }

    input:focus {
      outline-color: #0095e4;
    }

    button:hover {
      cursor: pointer;
    }

    .title {
      text-align: center; 
      margin-bottom: 20px
    }

    #gantt-container {
      padding: 1rem;
    }

    #gantt-grid-container {
        display: grid;
        grid-template-columns: 150px 1fr;
        outline: 2px solid ${outlineColor};
    }

    #gantt-grid-container, #settings > fieldset,
    #add-task, #add-task-duration  {
      border-radius: 5px;
      box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.05);
    }

    #gantt-grid-container__time {
      display: grid;
      overflow-x: auto;
    }

    .gantt-task-row {
        outline: 0.5px solid ${outlineColor};
        text-align: center;
        height: ${CELL_HEIGHT}px;
        // expand across whole grid
        grid-column: 1/-1;
        width: 100%;
        border: none;
    }

    .gantt-task-row button {
      border: none;
      height: ${CELL_HEIGHT}px;
    }

    .gantt-task-row input {
      width: 127px;
      border: none;
      outline: none;
      background: none;
    }

    #gantt-grid-container__tasks button {
      color: #ef5350;
      background: none;
      border-radius: 5px;
      height: 20px;
      transition: all 0.2s ease;
    }

    #gantt-grid-container__tasks button:focus {
      outline: none;
      transform: scale(1.3);
    }

    #gantt-grid-container__tasks .gantt-task-row {
      padding: 2px 0;
    }

    .gantt-time-period {
        display: grid;
        grid-auto-flow: column;
        grid-auto-columns: minmax(30px, 1fr);
        text-align: center;
        height: ${CELL_HEIGHT}px;
    }

    .gantt-time-period span {
      margin: auto;
    }

    .gantt-time-period-cell-container {
      grid-column: 1/-1;
      display: grid;
    }

    .gantt-time-period-cell {
      position: relative;
      outline: 0.5px solid ${outlineColor};
    }

    .day {
      color: #bbb;
    }

    #settings {
        display: flex;
        align-items: center;
        font-size: 14px;
        padding-bottom: 0.5rem;
    }

    .taskDuration {
      position: absolute;
      height: ${CELL_HEIGHT}px;
      z-index: 1;
      background: linear-gradient(90deg, rgba(158,221,255,1) 0%, rgba(0,149,228,1) 100%);
      border-radius: 5px;
      box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.05);
      cursor: move;
    }


    .taskDuration:focus {
      outline: 1px solid black;
    }

    .dragging {
      opacity: 0.5;
    }

    #add-forms-container {
      display: flex;
      flex-wrap: wrap;
      padding: 1rem 0;
      justify-content: space-between;
    }

    #add-forms-container form {
      padding: 1rem;
    }

    #add-forms-container form > * {
      display: flex;
      align-items: center;
    }

    #add-forms-container input {
      height: ${CELL_HEIGHT}px;
    }

    #add-task, #add-task-duration {
      margin-right: 10px;
      margin-bottom: 10px;
    }

    #add-forms-container button:hover,
    #add-forms-container button:focus {
      opacity: 0.85;
    }

    input[type=text], select {
      padding: 5px 7px;
      margin: 8px 0;
      display: inline-block;
      border: 1px solid #ccc;
      border-radius: 4px;
      box-sizing: border-box;
      font-family: 'Montserrat', sans-serif;
      font-size: 13px;
    }


    #add-forms-container button {
      color: white;
      background: #2ade3c;
      font-size: 1.1rem;
      box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.05);
      padding: 0.5rem 1rem;
      border: 0;
      border-radius: 5px;
      transition: all 0.3s ease;
      font-family: 'Montserrat', sans-serif;
      font-size: 13px;
    }

    .tracker-period {
      padding: 1rem;
    }

    .tracker-period h1{
      margin-bottom: 16px;
    }

    .inner-form-container {
      display: flex; 
      flex-direction: row
    }

    .inner-form-container h1 {
      margin-bottom: 0.5rem;
    }  
  `;
}
Enter fullscreen mode Exit fullscreen mode

We export a cssStyles function from this module. It returns a CSS string that we import and add to the HTML string in htmlContent.js. The variable ROW_HEIGHT allows us to easily change the height of the rows in the Gantt Chart.

Adding utility functions

Next, we’ll add some utility functions for our Gantt chart component. Add the following lines to the utils.js file:

export function monthDiff(firstMonth, lastMonth) {
  let months;
  months = (lastMonth.getFullYear() - firstMonth.getFullYear()) * 12;
  months -= firstMonth.getMonth();
  months += lastMonth.getMonth();
  return months <= 0 ? 0 : months;
}

export function dayDiff(startDate, endDate) {
  const difference = endDate.getTime() - startDate.getTime();
  const days = Math.ceil(difference / (1000 * 3600 * 24)) + 1;
  return days;
}

export function getDaysInMonth(year, month) {
  return new Date(year, month, 0).getDate();
}

export function getDayOfWeek(year, month, day) {
  const daysOfTheWeekArr = ["M", "T", "W", "T", "F", "S", "S"];
  const dayOfTheWeekIndex = new Date(year, month, day).getDay();
  return daysOfTheWeekArr[dayOfTheWeekIndex];
}

export function createFormattedDateFromStr(year, month, day) {
  let monthStr = month.toString();
  let dayStr = day.toString();

  if (monthStr.length === 1) {
    monthStr = `0${monthStr}`;
  }
  if (dayStr.length === 1) {
    dayStr = `0${dayStr}`;
  }
  return `${year}-${monthStr}-${dayStr}`;
}

export function createFormattedDateFromDate(date) {
  let monthStr = (date.getMonth() + 1).toString();
  let dayStr = date.getDate().toString();

  if (monthStr.length === 1) {
    monthStr = `0${monthStr}`;
  }
  if (dayStr.length === 1) {
    dayStr = `0${dayStr}`;
  }
  return `${date.getFullYear()}-${monthStr}-${dayStr}`;
}
Enter fullscreen mode Exit fullscreen mode

These functions will be used for date calculations and date formatting. We will go through the specifics of the functions as we use them in the tutorial.

Creating the Gantt chart component

Now that we have the basic structure of our app set up, we can start creating the Gantt chart. We will only work in the ganttChart.js file from now on. Add the following to the ganttChart.js file:

import { createHtmlContentFragment } from "./htmlContent.js";
import {
  monthDiff,
  dayDiff,
  getDaysInMonth,
  getDayOfWeek,
  createFormattedDateFromStr,
  createFormattedDateFromDate,
} from "./utils.js";

export function GanttChart(ganttChartElement, tasks, taskDurations) {
  const months = [
    "Jan",
    "Feb",
    "Mar",
    "Apr",
    "May",
    "Jun",
    "Jul",
    "Aug",
    "Sep",
    "Oct",
    "Nov",
    "Dec",
  ];
  const contentFragment = createHtmlContentFragment();
  let taskDurationElDragged;

  ganttChartElement.appendChild(contentFragment);
}
Enter fullscreen mode Exit fullscreen mode

We import all the functions we need from the other modules. The GanttChart constructor function takes in the Gantt chart DOM element, tasks, and task durations as arguments. We also create some variables that we will use later, including a contentFragment variable that is the document fragment of our HTML content from htmlContent.js. We then append this to the ganttChartElement so that it is added to the document DOM.

Now let’s add the month and year options for our “From” and “To” <select> elements. Add the following lines just above ganttChartElement.appendChild(contentFragment);:

// add date selector values
  let monthOptionsHTMLStrArr = [];
  for (let i = 0; i < months.length; i++) {
    monthOptionsHTMLStrArr.push(`<option value="${i}">${months[i]}</option>`);
  }

  const years = [];
  for (let i = 2022; i <= 2050; i++) {
    years.push(`<option value="${i}">${i}</option>`);
  }

  const fromSelectYear = contentFragment.querySelector("#from-select-year");
  const fromSelectMonth = contentFragment.querySelector("#from-select-month");
  const toSelectYear = contentFragment.querySelector("#to-select-year");
  const toSelectMonth = contentFragment.querySelector("#to-select-month");

  fromSelectMonth.innerHTML = `
      ${monthOptionsHTMLStrArr.join("")}
  `;
  fromSelectYear.innerHTML = `
      ${years.join("")}
  `;
  toSelectMonth.innerHTML = `
      ${monthOptionsHTMLStrArr.join("")}
  `;
  toSelectYear.innerHTML = `
      ${years.join("")}
  `;
Enter fullscreen mode Exit fullscreen mode

We use for loops to create string arrays of the <option> tags that we need. We then find their <select> tags in the document fragment stored in the contentFragment variable and add the options to their respective <select> tags. If you run your local server now, you should see the following basic skeleton of our app in your browser window:

Creating the Gantt chart grid

Let’s create the Gantt chart grid that will display our tasks, a timeline, and our task durations. The diagram below has six numbered arrows that show the order in which the columns, rows, and task duration elements will be created. The creation of each part of the grid will be performed with functions. These six functions will be called in the createGrid function, which we will soon create.

Image description

Let’s now select the tasks and time period containers from the content fragment, create the createGridfunction, and call it. Add the following lines just above ganttChartElement.appendChild(contentFragment);:

// create grid
  const containerTasks = contentFragment.querySelector(
    "#gantt-grid-container__tasks"
  );
  const containerTimePeriods = contentFragment.querySelector(
    "#gantt-grid-container__time"
  );

  const addTaskForm = contentFragment.querySelector("#add-task");
  const addTaskDurationForm =
    contentFragment.querySelector("#add-task-duration");
  const taskSelect = addTaskDurationForm.querySelector("#select-task");

  function createGrid() {
    const startMonth = new Date(
      parseInt(fromSelectYear.value),
      parseInt(fromSelectMonth.value)
    );
    const endMonth = new Date(
      parseInt(toSelectYear.value),
      parseInt(toSelectMonth.value)
    );
    const numMonths = monthDiff(startMonth, endMonth) + 1;

    // clear first each time it is changed
    containerTasks.innerHTML = "";
    containerTimePeriods.innerHTML = "";

    // createTaskRows();
    // createMonthsRow(startMonth, numMonths);
    // createDaysRow(startMonth, numMonths);
    // createDaysOfTheWeekRow(startMonth, numMonths);
    // createTaskRowsTimePeriods(startMonth, numMonths);
    // addTaskDurations();
  }

  createGrid();
Enter fullscreen mode Exit fullscreen mode

We first get the tasks and time periods containers from the document fragment, contentFragment. We also get the “add task” and “add task duration” form elements, as well as the “add task duration” <select> element. We will use these elements later.

We then define the createGrid function. In this function, we determine the planning time period based on the values of the “From” and “To” <select> values. We then use our utility function monthDiff to determine how many months the planning time period extends over. If the “From” date is greater than the “To” date, the monthDiff function returns zero.

Each time createGrid is called, we clear the containerTasks and containerTimePeriods inner HTML. We do this so that when we update the tasks or task durations and re-create the grid by calling createGrid, the old contents that showed the previous state are removed. We then call the six functions required to create the columns, rows, and task duration elements. The functions are commented out for now. As we define each function, you can uncomment the function call to see its effect on the grid creation. After defining the createGrid function, we call it for its initial render.

Let’s define the six functions called in createGrid now, one by one.

Creating task rows

Add the following function below ganttChartElement.appendChild(contentFragment);:

function createTaskRows() {
    const emptyRow = document.createElement("div");
    emptyRow.className = "gantt-task-row";
    // first 3 rows are empty
    for (let i = 0; i < 3; i++) {
      containerTasks.appendChild(emptyRow.cloneNode(true));
    }

    // add task select values
    let taskOptionsHTMLStrArr = [];

    tasks.forEach((task) => {
      const taskRowEl = document.createElement("div");
      taskRowEl.id = task.id;
      taskRowEl.className = "gantt-task-row";

      const taskRowElInput = document.createElement("input");
      taskRowEl.appendChild(taskRowElInput);
      taskRowElInput.value = task.name;

      taskOptionsHTMLStrArr.push(
        `<option value="${task.id}">${task.name}</option>`
      );

      // add delete button
      const taskRowElDelBtn = document.createElement("button");
      taskRowElDelBtn.innerText = "";
      taskRowEl.appendChild(taskRowElDelBtn);

      containerTasks.appendChild(taskRowEl);
    });
    taskSelect.innerHTML = `
      ${taskOptionsHTMLStrArr.join("")}
    `;
  }
Enter fullscreen mode Exit fullscreen mode

First, we create three empty rows. We then create a row for each task. Each task row has two children: an <input> element and a delete button. The value of the input is the task name. We’ve used an input here so that it can be edited easily. We also add the tasks as options for the task <select> element in the “add task” form.

You will be able to see the populated task rows in your app now. Make sure to uncomment the createTaskRows() function call in the createGrid function. We still need to make the task edit and delete functional.

Adding months

Let’s add function two now. Add the following createMonthsRow function below the createTaskRows function:

function createMonthsRow(startMonth, numMonths) {
    containerTimePeriods.style.gridTemplateColumns = `repeat(${numMonths}, 1fr)`;

    let month = new Date(startMonth);

    for (let i = 0; i < numMonths; i++) {
      const timePeriodEl = document.createElement("div");
      timePeriodEl.className = "gantt-time-period day";
      // to center text vertically
      const timePeriodElSpan = document.createElement("span");
      timePeriodElSpan.innerHTML =
        months[month.getMonth()] + " " + month.getFullYear();
      timePeriodEl.appendChild(timePeriodElSpan);
      containerTimePeriods.appendChild(timePeriodEl);
      month.setMonth(month.getMonth() + 1);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Make sure to uncomment the createMonthRow() function call in the createGrid function.

The startMonth and numMonths arguments determine how many month columns there will be and where the months start. The number of columns is dynamic because the user can change the time period. Each time a time period is selected, the number of columns in the containerTimePeriods container is determined based on the number of months in the time period. We use the CSS property grid-template-columns to make each month’s column take up one fraction of the container width so that the container is divided evenly into columns for each month.

For each month, we create a new time period row. We add a <span> element to it as a child. This span element is centered vertically in the row and contains the label for the month and year.

You will be able to see a single row with the text “Jan 2022” in your app now. However, changing the time period date range will not update the months. We need to add event listeners to listen for a change in the date range so that we can dynamically update the grid.

Add the following event listeners below the createMonthsRow function:

// re-create Grid if year / month selection changes
  fromSelectYear.addEventListener("change", createGrid);
  fromSelectMonth.addEventListener("change", createGrid);
  toSelectYear.addEventListener("change", createGrid);
  toSelectMonth.addEventListener("change", createGrid);
Enter fullscreen mode Exit fullscreen mode

We listen for the "change" event on the time period <select> tags to check if the year and month ranges have changed. If they have, we re-create the grid by calling the createGrid function.

You will now be able to see how the time period rows update when the planning time period is changed. For large time periods, there is a horizontal scroll in the grid container with the ID "gantt-grid-container__time". If an invalid time period is selected, for example from Jan 2023 to Jan 2022, only the first month of the “From” date will be displayed.

Image description

Adding days of the month

Let’s add function three now. Add the following createDaysRow function above the "change" event listeners:

function createDaysRow(startMonth, numMonths) {
    let month = new Date(startMonth);

    for (let i = 0; i < numMonths; i++) {
      const timePeriodEl = document.createElement("div");
      timePeriodEl.className = "gantt-time-period";
      containerTimePeriods.appendChild(timePeriodEl);

      // add days as children
      const numDays = getDaysInMonth(month.getFullYear(), month.getMonth() + 1);

      for (let i = 1; i <= numDays; i++) {
        let dayEl = document.createElement("div");
        dayEl.className = "gantt-time-period";
        const dayElSpan = document.createElement("span");
        dayElSpan.innerHTML = i;
        dayEl.appendChild(dayElSpan);
        timePeriodEl.appendChild(dayEl);
      }

      month.setMonth(month.getMonth() + 1);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Make sure to uncomment the createDaysRow() function call in the createGrid function.

This function loops through the number of months provided as an argument and creates a time period element, timePeriodEl, for each month. We create a Date object for the start month, which is stored in the month variable. In each iteration through the number of months, we increase the date of the month variable by one month using the setMonth method of the Date object.

We have a nested loop for creating the days of each month. The number of days in each month is calculated using our getDaysInMonth utility function. We then loop through each day of the month and create a day element <div> tag called dayEl. We create a <span> element that displays the day value. We add it as a child of the day element. We then add the day element as a child to the time period element for the given month.

If you view your app now, you will see that the days of the month cells are centered vertically. This is because the container with the "gantt-grid-container__time" ID has its display property set to grid. We will need this later for the drag-and-drop container that we will add inside of the grid. Once we add all of the rows, the rows will be aligned correctly.

Adding days of the week

Let’s add the fourth function. Add the following createDaysOfTheWeekRow function above the "change" event listeners:

function createDaysOfTheWeekRow(startMonth, numMonths) {
    let month = new Date(startMonth);

    for (let i = 0; i < numMonths; i++) {
      const timePeriodEl = document.createElement("div");
      timePeriodEl.className = "gantt-time-period";
      containerTimePeriods.appendChild(timePeriodEl);

      // add days of the week as children
      const currYear = month.getFullYear();
      const currMonth = month.getMonth() + 1;
      const numDays = getDaysInMonth(currYear, currMonth);

      for (let i = 1; i <= numDays; i++) {
        let dayEl = document.createElement("div");
        dayEl.className = "gantt-time-period";
        const dayOfTheWeek = getDayOfWeek(currYear, currMonth - 1, i - 1);
        const dayElSpan = document.createElement("span");
        dayElSpan.innerHTML = dayOfTheWeek;
        dayEl.appendChild(dayElSpan);
        timePeriodEl.appendChild(dayEl);
      }

      month.setMonth(month.getMonth() + 1);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Make sure to uncomment the createDaysOfTheWeekRow() function call in the createGrid function.

This function is similar to the previous createDaysRow function, the difference being that we add the days of the week instead of the day of the month number to the <span> element. We use our dayOfTheWeek utility function to determine the day of the week, which is displayed as a capitalized string of the first letter of its name. We use the current year, month, and day to calculate which day of the week it is. The dayOfTheWeek utility function makes use of the Date method getDay to determine this.

Creating a time period container

Let’s add the fifth function. Add the following createTaskRowsTimePeriods function above the "change" event listeners:

function createTaskRowsTimePeriods(startMonth, numMonths) {
    const dayElContainer = document.createElement("div");
    dayElContainer.className = "gantt-time-period-cell-container";
    dayElContainer.style.gridTemplateColumns = `repeat(${numMonths}, 1fr)`;

    containerTimePeriods.appendChild(dayElContainer);

    tasks.forEach((task) => {
      let month = new Date(startMonth);
      for (let i = 0; i < numMonths; i++) {
        const timePeriodEl = document.createElement("div");
        timePeriodEl.className = "gantt-time-period";
        dayElContainer.appendChild(timePeriodEl);

        const currYear = month.getFullYear();
        const currMonth = month.getMonth() + 1;

        const numDays = getDaysInMonth(currYear, currMonth);

        for (let i = 1; i <= numDays; i++) {
          let dayEl = document.createElement("div");
          dayEl.className = "gantt-time-period-cell";

          // color weekend cells differently
          const dayOfTheWeek = getDayOfWeek(currYear, currMonth - 1, i - 1);
          if (dayOfTheWeek === "S") {
            dayEl.style.backgroundColor = "#f7f7f7";
          }

          // add task and date data attributes
          const formattedDate = createFormattedDateFromStr(
            currYear,
            currMonth,
            i
          );
          dayEl.dataset.task = task.id;
          dayEl.dataset.date = formattedDate;
          timePeriodEl.appendChild(dayEl);
        }

        month.setMonth(month.getMonth() + 1);
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

Make sure to uncomment the createTaskRowsTimePeriods() function call in the createGrid function.

This function creates a dayElContainer <div> element that will be the drop zone for the draggable task duration elements. We use the same grid-template-columns CSS styling that we used in the createMonthsRow function for the containerTimePeriods container. This is done so that the number of columns in the container is determined by the number of months in the selected time period.

We then create a row for each task by iterating through the tasks. For each task row, we loop through the months. For each month, we loop through each day of the month and create an empty cell. This nested loop is similar to the loops we used for the previous two functions: createDaysRow and createDaysOfTheWeekRow.

For each empty cell, we color the cell differently if it is a weekend day. We also add task and date data attributes. These will be used for positioning our task durations on the grid.

Adding task durations

Let’s add the sixth and final function that is called in the createGrid function, which will add the task durations as horizontal bars on our grid. Add the following addTaskDurations function above the "change" event listeners:

function addTaskDurations() {
    taskDurations.forEach((taskDuration) => {
      const dateStr = createFormattedDateFromDate(taskDuration.start);
      // find gantt-time-period-cell start position
      const startCell = containerTimePeriods.querySelector(
        `div[data-task="${taskDuration.task}"][data-date="${dateStr}"]`
      );

      if (startCell) {
        // taskDuration bar is a child of start date position of specific task
        createTaskDurationEl(taskDuration, startCell);
      }
    });
  }
Enter fullscreen mode Exit fullscreen mode

Make sure to uncomment the addTaskDurations() function call in the createGrid function.

For each taskDuration passed into our Gantt chart component, we find the start position of where to place the task duration on our grid. This is stored in the startCell variable. We use our createFormattedDateFromDate utility function to convert each task duration’s start property, which is a Date object, into a string. The task duration task and start date is used to find the correct starting position on the grid by finding the cell with the matching data attributes for the task duration. If the start cell is found, we call the createTaskDurationEl function, which will create the task duration element and add it as a child to the cell at its starting position in the grid. Let’s now define this function. Add the createTaskDurationEl below the addTaskDurations function declaration:

function createTaskDurationEl(taskDuration, startCell) {
    const dayElContainer = containerTimePeriods.querySelector(
      ".gantt-time-period-cell-container"
    );
    const taskDurationEl = document.createElement("div");
    taskDurationEl.classList.add("taskDuration");
    taskDurationEl.id = taskDuration.id;

    const days = dayDiff(taskDuration.start, taskDuration.end);
    taskDurationEl.style.width = `calc(${days} * 100%)`;

    // append at start pos
    startCell.appendChild(taskDurationEl);

    return days;
  }
Enter fullscreen mode Exit fullscreen mode

We pass in the task duration object and the start cell element as arguments. The dayElContainer element will be the drop zone for our task durations once we add drag-and-drop functionality.

We create a task duration element and give it the "taskDuration" class for CSS styling. It has a z-index of 1 so that it is placed on top of the grid. Its position is absolute, and the cell that it is placed on has a relative position so that the task duration is placed on its start cell.

In order for the task duration to span across the correct number of cells, we make use of our dayDiff utility function to determine how many days our task duration is. We use this value to calculate the width of the task duration element by setting its width to 100% of the cell width, which is a single day, multiplied by the number of days. The function returns days, which we will use when we add drag-and-drop functionality.

Making the Gantt chart functional

Now that we have created our Gantt chart UI, let’s turn our attention to how to make the Gantt chart functional.

Adding drag-and-drop functionality to task durations

Let’s add drag-and-drop functionality to our task durations so that we can change the position of the task duration elements on the grid. Add the following lines inside the createTaskDurationEl function, just above the startCell.appendChild(taskDurationEl); line:

// drag and drop
    taskDurationEl.draggable = "true";

    taskDurationEl.addEventListener("dragstart", (e) => {
      taskDurationEl.classList.add("dragging");
      // determine taskDuration element that was dragged
      taskDurationElDragged = e.target;
    });

    taskDurationEl.addEventListener("dragend", () => {
      taskDurationEl.classList.remove("dragging");
    });
Enter fullscreen mode Exit fullscreen mode

The first thing we do is set the draggable property of the taskDurationEl to true. We don’t need this property for our drag and drop to work, but it is good for UX because it creates a “ghost” image of the task duration element that is attached to the mouse cursor as it is being dragged.

We then add two event listeners to the taskDurationEl: "dragstart" and "dragend". When the task duration element is being dragged, we add the class "dragging" to it. When the drag ends, we remove the class. We do this to style the task duration element that is being dragged. In our style.js file, we gave elements with the "dragging" class an opacity of 0.5. If you try to drag a task duration element and check the “Elements” tab in your dev tools, you will see that the "dragging" class is added when the drag starts and removed when the drag ends.

We also set the taskDurationElDragged variable to the task duration element that is being dragged. We will use this value later to remove the task duration element that was dragged, create a new task duration for the new position on the grid, and to update the task durations.

If you try to drag a task duration, you will see that the cursor will be changed to the “not-allowed” icon. This is because dragging and dropping an element inside of another element is disabled by default. You can read more about this in the following article: Using the HTML5 Drag and Drop API. To remove this default behavior, add the following lines of code under the "dragend" event listener we just added.:

 dayElContainer.addEventListener("dragover", (e) => {
      e.preventDefault();
    });
Enter fullscreen mode Exit fullscreen mode

This adds a "dragover" event listener to the dayElContainer, which is our drag-and-drop zone. This event fires continuously as an element is dragged over it. We prevent the event default behavior using the preventDefault method. Our cursor will now not show the “not-allowed” icon in our drag-and-drop zone.

Let’s get the drag and drop to work. In the createTaskRowsTimePeriods function, add the following line of code just above the timePeriodEl.appendChild(dayEl); line:

dayEl.ondrop = onTaskDurationDrop;
Enter fullscreen mode Exit fullscreen mode

We add the onDrop attribute to define the drop event handler for each cell in the drag-and-drop zone of the grid. The onTaskDurationDrop function is called when an element is dropped on a cell. Let’s define the onTaskDurationDrop function. Add the following lines below the createTaskDurationEl function:

function onTaskDurationDrop(e) {
    const targetCell = e.target;

    // prevent adding on another taskDuration
    if (targetCell.hasAttribute("draggable")) return;

    // find task
    const taskDuration = taskDurations.filter(
      (taskDuration) => taskDuration.id === taskDurationElDragged.id
    )[0];

    const dataTask = targetCell.getAttribute("data-task");
    const dataDate = targetCell.getAttribute("data-date");

    // remove old position from DOM
    taskDurationElDragged.remove();
    // add new position to DOM
    const daysDuration = createTaskDurationEl(taskDuration, targetCell);

    // get new task values
    // get start, calc end using daysDuration - make Date objects - change taskDurations
    const newTask = parseInt(dataTask);
    const newStartDate = new Date(dataDate);
    let newEndDate = new Date(dataDate);
    newEndDate.setDate(newEndDate.getDate() + daysDuration - 1);

    // update taskDurations
    taskDuration.task = newTask;
    taskDuration.start = newStartDate;
    taskDuration.end = newEndDate;

    const newTaskDuration = taskDurations.filter(
      (taskDuration) => taskDuration.id !== taskDurationElDragged.id
    );
    newTaskDuration.push(taskDuration);

    // update original / make API request to update data on backend
    taskDurations = newTaskDuration;
  }
Enter fullscreen mode Exit fullscreen mode

We determine which cell the task duration element has been dropped on by accessing the target property of the event. We prevent adding the task duration to another task duration by checking if the targetCell has a draggable attribute. Only the task durations have this attribute.

We then find the task in the task durations array by using the dragged task duration element’s id. The taskDurationElDragged was determined at the start of the drag. We then get the task duration’s name and start date from its data attributes. We remove it from the DOM and create a new task duration element for the task duration’s new position using the createTaskDurationEl function.

Lastly, we update the taskDurations by removing the dragged task duration and adding the updated dragged task duration with its new position data.

Image description

Making task durations deletable

Now let’s make our task durations deletable by pressing the delete button when the task duration is in focus. Add the following lines in the createTaskDurationEl function, above the startCell.appendChild(taskDurationEl); line:

// add event listener for deleting taskDuration
    taskDurationEl.tabIndex = 0;
    taskDurationEl.addEventListener("keydown", (e) => {
      if (e.key === "Delete" || e.key === 'Backspace') {
        deleteTaskDuration(e);
      }
    });
Enter fullscreen mode Exit fullscreen mode

We set the task duration element’s tabindex attribute to zero so that it can be focused, and add it to the sequential keyboard navigation.

Our "keydown" event listener listens for when the task duration element is in focus and the delete button is pressed. Should that occur, we call the deleteTaskDuration function. Let’s define this function below the onTaskDurationDrop function definition:

 function deleteTaskDuration(e) {
    const taskDurationToDelete = e.target;
    // remove from DOM
    taskDurationToDelete.remove();
    // update taskDurations
    const newTaskDurations = taskDurations.filter(
      (taskDuration) => taskDuration.id !== taskDurationToDelete.id
    );
    // update original / make API request to update data on backend
    taskDurations = newTaskDurations;
  }
Enter fullscreen mode Exit fullscreen mode

In this function, we determine which task to delete by checking which task duration was in focus using the event’s target property. We then remove it from the DOM and update the task durations by filtering the deleted task duration out of the task durations array.

You will be able to delete your task durations now by clicking on them and pressing the delete button.

 

Image description

Making the “Add task duration” form functional

Let’s make the “Add task duration” form functional so that we can add task durations to our grid. Add the following event listener below the "change" event listeners:

addTaskDurationForm.addEventListener("submit", handleAddTaskDurationForm);
Enter fullscreen mode Exit fullscreen mode

This listens for a "submit" event on our “Add task duration” form.

Let’s define the event handler function. Add the following handleAddTaskDurationForm function below the deleteTaskDuration function declaration:

function handleAddTaskDurationForm(e) {
    e.preventDefault();
    const task = parseInt(e.target.elements["select-task"].value);
    const start = e.target.elements["start-date"].value;
    const end = e.target.elements["end-date"].value;
    const startDate = new Date(start);
    const endDate = new Date(end);

    const timeStamp = Date.now();
    const taskDuration = {
      id: `${timeStamp}`,
      start: startDate,
      end: endDate,
      task: task,
    };

    // add task duration
    taskDurations.push(taskDuration);
    // find gantt-time-period-cell start position
    const startCell = containerTimePeriods.querySelector(
      `div[data-task="${taskDuration.task}"][data-date="${start}"]`
    );

    if (startCell) {
      // taskDuration bar is a child of start date position of specific task
      createTaskDurationEl(taskDuration, startCell);
    }
  }
Enter fullscreen mode Exit fullscreen mode

The first thing this event handler function does is prevent the “submit event” default behavior. This prevents the form from being submitted via a GET request, which would cause a page refresh. We would lose the changes that we made as we are not persisting the data changes.

We then get the new task name, start date, and end date from the form inputs. We create a timestamp using Date.now() to create a unique ID for our new task duration. We then use these values to create a taskDuration object and add it to the task durations data array.

Lastly, we create a task duration element and add it to the grid, if we can find its start position. You will now be able to add tasks to your Gantt chart component from the form.

Image description

Making tasks editable

The task inputs can be edited, but they do not update the tasks data array, and the edits don’t update options in the “Which task?” <select> element of the “Add task duration” form.

Let’s first create a function that handles updating the edited tasks. Add the following updateTasks function below the handleAddTaskDurationForm function definition:

function updateTasks(e) {
    const { id } = e.target.parentNode;
    const { value } = e.target.parentNode.firstChild;
    const idNum = parseInt(id);
    let newTasks = tasks.filter((task) => task.id !== idNum);
    newTasks.push({ id: idNum, name: value });

    newTasks = newTasks.sort((a, b) => a.id - b.id);
    // update original / make API request to update data on backend
    tasks = newTasks;
  }
Enter fullscreen mode Exit fullscreen mode

We get the id of the task to update from the target event’s parentNode. The target will be the task <input>, which has a parent element with an id attribute set to the task id we set in the createTaskRows function. We then remove the task that was edited from the tasks data array and add a new task with the updated task name to update the tasks. We also sort the tasks by ID, that is, the order in which they are created.

To call updateTasks when a task input value is changed, we need to add a "change" event listener to each task input. Add the following lines to the createTaskRows function below the taskRowElInput.value = task.name; line:

      // update task name
      taskRowElInput.addEventListener("change", updateTasks);
Enter fullscreen mode Exit fullscreen mode

The tasks data array will be updated when the task input is changed.

We also need to update the “Which task?” <select> element of the “Add task duration” form. Add the following code to the end of the updateTasks function:

   let newTaskOptionsHTMLStrArr = [];
    tasks.forEach((task) => {
      newTaskOptionsHTMLStrArr.push(
        `<option value="${task.id}">${task.name}</option>`
      );

      taskSelect.innerHTML = `
        ${newTaskOptionsHTMLStrArr.join("")}
      `;
    });
Enter fullscreen mode Exit fullscreen mode

This will update the taskSelect <select> element with options that reflect the updated tasks state.

Making tasks deletable

Now let’s make the delete task buttons work. In the createTaskRows function below taskRowElDelBtn.innerText = "✕"; line, add the following line:

     taskRowElDelBtn.addEventListener("click", deleteTask);
Enter fullscreen mode Exit fullscreen mode

This adds a "click" event listener to each task delete button. Let’s add the deleteTask function that is called when the click event is fired. Add the following deleteTask function below the updateTasksfunction:

function deleteTask(e) {
    const id = parseInt(e.target.parentNode.id);
    // filter out task to delete
    const newTasks = tasks.filter((task) => task.id !== id);
    // update original / make API request to update data on backend
    tasks = newTasks;

    // delete any taskDurations associated with the task
    const newTaskDurations = taskDurations.filter(
      (taskDuration) => taskDuration.task !== id
    );
    taskDurations = newTaskDurations;
    createGrid();
  }
Enter fullscreen mode Exit fullscreen mode

This function gets the id of the task from its parentNode, like we did for the <input> in the updateTasks function. We then filter out the task to delete from the tasks data array to update the tasks. We also delete any task durations that are associated with the deleted task by filtering out any task durations that have a task id that matches the deleted task’s id.

You will now be able to delete tasks by clicking the delete button next to each task name input.

Image description

Making the “Add task” form functional

To make the add task form functional, we will first create a "submit" event listener. Add the following line to the GanttChart function, at the bottom:

addTaskForm.addEventListener("submit", handleAddTaskForm);
Enter fullscreen mode Exit fullscreen mode

We need to define the handleAddTaskForm function that is called when the “Add task” form is submitted. Add the following handleAddTaskForm function below the handleAddTaskDurationForm function definition:

 function handleAddTaskForm(e) {
    e.preventDefault();
    const newTaskName = e.target.elements[0].value;
    // find largest task number, add 1 for new task - else could end up with tasks with same id
    const maxIdVal = tasks.reduce(function (a, b) {
      return Math.max(a, b.id);
    }, -Infinity);
    // create new task
    tasks.push({ id: maxIdVal + 1, name: newTaskName });
    // re-create grid
    createGrid();
  }
Enter fullscreen mode Exit fullscreen mode

We first prevent the default "submit" event behavior, and then get the new task name value from the form. We use the reduce array method to determine the new task id. The task id values are numbers that represent the order in which they were created. They are sorted by their id. We find the largest current task id, and then add one to it to create the id for the new task.

We then call the createGrid function to re-render the grid so that it will show the new task row.

We now have a working basic Gantt chart component, but you might like to make some improvements to it.

Next steps

There are many ways you can add to or improve the Gantt chart component. Here are some things that you might want to try:

  • Improve the styling. The screenshot shown has some extra CSS not included in the examples above, but which you can find in the GitHub repository.
  • Make the number of days that the task durations span editable.
  • Make the tasks reorderable.
  • Sanitize the task and task duration data as setting user-supplied data using innerHTML is a security risk.
  • Add configuration arguments or properties that will allow a user to easily change properties of the Gantt chart component, such as the styling or theme.
  • Persist the data in a database.
  • Make the component a web component so that you have a nicely encapsulated custom HTML element.

A fully featured Gantt chart component that could be used for large projects would include many other advanced features such as:

  • Scheduling conflict resolution.
  • Handling time-zone differences.
  • Grouping of tasks.
  • Extra information labels.
  • Undo and redo functionality.
  • Complex task and task duration customization.
  • Exporting to Excel, PDF, or as an image.
  • Performance optimizations for big data sets.
  • Integration with other project management tools, such as task boards.

Build or buy?

This article gives you a starting point for building your own Gantt chart. If you’re instead looking to buy an off-the-shelf battle-tested solution that just works, take a look at some examples of our Bryntum Gantt Chart.

Top comments (0)