Vitral 0.2
Data

Schedule

A calendar with month, week, day, agenda and resource timeline views, drag to move and resize, recurring events and any time zone.

Import

main.ts
import { Schedule } from '@vitral/vue';

Week

Business hours are shaded; overlapping events share the column; the red line is now. The standup repeats every weekday, payroll on the first and last of the month.

Sep 20, 2026 – Sep 26, 2026
Time
All day
12:00 AM
12:30 AM
1:00 AM
1:30 AM
2:00 AM
2:30 AM
3:00 AM
3:30 AM
4:00 AM
4:30 AM
5:00 AM
5:30 AM
6:00 AM
6:30 AM
7:00 AM
7:30 AM
8:00 AM
8:30 AM
9:00 AM
9:30 AM
10:00 AM
10:30 AM
11:00 AM
11:30 AM
12:00 PM
12:30 PM
1:00 PM
1:30 PM
2:00 PM
2:30 PM
3:00 PM
3:30 PM
4:00 PM
4:30 PM
5:00 PM
5:30 PM
6:00 PM
6:30 PM
7:00 PM
7:30 PM
8:00 PM
8:30 PM
9:00 PM
9:30 PM
10:00 PM
10:30 PM
11:00 PM
11:30 PM
Move, resize or select something. Press an event to open it.
<script setup lang="ts">import { Button, Dialog, Schedule, StackPanel, Tag, type ScheduleEvent, type ScheduleEventChange, type ScheduleOccurrenceInfo, type ScheduleSelection, type ScheduleViewName } from '@vitral/vue';import { computed, ref } from 'vue'; const today = new Date();const at = (dayOffset: number, hour: number, minute = 0) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + dayOffset, hour, minute);const day = (dayOffset: number) => at(dayOffset, 0); const events = ref<ScheduleEvent[]>([    { id: 1, title: 'Standup', start: at(-today.getDay() + 1, 9), end: at(-today.getDay() + 1, 9, 15), recurrence: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', color: 'var(--vt-chart-4)' },    { id: 2, title: 'Design review', start: at(0, 10), end: at(0, 11, 30) },    { id: 3, title: 'Pairing', start: at(0, 10, 30), end: at(0, 12) },    { id: 4, title: 'Lunch with Ana', start: at(1, 12, 30), end: at(1, 13, 30), color: 'var(--vt-chart-3)' },    { id: 5, title: 'Release', start: day(2), end: day(4), allDay: true, color: 'var(--vt-chart-2)' },    { id: 6, title: 'Planning', start: at(-1, 14), end: at(-1, 16) },    { id: 7, title: 'Board meeting', start: at(3, 15), end: at(3, 16), editable: false, color: 'var(--vt-chart-6)' },    { id: 8, title: 'Payroll', start: new Date(today.getFullYear(), today.getMonth(), 1, 8), recurrence: 'FREQ=MONTHLY;BYMONTHDAY=1,-1', allDay: true, color: 'var(--vt-chart-5)' }]); let nextId = 100;const log = ref('Move, resize or select something. Press an event to open it.');const view = ref<ScheduleViewName>('week');const date = ref(day(0)); // ---- opening an event// `event-click` hands over the event, which occurrence of it was pressed, and// the original DOM event. What to do with that is the application's: a dialog// here, a drawer or a side panel somewhere else.const opened = ref<{ event: ScheduleEvent; occurrence: ScheduleOccurrenceInfo } | null>(null);const details = computed(() => {    const current = opened.value;    if (!current) return null;    const { event, occurrence } = current;    const when = occurrence.allDay        ? occurrence.start.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })        : `${occurrence.start.toLocaleString(undefined, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} – ${occurrence.end.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;    return { title: event.title, when, recurring: occurrence.recurring, editable: event.editable !== false };}); // Applies a change: a single event is updated; one occurrence of a series is// split off as an event of its own and left out of the series.function apply(change: ScheduleEventChange) {    const { event, occurrence, start, end, allDay, resourceId } = change;    if (occurrence.recurring) {        events.value = [            ...events.value.map((e) => (e === event ? { ...e, exdate: [...(e.exdate ?? []), occurrence.start] } : e)),            { ...event, id: nextId++, recurrence: null, exdate: undefined, start, end, allDay, resourceId }        ];    } else {        events.value = events.value.map((e) => (e === event ? { ...e, start, end, allDay, resourceId } : e));    }    log.value = `${event.title}: ${start.toLocaleString()} → ${end.toLocaleString()} (${change.kind}, ${change.via})`;} function create(selection: ScheduleSelection) {    events.value = [...events.value, { id: nextId++, title: 'New event', start: selection.start, end: selection.end, allDay: selection.allDay, resourceId: selection.resourceId }];    log.value = `Created ${selection.start.toLocaleString()} → ${selection.end.toLocaleString()}`;}</script> <template>    <Schedule        v-model:view="view"        v-model:date="date"        :events="events"        scroll-time="08:00"        style="width: 100%"        @event-change="apply"        @select="create"        @event-click="opened = { event: $event.event, occurrence: $event.occurrence }"    />    <small style="color: var(--vt-text-muted-color)">{{ log }}</small>     <Dialog :visible="!!opened" modal :header="details?.title" :style="{ width: 'min(26rem, 92vw)' }" @update:visible="opened = null">        <StackPanel v-if="details" spacing="0.5rem">            <p style="margin: 0">{{ details.when }}</p>            <StackPanel orientation="horizontal" spacing="0.625rem" align="center" wrap>                <Tag v-if="details.recurring" value="Repeats" severity="info" />                <Tag v-if="!details.editable" value="Locked" severity="secondary" />            </StackPanel>        </StackPanel>        <template #footer>            <Button label="Close" severity="secondary" variant="text" @click="opened = null" />        </template>    </Dialog></template>

Month

Events run across the days they cover; a busy day shows “+N more”, which opens the full list.

September 2026
Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Sunday, August 30, 2026
Monday, August 31, 2026
Tuesday, September 1, 2026
Wednesday, September 2, 2026
Thursday, September 3, 2026
Friday, September 4, 2026
Saturday, September 5, 2026
Sunday, September 6, 2026
Monday, September 7, 2026
Tuesday, September 8, 2026
Wednesday, September 9, 2026
Thursday, September 10, 2026
Friday, September 11, 2026
Saturday, September 12, 2026
Sunday, September 13, 2026
Monday, September 14, 2026
Tuesday, September 15, 2026
Wednesday, September 16, 2026
Thursday, September 17, 2026
Friday, September 18, 2026
Saturday, September 19, 2026
Sunday, September 20, 2026
Monday, September 21, 2026
Tuesday, September 22, 2026
Wednesday, September 23, 2026
Thursday, September 24, 2026
Friday, September 25, 2026
Saturday, September 26, 2026
Sunday, September 27, 2026
Monday, September 28, 2026
Tuesday, September 29, 2026
Wednesday, September 30, 2026
Thursday, October 1, 2026
Friday, October 2, 2026
Saturday, October 3, 2026
Sunday, October 4, 2026
Monday, October 5, 2026
Tuesday, October 6, 2026
Wednesday, October 7, 2026
Thursday, October 8, 2026
Friday, October 9, 2026
Saturday, October 10, 2026
<script setup lang="ts">import { Button, Dialog, Schedule, StackPanel, Tag, type ScheduleEvent, type ScheduleEventChange, type ScheduleOccurrenceInfo, type ScheduleSelection, type ScheduleViewName } from '@vitral/vue';import { computed, ref } from 'vue'; const today = new Date();const at = (dayOffset: number, hour: number, minute = 0) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + dayOffset, hour, minute);const day = (dayOffset: number) => at(dayOffset, 0); const events = ref<ScheduleEvent[]>([    { id: 1, title: 'Standup', start: at(-today.getDay() + 1, 9), end: at(-today.getDay() + 1, 9, 15), recurrence: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', color: 'var(--vt-chart-4)' },    { id: 2, title: 'Design review', start: at(0, 10), end: at(0, 11, 30) },    { id: 3, title: 'Pairing', start: at(0, 10, 30), end: at(0, 12) },    { id: 4, title: 'Lunch with Ana', start: at(1, 12, 30), end: at(1, 13, 30), color: 'var(--vt-chart-3)' },    { id: 5, title: 'Release', start: day(2), end: day(4), allDay: true, color: 'var(--vt-chart-2)' },    { id: 6, title: 'Planning', start: at(-1, 14), end: at(-1, 16) },    { id: 7, title: 'Board meeting', start: at(3, 15), end: at(3, 16), editable: false, color: 'var(--vt-chart-6)' },    { id: 8, title: 'Payroll', start: new Date(today.getFullYear(), today.getMonth(), 1, 8), recurrence: 'FREQ=MONTHLY;BYMONTHDAY=1,-1', allDay: true, color: 'var(--vt-chart-5)' }]); let nextId = 100;const view = ref<ScheduleViewName>('month'); const opened = ref<{ event: ScheduleEvent; occurrence: ScheduleOccurrenceInfo } | null>(null);const details = computed(() => {    const current = opened.value;    if (!current) return null;    const { event, occurrence } = current;    const when = occurrence.allDay        ? occurrence.start.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })        : `${occurrence.start.toLocaleString(undefined, { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} – ${occurrence.end.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;    return { title: event.title, when, recurring: occurrence.recurring, editable: event.editable !== false };}); // Applies a change: a single event is updated; one occurrence of a series is// split off as an event of its own and left out of the series.function apply(change: ScheduleEventChange) {    const { event, occurrence, start, end, allDay, resourceId } = change;    if (occurrence.recurring) {        events.value = [            ...events.value.map((e) => (e === event ? { ...e, exdate: [...(e.exdate ?? []), occurrence.start] } : e)),            { ...event, id: nextId++, recurrence: null, exdate: undefined, start, end, allDay, resourceId }        ];    } else {        events.value = events.value.map((e) => (e === event ? { ...e, start, end, allDay, resourceId } : e));    }} function create(selection: ScheduleSelection) {    events.value = [...events.value, { id: nextId++, title: 'New event', start: selection.start, end: selection.end, allDay: selection.allDay, resourceId: selection.resourceId }];}</script> <template>    <Schedule        v-model:view="view"        :events="events"        :max-events-per-day="2"        style="width: 100%"        @event-change="apply"        @select="create"        @event-click="opened = { event: $event.event, occurrence: $event.occurrence }"    />     <Dialog :visible="!!opened" modal :header="details?.title" :style="{ width: 'min(26rem, 92vw)' }" @update:visible="opened = null">        <StackPanel v-if="details" spacing="0.5rem">            <p style="margin: 0">{{ details.when }}</p>            <StackPanel orientation="horizontal" spacing="0.625rem" align="center" wrap>                <Tag v-if="details.recurring" value="Repeats" severity="info" />                <Tag v-if="!details.editable" value="Locked" severity="secondary" />            </StackPanel>        </StackPanel>        <template #footer>            <Button label="Close" severity="secondary" variant="text" @click="opened = null" />        </template>    </Dialog></template>

Agenda

The next two weeks as a list, day by day.

Sep 25, 2026 – Oct 8, 2026
  • Friday, September 25, 2026
  • Saturday, September 26, 2026
  • Sunday, September 27, 2026
  • Monday, September 28, 2026
  • Tuesday, September 29, 2026
  • Wednesday, September 30, 2026
  • Thursday, October 1, 2026
  • Friday, October 2, 2026
  • Monday, October 5, 2026
  • Tuesday, October 6, 2026
  • Wednesday, October 7, 2026
  • Thursday, October 8, 2026
<script setup lang="ts">import { Schedule, type ScheduleEvent } from '@vitral/vue'; const today = new Date();const at = (dayOffset: number, hour: number, minute = 0) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + dayOffset, hour, minute);const day = (dayOffset: number) => at(dayOffset, 0); const events: ScheduleEvent[] = [    { id: 1, title: 'Standup', start: at(-today.getDay() + 1, 9), end: at(-today.getDay() + 1, 9, 15), recurrence: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', color: 'var(--vt-chart-4)' },    { id: 2, title: 'Design review', start: at(0, 10), end: at(0, 11, 30) },    { id: 3, title: 'Pairing', start: at(0, 10, 30), end: at(0, 12) },    { id: 4, title: 'Lunch with Ana', start: at(1, 12, 30), end: at(1, 13, 30), color: 'var(--vt-chart-3)' },    { id: 5, title: 'Release', start: day(2), end: day(4), allDay: true, color: 'var(--vt-chart-2)' },    { id: 6, title: 'Planning', start: at(-1, 14), end: at(-1, 16) },    { id: 7, title: 'Board meeting', start: at(3, 15), end: at(3, 16), editable: false, color: 'var(--vt-chart-6)' },    { id: 8, title: 'Payroll', start: new Date(today.getFullYear(), today.getMonth(), 1, 8), recurrence: 'FREQ=MONTHLY;BYMONTHDAY=1,-1', allDay: true, color: 'var(--vt-chart-5)' }];</script> <template>    <Schedule view="agenda" :views="['agenda']" :agenda-days="14" :events="events" style="width: 100%" /></template>

Timeline by resource

Rooms as rows and time across. Drag a booking to another room, or press Alt with Up and Down.

Friday, September 25, 2026
Resources
7:00 AM
8:00 AM
9:00 AM
10:00 AM
11:00 AM
12:00 PM
1:00 PM
2:00 PM
3:00 PM
4:00 PM
5:00 PM
6:00 PM
7:00 PM
Auditorium
Room B
Room C
Studio
<script setup lang="ts">import { Schedule, type ScheduleEvent, type ScheduleEventChange, type ScheduleViewName } from '@vitral/vue';import { ref } from 'vue'; const today = new Date();const at = (dayOffset: number, hour: number, minute = 0) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + dayOffset, hour, minute);const day = (dayOffset: number) => at(dayOffset, 0); const view = ref<ScheduleViewName>('timeline');const resources = [    { id: 'a', title: 'Auditorium' },    { id: 'b', title: 'Room B', color: 'var(--vt-chart-3)' },    { id: 'c', title: 'Room C', color: 'var(--vt-chart-7)' },    { id: 'd', title: 'Studio' }];const bookings = ref<ScheduleEvent[]>([    { id: 'k1', title: 'Onboarding', start: at(0, 9), end: at(0, 12), resourceId: 'a' },    { id: 'k2', title: 'Interview', start: at(0, 10), end: at(0, 11), resourceId: 'b' },    { id: 'k3', title: 'Interview', start: at(0, 10, 30), end: at(0, 11, 30), resourceId: 'b' },    { id: 'k4', title: 'Workshop', start: at(0, 13), end: at(0, 17), resourceId: 'c' },    { id: 'k5', title: 'Recording', start: at(0, 8), end: at(0, 10), resourceId: 'd' },    { id: 'k6', title: 'Maintenance', start: day(0), end: day(1), allDay: true, resourceId: 'd' }]); function rebook(change: ScheduleEventChange) {    bookings.value = bookings.value.map((e) => (e === change.event ? { ...e, start: change.start, end: change.end, resourceId: change.resourceId } : e));}</script> <template>    <Schedule v-model:view="view" :views="['timeline', 'week']" :resources="resources" :events="bookings" min-time="07:00" max-time="20:00" scroll-height="20rem" style="width: 100%" @event-change="rebook">        <template #resource="{ resource }">            <span style="display: flex; align-items: center; gap: 0.5rem">                <span :style="{ width: '0.5rem', height: '0.5rem', borderRadius: '50%', background: resource.color ?? 'var(--vt-chart-1)' }" />                {{ resource.title }}            </span>        </template>    </Schedule></template>

The same instants, read somewhere else

Three fixed instants, drawn in whichever zone is chosen. Nothing about the events changes — only which hour and which day the grid puts them at, so the nightly build crosses into the day before or the day after depending on where you are standing. Click an event: what comes back is the instant it always was.

Monday, March 9, 2026
Time
Monday, March 9, 2026
All day
12:00 AM
12:30 AM
1:00 AM
1:30 AM
2:00 AM
2:30 AM
3:00 AM
3:30 AM
4:00 AM
4:30 AM
5:00 AM
5:30 AM
6:00 AM
6:30 AM
7:00 AM
7:30 AM
8:00 AM
8:30 AM
9:00 AM
9:30 AM
10:00 AM
10:30 AM
11:00 AM
11:30 AM
12:00 PM
12:30 PM
1:00 PM
1:30 PM
2:00 PM
2:30 PM
3:00 PM
3:30 PM
4:00 PM
4:30 PM
5:00 PM
5:30 PM
6:00 PM
6:30 PM
7:00 PM
7:30 PM
8:00 PM
8:30 PM
9:00 PM
9:30 PM
10:00 PM
10:30 PM
11:00 PM
11:30 PM
<script setup lang="ts">import { Button, Schedule, StackPanel } from '@vitral/vue';import { ref } from 'vue'; // ---- the same instants, read in three placesconst zones = ['UTC', 'America/Sao_Paulo', 'Asia/Tokyo'] as const;const zone = ref<(typeof zones)[number]>('America/Sao_Paulo');const handoverDay = new Date(2026, 2, 9);const handovers = [    { id: 'h1', title: 'Handover', start: new Date(Date.UTC(2026, 2, 9, 15, 0)), end: new Date(Date.UTC(2026, 2, 9, 16, 0)) },    { id: 'h2', title: 'Market open', start: new Date(Date.UTC(2026, 2, 9, 13, 30)), end: new Date(Date.UTC(2026, 2, 9, 14, 0)) },    { id: 'h3', title: 'Nightly build', start: new Date(Date.UTC(2026, 2, 9, 2, 0)), end: new Date(Date.UTC(2026, 2, 9, 3, 0)) }];const readBack = ref('');const showInstant = (change: { occurrence: { start: Date } }) => (readBack.value = change.occurrence.start.toISOString());</script> <template>    <StackPanel spacing="1rem" style="width: 100%">        <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem">            <div style="display: flex; gap: 0.5rem" role="group" aria-label="Time zone">                <Button v-for="z in zones" :key="z" size="small" severity="secondary" variant="outlined" :aria-pressed="zone === z" @click="zone = z">{{ z }}</Button>            </div>            <small v-if="readBack" style="color: var(--vt-text-muted-color)">Reported as {{ readBack }}</small>        </div>        <Schedule :events="handovers" :date="handoverDay" view="day" :views="['day']" :time-zone="zone" scroll-time="00:00" aria-label="Handovers" @event-click="showInstant" />    </StackPanel></template>

Custom toolbar, content and revert

The toolbar and the event content are slots. Hours run 08:00–18:00 in 15-minute slots, and a move onto a weekend is reverted.

Sep 20, 2026 – Sep 26, 2026
Sep 20, 2026 – Sep 26, 2026
Time
All day
08:00
08:15
08:30
08:45
09:00
09:15
09:30
09:45
10:00
10:15
10:30
10:45
11:00
11:15
11:30
11:45
12:00
12:15
12:30
12:45
13:00
13:15
13:30
13:45
14:00
14:15
14:30
14:45
15:00
15:15
15:30
15:45
16:00
16:15
16:30
16:45
17:00
17:15
17:30
17:45
<script setup lang="ts">import { Button, Schedule, type ScheduleEvent, type ScheduleEventChange } from '@vitral/vue';import { ref } from 'vue'; const today = new Date();const at = (hour: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate(), hour); // Weekends are closed: a move onto one is taken back.const guarded = ref<ScheduleEvent[]>([{ id: 'g', title: 'Try moving me to a weekend', start: at(11), end: at(12) }]);function guard(change: ScheduleEventChange) {    const weekend = change.start.getDay() === 0 || change.start.getDay() === 6;    if (weekend) {        change.revert();        return;    }    guarded.value = guarded.value.map((e) => (e === change.event ? { ...e, start: change.start, end: change.end } : e));}</script> <template>    <Schedule :events="guarded" view="week" :slot-duration="15" min-time="08:00" max-time="18:00" scroll-height="24rem" :hour12="false" style="width: 100%" @event-change="guard">        <template #toolbar="{ title, prev, next, today: goToday }">            <div style="display: flex; align-items: center; gap: 0.5rem">                <Button icon="chevronLeft" aria-label="Previous week" variant="outlined" size="small" @click="prev" />                <Button label="This week" variant="outlined" size="small" @click="goToday" />                <Button icon="chevronRight" aria-label="Next week" variant="outlined" size="small" @click="next" />                <strong style="margin-inline-start: 0.5rem">{{ title }}</strong>            </div>        </template>        <template #event="{ event, timeText }">            <strong>{{ event.title }}</strong>            <small>{{ timeText }}</small>        </template>    </Schedule></template>

API

Read from packages/vue/src/components/Schedule/types.ts, so it says what the component actually accepts.

Props

NameTypeDescription
eventsScheduleEvent[]—
resourcesScheduleResource[]Rows of the timeline view.
viewsScheduleViewName[]The views the switcher offers, in order. Defaults to month, week, day and agenda, plus timeline when there are resources.
toolbarbooleanShown after the title; set to false to hide the built-in toolbar. Defaults to true.
timeZonestringThe zone the calendar is drawn in — an IANA name such as `'America/Sao_Paulo'`. Events keep the instants they were given; only which hour and which day they are shown at changes, and every date the schedule emits is an instant again. An all-day event is a date rather than an instant, so it stays where it is put.
slotDurationnumber—
timelineSlotDurationnumberMinutes the timeline's columns span. Defaults to 60.
snapDurationnumberMinutes a drag or a key moves by. Defaults to 15.
minTimestringThe first time shown, `'HH:mm'`. Defaults to `'00:00'`.
maxTimestringThe last time shown, `'HH:mm'`. Defaults to `'24:00'`.
scrollTimestringWhere the week and day views open scrolled to. Defaults to `'08:00'`.
businessHoursScheduleBusinessHours | ScheduleBusinessHours[] | falseShades the time outside them; false shades nothing. Defaults to Monday–Friday, 09:00–17:00.
firstDayOfWeeknumber0 = Sunday; defaults to the locale's `firstDayOfWeek`.
hour12booleanForce a 12- or 24-hour clock; defaults to the locale's.
editablebooleanEvents can be dragged and resized. Defaults to true.
selectablebooleanEmpty time can be dragged over to pick a range (`select`). Defaults to true.
nowIndicatorbooleanA line at the current time. Defaults to true.
defaultDurationnumberMinutes a timed event without an end lasts. Defaults to 60.
agendaDaysnumberDays the agenda lists. Defaults to 7.
timelineDaysnumberDays the timeline spans. Defaults to 1.
maxEventsPerDaynumberEvents shown in a month cell before "+N more". Defaults to 3.
scrollHeightstringHeight of the scrolling area of the week, day and timeline views. Defaults to `'36rem'`.

Plus pt, dt and unstyled from BaseProps, see pass-through and unstyled mode.

Emits

EventPayloadDescription
range-changerange: { start: Date; end: Date; view: ScheduleViewName }The visible range changed: the moment to fetch its events.
event-clickpayload: { event: ScheduleEvent; occurrence: ScheduleOccurrenceInfo; originalEvent: Event }—
event-changechange: ScheduleEventChange—
selectselection: ScheduleSelection—
date-clickpayload: { date: Date; allDay: boolean; resourceId?: string | number | null; originalEvent: Event }—

Slots

NameSlot propsDescription
default—The parts, written as children: `<Schedule.Toolbar>`, `<Schedule.Event>`…
toolbar(props: ScheduleToolbarSlotProps)Replaces the whole toolbar.
event(props: ScheduleEventSlotProps)An event's content.
resource(props: { resource: ScheduleResource })A resource's row header in the timeline.
empty—The agenda with nothing in it.