Build Angular Calendar Component Yourself - No UI Libraries Needed

In this post, you will learn how to create an interactive calendar in Angular without using any additional libraries. Here's how it will look:

finished

We have a calendar where we can select any date, jump to the previous and next months, go to today, and see the meetings planned for that specific day.

We won't use any UI libraries to create this project, but we will need a library to work with dates in JavaScript.

The default date handling in JavaScript is really painful and not flexible.

luxon

A library called Luxon is a popular solution for working with dates in a comfortable and efficient manner.

 DateTime.now().setZone('America/New_York').minus({weeks:1}).endOf('day').toISO();

We can write our code like this. Even if you've never worked with Luxon, you can understand what we are doing here. First, we get the current date. Then, we set the timezone, subtract one week from this date, and get the end of the day. The code is extremely readable, easy to support, and it will help us a lot in creating an interactive calendar with Angular.

Project and dependencies

I already generated an empty Angular application. Now we must install Luxon.

npm i luxon
npm i @types/luxon -D

It is not enough to just install Luxon. We also want to cover all functions with data types, so we installed types for this library.

Now let's look at our code.

import { CalendarComponent } from './calendar/calendar.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, CalendarComponent],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css',
})
export class AppComponent {}

Our AppComponent is completely empty, but we imported our calendar component into it.

<calendar/>

In the HTML, we simply render this component.

// src/app/calendar/calendar.component.ts
import { CalendarComponent } from './calendar/calendar.component';

@Component({
  selector: 'calendar',
  standalone: true,
  templateUrl: './calendar.component.html',
  styleUrl: './calendar.component.css',
})
export class CalendarComponent {
}

Our CalendarComponent is completely empty.

<!-- src/app/calendar/calendar.component.css -->
.calendar-container {
  display: flex;
}

.calendar {
  padding-right: 10px;
  min-width: 384px;
}

.calendar-headline {
  display: flex;
  margin-bottom: 10px;
  justify-content: space-between;
  padding: 0 20px;
}

.calendar-headline-month {
  font-weight: 600;
}

.calendar-headline-controls {
  display: flex;
}

.calendar-headline-control {
  cursor: pointer;
}

.calendar-headline-control:hover {
  text-decoration: underline;
}

.calendar-headline-control-today {
  margin: 0 10px;
}

.calendar-weeks-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  font-size: 12px;
  margin-bottom: 10px;
  color: rgb(81, 81, 81);
}

.calendar-weeks-grid-cell {
  text-align: center;
}

.calendar-grid-cell-inactive {
  color: #9e9e9e;
}

.calendar-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  border-top: 1px solid rgb(190, 190, 190);
  border-left: 1px solid rgb(190, 190, 190);
}

.calendar-grid-cell {
  padding: 5px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-bottom: 1px solid rgb(190, 190, 190);
  border-right: 1px solid rgb(190, 190, 190);
  cursor: pointer;
}

.calendar-grid-cell:hover {
  background: #9ba3bf;
}

.calendar-grid-cell-active {
  background: #9ba3bf;
}

.schedule {
  margin-left: 10px;
}

.schedule-headline {
  font-weight: 600;
  margin-bottom: 30px;
}

Our HTML is completely empty, but in the CSS file, I prepared lots of styling that we will use for our calendar. As always, you can download the source code of the project at the end of the post.

Preparing State

Now we are ready to implement our project. First, we need several states for our application. Here I want to heavily use signals as it is much easier to write code with them.

First, we need a reference to today.

export class CalendarComponent {
  today: Signal<DateTime> = signal(DateTime.local());
}

Here we created an Angular signal with a Luxon date inside. It allows us to access a variety of properties and functions on the variable.

Another thing that we need is the first date of the month. Why do we need that? We want to render the whole month on the screen, and it will be much simpler if we know the first day of the month.

export class CalendarComponent {
  ...
  firstDayOfActiveMonth: WritableSignal<DateTime> = signal(
    this.today().startOf('month'),
  );
}

The first important thing is that we created a WritableSignal. It allows us not only to read its value but also to update it. By using startOf('month') we can get the correct date in relation to today.

The next thing that we need to prepare is an array of day names in a week. We can also use Luxon for this.

export class CalendarComponent {
  ...
  weekDays: Signal<string[]> = signal(Info.weekdays('short'));
}

