This guide builds a calendar for booking meeting rooms: a timeline with a row for each room, bookings you drag into place, a rule that keeps a room from being booked twice, and a file that puts a booking in any calendar. It uses the scheduler from @needless-ui/angular, MIT-licensed like the rest of Needless UI, so the resource timeline is free for any app.
Rooms and bookings
Rooms are resources: an id, a title, and anything else in data. Bookings are events with plain ISO times, such as 2026-10-23T09:00, and their room’s id in resource. A booking can hold several rooms, or people, as a list.
A row for each room
Pass both to nui-scheduler with view="timeline". dayStart and dayEnd keep the day to opening hours, snapMinutes moves bookings by quarter hours, and businessHours shades the time the rooms are closed. nuiSchedulerResource draws the head of each row.
<nui-scheduler
[events]="bookings()"
[resources]="rooms"
view="timeline"
dayStart="8"
dayEnd="19"
snapMinutes="15"
[businessHours]="hours"
editable
selectable
[allow]="free"
(slotSelect)="book($event)"
(eventChange)="move($event)"
(rangeChange)="range.set($event)"
>
<ng-template nuiSchedulerResource let-room>{{ room.title }}</ng-template>
</nui-scheduler>
<button nuiButton type="button" (click)="download()">Add to calendar</button>Book and move
The scheduler never changes your data itself: it reports what people do. Dragging across a room’s row chooses time, and slotSelect reports it with the room. Dragging a booking, or its end, reports its new times in eventChange, and its new room when it moved to another. Update the signal, and the timeline follows.
import { Component, signal } from '@angular/core';
import { NuiButton } from '@needless-ui/angular/button';
import {
NuiScheduler,
type NuiSchedulerChange,
type NuiSchedulerEvent,
NuiSchedulerResourceTemplate,
type NuiSchedulerSlot,
} from '@needless-ui/angular/scheduler';
import { downloadIcs } from './booking-ics';
@Component({
selector: 'app-bookings',
imports: [NuiButton, NuiScheduler, NuiSchedulerResourceTemplate],
templateUrl: './booking.html',
})
export class Bookings {
/** Rooms are resources; bookings are events, with their room in `resource`. */
protected readonly rooms = [
{ id: 'atlas', title: 'Atlas' },
{ id: 'boreal', title: 'Boreal' },
];
protected readonly bookings = signal<NuiSchedulerEvent[]>([]);
/** Open on weekdays from 8:00 to 19:00: other time is shaded. */
protected readonly hours = [{ days: [1, 2, 3, 4, 5], start: '08:00', end: '19:00' }];
/** The days on screen: load their bookings when it changes. */
protected readonly range = signal<{ start: string; end: string } | null>(null);
protected book({ start, end, resource }: NuiSchedulerSlot): void {
const booking = { id: crypto.randomUUID(), title: 'New booking', start, end, resource };
this.bookings.update((all) => [...all, booking]);
}
protected move({ event, start, end, resource }: NuiSchedulerChange): void {
this.bookings.update((all) =>
all.map((b) =>
b.id === event.id ? { ...b, start, end, resource: resource ?? b.resource } : b,
),
);
}
/** A room holds one booking at a time. */
protected readonly free = (time: NuiSchedulerChange | NuiSchedulerSlot): boolean => {
const id = 'event' in time ? time.event.id : null;
const room = 'event' in time ? (time.resource ?? time.event.resource) : time.resource;
return !this.bookings().some(
(b) => b.id !== id && b.resource === room && b.start < time.end && b.end > time.start,
);
};
protected download(): void {
downloadIcs(this.bookings(), 'bookings');
}
}No double bookings
allow is asked before a booking is made or moved. When it returns false, a drag shows in red and drops nothing, and from the keyboard the scheduler says the time isn’t available. The example checks the room’s other bookings; check again on your server, which has the last word.
Load the days on screen
rangeChange reports the first and last day shown, once at the start and again whenever people page, so you can fetch only those bookings, with httpResource or any request you like.
Add to calendar
nuiSchedulerToIcs() writes bookings as an iCalendar file, which Google Calendar, Outlook and Apple Calendar import. nuiSchedulerFromIcs() reads one back, and moves times from other time zones to the reader’s.
import { type NuiSchedulerEvent, nuiSchedulerToIcs } from '@needless-ui/angular/scheduler';
/** Saves events as an .ics file, which Google Calendar, Outlook and Apple Calendar import. */
export function downloadIcs(events: readonly NuiSchedulerEvent[], name: string): void {
const file = new Blob([nuiSchedulerToIcs(events, { name })], { type: 'text/calendar' });
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = `${name}.ics`;
link.click();
URL.revokeObjectURL(link.href);
}Keyboard and screen readers
All of it works without a mouse. The timeline is one tab stop: the arrow keys move through times and rooms, Shift with the arrows chooses time, and Enter books it. On a booking, Alt with the arrows moves it to another time or room. Every booking is a button named by its title, room, day and time.
For the rooms side by side in a day, repeating bookings and every other option, see the scheduler’s page and its API.