Building a vertical calendar with HTML, CSS & JS

Building a vertical calendar with HTML, CSS & JS

Today let's build together a small vertical calendar using CSS grid layout, the tag and a little bit of JavaScript...

This article is an extract of a larger project dashboard I coded. For the impatient ones or those who want to see how the entire dashboard is built (starting from 14:13):

Result

Getting started

The general structure is divided into two layers stacked on top of each other:

  • Hour grid: The lower layer is the hour grid that visually provides the time scale
  • Event grid: On top of the hour grid we place an event grid that puts the events in the right place on the time scale.

So, let's start with a little bit of markup:

<section class="my-day">
  <header>
    <!-- some header styling,
         see video for entire code -->
  </header>
  <div class="calendar">
    <!-- calendar will come here -->
  </div>
</section>

Therefore the container .calendar needs to have set position: relative; in order to make the absolute position of both children (hour grid and event grid) work correctly.

.calendar {
  /* we'll need that later */
  --left-margin: var(--sp-base);

  position: relative;
}

.calendar > * {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
}

The Hour Grid: Basic Setup

First some basic calculations: We need to define from which hour onwards the calendar is starting and at which hour it is ending:

const startHour = 8;
const endHour = 18;

Since we need these values in the JS and the CSS code, it's a good idea to define them in one place (the JS code in this case) and pass it on to the CSS code. Through <elem>.style.setProperty we're easily able to programmatically change values of CSS custom properties:

const calendar = document
  .querySelector('.calendar');

calendar.style.setProperty(
  '--start-hour', startHour);
calendar.style.setProperty(
  '--end-hour', endHour);

So the number of hours can be calculated by subtracting the start hour from the end hour:

.calendar {
  --hours: calc(var(--end-hour)
    - var(--start-hour));
}

Hour Grid: Construction

we'll use the <template> tag here (see the MDN Docs), in order to be able to dynamically construct the hour grid. So instead of having a fixed number of hours, we'll have the hour grid constructed depending on the actual number of hour's we'll need.

<div class="calendar">
  <div class="calendar__hour-grid">

    <template id="template-hour">
      <div class="calendar__hour">
        <p class="label"></p>
      </div>
    </template>

  </div>
</div>

In this article I'm using plain HTML/JS without UI libraries such as React/Vue/Svelte/... Using the <template> tag is slightly similar to using UI components in these frameworks and their respective loop rendering technique (e.g. v-for in Vue). So transferring this code into a UI library of your choice should not be too much trouble.

Now it's time to actually construct the hour grid:

// Retrieve a reference to the <template> tag
const hourTemplate = document.querySelector(
  '#template-hour');
// Retrieve a reference to the 
// calendar hour grid element
const hourGrid = document.querySelector(
  '.calendar__hour-grid');

So for the required number of hours (from start hour to end hour) we'll clone the hour template content and set its label to the hour it represents:

for (let i = startHour; i < endHour; i++) {
  //clone the template and 
  const hourNode = hourTemplate.content
    .firstElementChild.cloneNode(true);
  // ...append it to the hour grid
  hourGrid.appendChild(hourNode);

  // set the hour label
  hourNode.querySelector('.label')
    .innerText = `${i}`.padStart(2, '0');
}

And to make the hour grid appear as a vertical list, we'll configure the .calendar__hour-grid class to

  • be a grid layout container
  • generate one row for each element in grid auto flow mode
  • give each row the same amount of space (1fr)
.calendar__hour-grid {
  display: grid;
  grid-auto-flow: row;
  grid-auto-rows: 1fr;
}

.calendar__hour > .label {
  font-size: var(--fs-sm-1);
  line-height: 2.5;
}

In order to have a nicely visible grid, each hour element is given a dashed top border. Additionally, the last hour (identified through :last-child) is also given a border at the bottom:

.calendar__hour {
  border-top: 1px dashed var(--bg-secondary);
}

.calendar__hour:last-child {
  border-bottom: 1px dashed var(--bg-secondary);
}

Hour Grid: Highlighting current time

Since it's also quite usual in a calendar to display the current time, we'll put the current hour and minute we want to highlight in two variables:

const currentHour = 12;
const currentMinute = 25;

Hour and minute are fixed in this example, you could of course also retrieve them from a freshly created Date object, providing the current time.

Now, when we generate the hour grid, we simply check, if the hour currently being generated is the current hour. If this is the case we simply add the active class to the hour element and update the --current-minute custom CSS property (which is then used a little later):