It's a signal where we use Info.weekdays to get a short format of names like this:

['Mon', 'Tue', 'Wed', ...]

Now we must prepare data for the month grid. It should be an array of Luxon dates which includes the start and end of the weeks. Again, Luxon can help tremendously here.

export class CalendarComponent {
  ...
  daysOfMonth: Signal<DateTime[]> = computed(() => {
    return Interval.fromDateTimes(
      this.firstDayOfActiveMonth().startOf('week'),
      this.firstDayOfActiveMonth().endOf('month').endOf('week'),
    )
      .splitBy({ day: 1 })
  });
}

Here we used computed to create a signal which is based on the firstDayOfActiveMonth. We get an interval between the start and end of the month and split it by 1 day.

dates

As you can see, it returns an array of intervals which is mostly what we need, as we can read the start date from each interval.

export class CalendarComponent {
  ...
  daysOfMonth: Signal<DateTime[]> = computed(() => {
    return Interval.fromDateTimes(
      this.firstDayOfActiveMonth().startOf('week'),
      this.firstDayOfActiveMonth().endOf('month').endOf('week'),
    )
      .splitBy({ day: 1 })
      .map((d) => {
        if (d.start === null) {
          throw new Error('Wrong dates');
        }
        return d.start;
      });
  });
}

This will get us an array of Luxon dates that we can render now. As you can see, Luxon allows us to generate such an array in just 3 lines of code.

Rendering Markup

Let's begin with some rendering.

<div class="calendar-container">
  <div class="calendar">
    <div class="calendar-headline">
      <div class="calendar-headline-month">
        {{ firstDayOfActiveMonth().monthShort }},
        {{ firstDayOfActiveMonth().year }}
      </div>
      <div class="calendar-headline-controls">
        <div class="calendar-headline-control">
          «
        </div>
        <div
          class="calendar-headline-control calendar-headline-control-today"
        >
          Today
        </div>
        <div class="calendar-headline-control">»</div>
      </div>
    </div>
  </div>
</div>

headline

Here is some markup for the headline of the calendar. We render the month name, year, and controls.

Now, let's render a list of weekdays and a grid for the month.

<div class="calendar-container">
  <div class="calendar">
    <div class="calendar-headline">
      ...
    </div>
    <div class="calendar-weeks-grid">
      @for (weekDay of weekDays(); track $index) {
        <div class="calendar-weeks-grid-cell">{{ weekDay }}</div>
      }
    </div>
    <div class="calendar-grid">
      @for (dayOfMonth of daysOfMonth(); track $index) {
        <div class="calendar-grid-cell">
          {{ dayOfMonth.day }}
        </div>
      }
    </div>
  </div>
</div>

Here, we mapped through weekDays and daysOfMonth to render the entire UI of the calendar.

grid

As you can see, the calendar is rendered correctly.

Adding Meetings

The next feature we need to implement is rendering a list of meetings on the right side of our calendar. To achieve this, we first need to pass the meeting information to our calendar component.

To do that, it makes sense to create an interface for our meeting.

// src/app/calendar/meetings.interface.ts
export interface Meetings {
  [key: string]: string[];
}

Let's see what our meetings can look like.

// src/app/app.component.ts
export class AppComponent {
  meetings: Meetings = {
    '2024-04-05': ['Dring Coffee', 'Learn React', 'Sleep'],
    '2024-04-06': ['Dring Coffee', 'Learn Angular', 'Sleep'],
  };
}

We store our meetings as an object where the key is a short date and the value is a list of strings. Now we can accept them in our component as an input signal.

export class CalendarComponent {
  ...
  meetings: InputSignal<Meetings> = input.required();
}

We also need to create a signal for the active day (the day we clicked on) and find the list of meetings for that day.

export class CalendarComponent {
  ...
  meetings: InputSignal<Meetings> = input.required();
  activeDay: WritableSignal<DateTime | null> = signal(null);
  activeDayMeetings: Signal<string[]> = computed(() => {
    const activeDay = this.activeDay();
    if (activeDay === null) {
      return [];
    }
    const activeDayISO = activeDay.toISODate();

    if (!activeDayISO) {
      return [];
    }

    return this.meetings()[activeDayISO] ?? [];
  });
}

