Use it for
Use BbCalendar when the calendar is the interface: a booking step, a
dashboard's date panel, or a filter sheet where a second sheet would be one too
many.
Use something else when
BbDatePickerInput: people type the date, or it is a labelled field with validation messagesBbDatePicker: the calendar opens from a cell, chip, or button you own and closes after the pickBbTimePicker: the value is a time with no date
BbCalendar is the grid that BbDatePicker opens in its popover, rendered in
place. It takes the same value shapes and the same min, max, and
selectable rules; the value contract
is explained on the picker's page.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
A booking step
Bind the day with v-model and add floating so the value
is a plain YYYY-MM-DD with no time or zone. Name the grid with aria-label,
so a page with two calendars reads "Delivery date" and "Stay" rather than two
unnamed grids. Every pick updates the value, and nothing opens or closes.
Delivery date
Arrives Tuesday, September 1
<template>
<div
class="grid w-fit gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">Delivery date</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ summary }}
</p>
</div>
<!-- The calendar IS the control: every pick updates `delivery`, and
there is nothing to open or close. -->
<BbCalendar
v-model="delivery"
aria-label="Delivery date"
:disabled="collect"
floating
:max="lastDay"
:min="firstDay"
:selectable="isWeekday"
/>
<BbSwitch
id="delivery-collect"
v-model="collect"
compact
label="Collect from the shop instead"
name="collect"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbCalendar, BbSwitch } from 'bitboss-ui';
// The window is fixed so the page renders the same on every visit. An app
// derives firstDay and lastDay from the clock.
const firstDay = '2026-09-01';
const lastDay = '2026-09-30';
// The earliest slot is preselected; a pick replaces it.
const delivery = ref<string | null>(firstDay);
const collect = ref(false);
/** `selectable` receives the floating `YYYY-MM-DD`: no weekend deliveries. */
const isWeekday = (day: string): boolean => {
const weekday = new Date(`${day}T00:00:00Z`).getUTCDay();
return weekday !== 0 && weekday !== 6;
};
const longDay = (day: string): string =>
new Date(`${day}T00:00:00Z`).toLocaleDateString('en-US', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'UTC',
});
const summary = computed(() => {
if (collect.value) return 'Ready to collect the next working day';
return delivery.value
? `Arrives ${longDay(delivery.value)}`
: 'Pick a weekday in September';
});
</script>
min and max set the window; selectable refuses individual days, here the
weekends. disabled turns off the whole grid, as the collect switch does. The
calendar is one Tab stop: arrows move by day and week, PageUp and PageDown by
month.
An empty calendar opens on today's month, or on the nearest month inside min
and max. On a prerendered page it opens on the build date's month and moves
to the reader's month after hydration, so seed a value when the opening month
matters.
Ranges and sets of days
Add range for a stay or any continuous window. The model is [start, end],
[] when empty. readonly keeps a confirmed booking on screen and ignores clicks;
people can still page through the months.
Your stay
Fri, Oct 9 to Mon, Oct 12, €384
<template>
<div
class="grid w-fit gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">
{{ booked ? 'Booked' : 'Your stay' }}
</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ summary }}
</p>
</div>
<!-- `readonly` keeps the booking on screen but ignores picks. -->
<BbCalendar
v-model="stay"
aria-label="Stay"
floating
range
:readonly="booked"
/>
<div class="flex justify-end gap-2">
<BbButton
v-if="booked"
size="sm"
variant="outline"
@click="booked = false"
>
Change dates
</BbButton>
<template v-else>
<BbButton
v-if="stay.length"
size="sm"
variant="ghost"
@click="stay = []"
>
Clear
</BbButton>
<BbButton
:disabled="nights === 0"
size="sm"
variant="primary"
@click="booked = true"
>
{{ bookLabel }}
</BbButton>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbCalendar } from 'bitboss-ui';
import { daysBetween } from '~/demo-data';
// A range is an array at every moment: `[]` empty, `[start]` half-picked,
// `[check-in, check-out]` done. Never `null`.
const stay = ref<string[]>(['2026-10-09', '2026-10-12']);
const booked = ref(false);
const nightlyRate = 128;
const nights = computed(() => {
const [from, to] = stay.value;
return from && to ? daysBetween(from, to) : 0;
});
const bookLabel = computed(() => {
if (nights.value === 0) return 'Book';
return nights.value === 1 ? 'Book 1 night' : `Book ${nights.value} nights`;
});
const shortDay = (day: string): string =>
new Date(`${day}T00:00:00Z`).toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
const summary = computed(() => {
const [from, to] = stay.value;
if (!from) return 'Pick a check-in day';
if (!to) return 'Now pick the check-out day';
return `${shortDay(from)} to ${shortDay(to)}, €${nights.value * nightlyRate}`;
});
</script>
Use multiple for days that do not touch. Each pick adds or removes one day,
and the array stays sorted.
Weekends on call
3 days in the rota
2026-09-052026-09-062026-09-19
<template>
<div
class="grid w-fit gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">Weekends on call</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ onCall.length }} {{ onCall.length === 1 ? 'day' : 'days' }} in the
rota
</p>
</div>
<!-- Every pick toggles one day in or out; the array stays sorted. -->
<BbCalendar
v-model="onCall"
aria-label="Weekends on call"
floating
multiple
:selectable="isWeekend"
/>
<ul class="m-0 flex max-w-64 list-none flex-wrap gap-x-2 gap-y-1 p-0">
<li v-for="day in onCall" :key="day" class="m-0 leading-5">
<code class="text-xs whitespace-nowrap">{{ day }}</code>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCalendar } from 'bitboss-ui';
// `multiple` needs an array from the first render: `[]` when empty.
const onCall = ref<string[]>(['2026-09-05', '2026-09-06', '2026-09-19']);
const isWeekend = (day: string): boolean => {
const weekday = new Date(`${day}T00:00:00Z`).getUTCDay();
return weekday === 0 || weekday === 6;
};
</script>
multiple throws with range or with type="datetime".
Months, years, and times
Set type="month" or type="year" to swap the day grid for months or years. The
values are plain labels (2026-08, 2026), and min and max take the same
shape.
Revenue report
Jun to Aug 2026
<template>
<div
class="grid w-fit gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">Revenue report</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ summary }}
</p>
</div>
<div class="flex flex-wrap items-start gap-4">
<!-- type="year": the value is a plain `YYYY`. A second click on the
picked year would clear it, so the handler keeps the last one. -->
<BbCalendar
aria-label="Report year"
:max="fixedDates.year"
min="2017"
:model-value="year"
type="year"
@update:model-value="pickYear"
/>
<!-- type="month" + range: a pair of `YYYY-MM` labels, bounded to
the chosen year. -->
<BbCalendar
v-model="months"
aria-label="Months"
:max="`${year}-12`"
:min="`${year}-01`"
range
type="month"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbCalendar } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
const year = ref<string>(fixedDates.year);
const months = ref<string[]>(['2026-06', fixedDates.month]);
// A new year starts the report over at its full twelve months.
const pickYear = (next: string | string[] | null) => {
if (typeof next !== 'string' || next === year.value) return;
year.value = next;
months.value = [`${next}-01`, `${next}-12`];
};
const shortMonth = (month: string): string =>
new Date(`${month}-01T00:00:00Z`).toLocaleDateString('en-US', {
month: 'short',
timeZone: 'UTC',
});
const summary = computed(() => {
const [from, to] = months.value;
if (!from) return 'Pick the first month';
if (!to) return 'Now pick the last month';
if (from === to) return `${shortMonth(from)} ${year.value}`;
return `${shortMonth(from)} to ${shortMonth(to)} ${year.value}`;
});
</script>
type="datetime" adds a time rail beside the days. With floating, bind
v-model:time: it is the only place the time is kept. step sets the minutes
between entries. When the calendar's own box is narrower than the grid and the
rail together, such as a phone column, the rail moves under the grid and the
cells keep their size.
Book a consultation
Tue, Sep 15 at 14:30
<template>
<div
class="grid w-fit max-w-full gap-3 sm:rounded-[var(--bb-radius)] sm:border sm:border-[color:var(--bb-border)] sm:p-4"
>
<div>
<p class="m-0 text-sm font-medium">Book a consultation</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ summary }}
</p>
</div>
<!-- type="datetime" puts the time rail beside the grid. With
`floating`, the day stays `YYYY-MM-DD` and the time rides
v-model:time. The time on `min` applies on its own day only:
on the 15th nothing before 13:00 can be picked. In a narrow box
the rail moves under the grid by itself. -->
<BbCalendar
v-model="day"
v-model:time="time"
aria-label="Consultation"
floating
min="2026-09-15T13:00"
:selectable="isWeekday"
:step="15"
type="datetime"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbCalendar } from 'bitboss-ui';
import { fixedTimes } from '~/demo-data';
const day = ref<string | null>('2026-09-15');
const time = ref<string | null>(fixedTimes.slot);
const isWeekday = (date: string): boolean => {
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
return weekday !== 0 && weekday !== 6;
};
const summary = computed(() => {
if (!day.value) return 'Pick a day';
const label = new Date(`${day.value}T00:00:00Z`).toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
return `${label} at ${time.value ?? '--:--'}`;
});
</script>
Give min or max a time, as in 2026-09-15T13:00, and on that day the time
column disables the times outside it. Without floating the time is part of
the value itself. ampm changes only the display, and
seconds adds a seconds column.
Marking days
Use the day:append slot to put a dot or a short label under days that
come from your data. For one fixed date, name the slot after it:
#2026-09-07:append.
Meeting room, 2nd floor
Tue, Sep 15: Supplier call
<template>
<div
class="grid w-fit gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div>
<p class="m-0 text-sm font-medium">Meeting room, 2nd floor</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ summary }}
</p>
</div>
<!-- 36px cells leave room for a one-word label under the day, and the
padding under each cell keeps a mark close to its own number. -->
<BbCalendar
v-model="day"
aria-label="Meeting room day"
floating
pt:day="pb-1.5"
pt:root="[--cell:36px]"
:selectable="isOpen"
>
<!-- Every day, from data: a closure label or a booking dot. -->
<template #day:append="{ item, outside }">
<template v-if="!outside">
<span v-if="closedOn(item)" class="note">Closed</span>
<span v-else-if="bookingOn(item)" aria-hidden="true" class="dot" />
</template>
</template>
<!-- One fixed date, by name: it wins over `day:append` that day. -->
<template #2026-09-07:append>
<span class="note">Holiday</span>
</template>
</BbCalendar>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbCalendar } from 'bitboss-ui';
import { closedDays, dayEvents } from '~/demo-data';
const day = ref<string | null>('2026-09-15');
// The slot hands you the day as its 'YYYY-MM-DD' string, the same key the data uses.
const bookingOn = (item: string): boolean => dayEvents[item] !== undefined;
const closedOn = (item: string): boolean => closedDays.includes(item);
// Refused days are disabled and announced as such: no click guard on top.
const isOpen = (date: string): boolean => {
const weekday = new Date(`${date}T00:00:00Z`).getUTCDay();
return weekday !== 0 && weekday !== 6 && !closedDays.includes(date);
};
const summary = computed(() => {
if (!day.value) return 'Pick a day to see who has the room';
const booking = dayEvents[day.value];
const label = new Date(`${day.value}T00:00:00Z`).toLocaleDateString('en-US', {
weekday: 'short',
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
return booking ? `${label}: ${booking}` : `${label}: free all day`;
});
</script>
<style scoped>
.dot {
background: var(--bb-primary);
border-radius: 999px;
display: block;
height: 0.3125rem;
margin: 0 auto;
width: 0.3125rem;
}
.note {
color: var(--bb-text-muted);
display: block;
font-size: 0.625rem;
line-height: 1;
text-align: center;
}
</style>
A date's own slot wins over day:append on that day. Every week takes the
height of the tallest one, so the month stays even. #day
and #2026-09-07 replace the day number instead. Keep it to dots and short
badges: BbCalendar is not an event calendar.
Next to fields and in forms
In a filter sheet, put the calendar under two BbDatePickerInput fields with
disable-calendar and share one range between them, so no second sheet opens
over the first.
Events
Sep 7 to Sep 11
<template>
<div
class="flex max-w-sm items-center justify-between gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
>
<div class="min-w-0">
<p class="m-0 text-sm font-medium">Events</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">{{ summary }}</p>
</div>
<BbButton
v-bb-off-canvas="'calendar-filter-sheet'"
prepend:icon="lucide:sliders-horizontal"
variant="outline"
>
Filters
</BbButton>
</div>
<!-- On a wide screen the bottom sheet spans the viewport: header, body
and footer share one centred column so they read as one panel. -->
<BbOffCanvas
id="calendar-filter-sheet"
pt:header="mx-auto w-full max-w-sm"
side="bottom"
size="auto"
title="Filter events"
>
<div class="mx-auto grid w-full max-w-sm gap-4">
<!-- The grid below IS the fields' calendar: `disable-calendar` drops
their own trigger, so no second sheet opens over this one. -->
<div class="grid grid-cols-2 gap-3">
<BbDatePickerInput
id="events-from"
v-model="from"
compact
disable-calendar
floating
label="From"
name="from"
/>
<BbDatePickerInput
id="events-to"
v-model="to"
compact
disable-calendar
floating
label="To"
name="to"
/>
</div>
<BbCalendar
aria-label="From and to"
class="justify-self-center"
floating
:model-value="range"
range
@update:model-value="onRange"
/>
</div>
<template #footer="{ close }">
<div class="mx-auto flex w-full max-w-sm justify-end gap-2">
<BbButton variant="ghost" @click="reset">Reset</BbButton>
<BbButton variant="primary" @click="close">Apply</BbButton>
</div>
</template>
</BbOffCanvas>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
BbButton,
BbCalendar,
BbDatePickerInput,
BbOffCanvas,
vBbOffCanvas,
} from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
const from = ref<string | null>(fixedDates.rangeStart);
const to = ref<string | null>(fixedDates.rangeEnd);
const reset = () => {
from.value = null;
to.value = null;
};
/** The calendar's range model, derived from the two fields. */
const range = computed(() =>
[from.value, to.value].filter((day): day is string => day !== null)
);
/** A calendar pick writes both ends back; a first pick starts a new range. */
const onRange = (value: string | string[] | null) => {
const [start = null, end = null] = Array.isArray(value) ? value : [];
from.value = start;
to.value = end;
};
const shortDay = (day: string | null): string =>
day
? new Date(`${day}T00:00:00Z`).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
})
: '…';
const summary = computed(() => {
if (!from.value && !to.value) return 'Any date';
return `${shortDay(from.value)} to ${shortDay(to.value)}`;
});
</script>
BbCalendar does not join a BbForm by itself, so its value is not submitted.
Bind it with useBbFormField and render
the message next to it.
Size and styling
Set --cell on pt:root to resize the whole grid, and restyle the day marks
on pt:day-button, with a state such as :selected or :today when needed.
<template>
<div class="flex flex-wrap items-start gap-6">
<figure class="m-0 grid gap-2">
<BbCalendar v-model="day" aria-label="Default calendar" floating />
<figcaption class="text-xs text-[color:var(--bb-text-muted)]">
Default, 28px cells
</figcaption>
</figure>
<figure class="m-0 grid gap-2">
<!-- `--cell` on the root drives the whole grid; the marks are
restyled on the button inside each day, per state when needed:
here a ring inside the selected mark. -->
<BbCalendar
v-model="day"
aria-label="Large calendar"
:first-day-of-week="0"
floating
pt:day-button="rounded-full"
pt:day-button:selected="font-semibold shadow-[inset_0_0_0_2px_var(--bb-panel)]"
pt:root="[--cell:36px]"
/>
<figcaption class="text-xs text-[color:var(--bb-text-muted)]">
36px cells, round marks, a ringed selection, weeks from Sunday
</figcaption>
</figure>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCalendar } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
const day = ref<string | null>(fixedDates.rangeStart);
</script>
A background on pt:day paints the whole cell square, not the mark inside it.
On a phone layout, raise --cell yourself (for example
pt:root="max-sm:[--cell:40px]"). The picker's bottom sheet enlarges
its days; a calendar in the page does not.
BbDatePicker and BbDatePickerInput forward the same parts to the calendar
they open.
first-day-of-week runs from 0 (Sunday) to 6 and defaults to the locale's week start (Monday in it, Sunday in en).