for (let i = startHour; i < endHour; i++) {
  // ...

  if (currentHour === i) {
    hourNode.classList.add('active');
    hourNode.style.setProperty(
      '--current-minute', currentMinute
    );
  }
}

The current hour is simply highlighted through text color ...

.calendar__hour.active {
  color: var(--hi-primary);
}

...and the current minute is rendered as a ::before pseudo-element with a dashed line at its bottom border:

.calendar__hour.active {
  position: relative;
}

.calendar__hour.active::before {
  content: "";

  position: absolute;

  left: calc(1.5 * var(--left-margin));
  right: 0;
  height: 1px;

  border-bottom: 2px dashed var(--hi-primary);
}

The position of the current minute is then calculated by dividing the current minute by 60 and then converting it to a percentage by multiplying with 100%:

.calendar__hour.active::before {
  /* ... */
  top: calc(100% * var(--current-minute) / 60);
  /* ... */
}

Event Grid: Basic Setup

Since we're now able to display the hour grid Similar to the hour grid, the event grid contains also a <template> tag which is used for each event being rendered:

<div class="calendar">
  <!-- ... -->
  <!-- Put this _after_ the hour grid,
       otherwise the hour grid will appear
       on top of the events -->
  <div class="calendar__events">
    <template id="template-event">
      <div class="calendar__event">
        <p class="label"></p>
      </div>
    </template>
  </div>
</div>

Unlike the hour grid the event grid itself is not operating in auto flow mode, but is given the number of rows it should render. The calculation of the number of rows is shown in the following section.

.calendar__events {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: repeat(var(--rows), 1fr);

  left: calc(2 * var(--left-margin));
}

Let's also directly retrieve the necessary element references as we're going to need them later: One for the event template and one for the event grid.

const eventTemplate = document
  .querySelector('#template-event');
const calendarEvents = document
  .querySelector('.calendar__events');

Event Grid: Determine number of rows

In our JS code we define the resolution of the event grid. 2 defines that each hour is subdivided into two parts, i.e. half hours. This value we also pass on to the --resolution custom CSS property:

const resolution = 2;

calendar.style.setProperty(
  '--resolution', resolution);

The number of rows we have in our event grid is can now be easily calculated by multiplying the --resolution with the number of --hours. So, if we have a resolution of 2 and 10 hours (from 8:00 to 18:00) the event grid needs to have 20 rows:

.calendar {
  /* ... */

  --rows: calc(
    var(--resolution) * var(--hours)
  );
}

Event Grid: Display Events

Now it's time to actually add the events to the event grid. This is the array of events that we'll display:

Note that the second event has a fraction at the start and end time.

const events = [
  {
    start: 8,
    end: 10,
    title: 'Focus Time',
    past: true,
  },
  {
    start: 10.5,
    end: 11.5,
    title: '1:1 with Tamika',
    past: true,
  },
  {
    start: 14,
    end: 15,
    title: 'Technical Weekly',
  },
];

Just like in the hour grid, we clone the event template for each event we want to display and set its label. Additionally, the custom CSS properties for --start and --end for being able to correctly display the event at its start and end time.

events.forEach((event) => {
  const eventNode = eventTemplate.content
    .firstElementChild.cloneNode(true);
  calendarEvents.appendChild(eventNode);

  eventNode.querySelector('.label')
    .innerText = event.title;
  eventNode.style.setProperty(
    '--start', event.start);
  eventNode.style.setProperty(
    '--end', event.end);
});

Event Grid: Calculating Event Position

The cool thing now is, that we can calculate the start and end row with the same formula for each event.

Note that the + 1 is required since grid lines start counting at 1 and clock time starts counting at 0 (00:00)

.calendar__event {
  /* ... */

  --start: 0;
  --end: 0;

  grid-row-start: calc(
    (var(--start) - var(--start-hour))
    * var(--resolution)
    + 1
  );
  grid-row-end: calc(
    (var(--end) - var(--start-hour))
    * var(--resolution)
    + 1
  );
}

Event Grid: Past Events

Finally, let's add some necessary styling to each event:

.calendar__event {
  padding: var(--sp-sm-2);
  border-radius: calc(2 / 3 * var(--bd-radius));

  background: var(--bg-hi);
}

.calendar__event > .label {
  font-weight: var(--fw-sm);
}

And each event that's in the past should be displayed muted, so let's add for each past event the past class...

events.forEach((event) => {
  // ...
  if (event.past) {
    eventNode.classList.add('past');
  }
});

... and add some styling for past events:

.calendar__event.past {
  background: var(--bg-primary);
}