Our activeDay is null by default. Based on this, we create a computed signal, activeDayMeetings. We try to get meetings for the specific date, and if it doesn't work, we get an empty array. With this setup, we have all the necessary information to render the list of meetings.

<div class="calendar-container">
  <div class="calendar">
    ...
  </div>
  <div class="schedule">
    <div class="schedule-headline">
      @if (activeDay(); as activeDay) {
        <div>{{ activeDay.toLocaleString(DATE_MED) }}</div>
      } @else {
        <div>Please select a day</div>
      }
    </div>
    <div>
      @if (activeDay() && activeDayMeetings().length === 0) {
        <div>No Planned Meetings today</div>
      }

      @if (activeDay() && activeDayMeetings().length > 0) {
        @for (meeting of activeDayMeetings(); track $index) {
          <div>{{ meeting }}</div>
        }
      }
    </div>
  </div>
</div>

If there is no activeDay, we render a label saying "Please select a day". Once a day is selected, we render at least a headline. If there are any meetings for this day, we render them as a list; otherwise, we render "No Planned Meetings Today".

The meetings list functionality is complete, but we still need to implement a click event to set the activeDay.

<div class="calendar-grid">
  @for (dayOfMonth of daysOfMonth(); track $index) {
    <div class="calendar-grid-cell" (click)="activeDay.set(dayOfMonth)">
      {{ dayOfMonth.day }}
    </div>
  }
</div>

We use activeDay.set to update the state with the clicked day.

select day

Now you can see that a day can be selected, and we render the correct information accordingly.

Highlighting dates

One more feature we're missing is highlighting the active day.

<div class="calendar-grid">
  @for (dayOfMonth of daysOfMonth(); track $index) {
    <div
      [ngClass]="{
        'calendar-grid-cell': true,
        'calendar-grid-cell-active':
          activeDay()?.toISODate() === dayOfMonth.toISODate(),
        'calendar-grid-cell-inactive':
          dayOfMonth.month !== firstDayOfActiveMonth().month
      }"
      (click)="activeDay.set(dayOfMonth)"
    >
      {{ dayOfMonth.day }}
    </div>
  }
</div>

Now, instead of applying a single class to our cell, we use ngClass to concatenate them dynamically. By default, we set calendar-grid-cell for all cells. If the day is not in the current month, we add calendar-grid-cell-inactive. When the day is active, we add calendar-grid-cell-active.

classnames

As you can see, now the active day is correctly highlighted.

The last feature we're missing is arrow navigation and jumping to today. To implement this, we need to create several functions to change firstDayOfActiveMonth. These functions will handle navigating to the previous and next months, as well as jumping to today's date.

export class CalendarComponent {
  ...
  goToPreviousMonth(): void {
    this.firstDayOfActiveMonth.set(
      this.firstDayOfActiveMonth().minus({ month: 1 }),
    );
  }

  goToNextMonth(): void {
    this.firstDayOfActiveMonth.set(
      this.firstDayOfActiveMonth().plus({ month: 1 }),
    );
  }

  goToToday(): void {
    this.firstDayOfActiveMonth.set(this.today().startOf('month'));
  }
}

Here, we use Luxon to subtract or add a month to the firstDayOfActiveMonth. Now, we just need to add click events to trigger these functions.

<div class="calendar-headline-controls">
  <div class="calendar-headline-control" (click)="goToPreviousMonth()">
    «
  </div>
  <div
    class="calendar-headline-control calendar-headline-control-today"
    (click)="goToToday()"
  >
    Today
  </div>
  <div class="calendar-headline-control" (click)="goToNextMonth()">»</div>
</div>

We can navigate through the calendar by clicking on our headline controls.

finished

Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!

📚 Source code of what we've done
Don't miss a thing!
Follow me on Youtube, Twitter or Instagram.
Oleksandr Kocherhin
Oleksandr Kocherhin is a full-stack developer with a passion for learning and sharing knowledge on Monsterlessons Academy and on his YouTube channel. With around 15 years of programming experience and nearly 9 years of teaching, he has a deep understanding of both disciplines. He believes in learning by doing, a philosophy that is reflected in every course he teaches. He loves exploring new web and mobile technologies, and his courses are designed to give students an edge in the fast-moving tech industry.