Use it for
Use BbDatePicker to assign a date from a trigger you own, such as a due-date
cell or booking chip.
Use something else when
BbDatePickerInput: people must type the value or submit it as a labelled form fieldBbTimePicker: the value is a recurring wall-clock time with no dateBbPopover: the panel contains something other than a calendarBbCalendar: the calendar sits in the page, always visible, with no trigger
Decide whether the model represents a calendar label or an instant before building the trigger.
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.
Choose the value first
Use floating for due dates, birthdays, report filters, and other calendar days.
It emits YYYY-MM-DD, with no time or timezone to shift the day.
- Send the supplier invoice Urgent Due date for Send the supplier invoice
- Draft the Q3 report Due date for Draft the Q3 report
- Renew the SSL certificate Due date for Renew the SSL certificate
- Models:
["2026-08-31","2026-09-07",null]
<template>
<ul class="flex max-w-md flex-col divide-y divide-(--bb-border) text-sm">
<li
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between gap-3 py-2"
>
<span class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
{{ task.title }}
<BbBadge v-if="task.urgent" size="xs" variant="destructive">
Urgent
</BbBadge>
</span>
<BbDatePicker
v-model="task.due"
floating
:label="`Due date for ${task.title}`"
>
<!-- `v-bind="props"` is the whole wiring: reference, click and ARIA. -->
<template #activator="{ props }">
<BbButton
class="shrink-0 whitespace-nowrap"
prepend:icon="lucide:calendar"
size="sm"
:variant="task.due ? 'outline' : 'ghost'"
v-bind="props"
>
{{ task.due ? shortDate(task.due) : 'Set due date' }}
</BbButton>
</template>
</BbDatePicker>
</li>
<li class="pt-2 text-xs opacity-70">
Models: <code>{{ JSON.stringify(tasks.map((task) => task.due)) }}</code>
</li>
</ul>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbButton, BbDatePicker } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
type Task = { id: number; title: string; urgent: boolean; due: string | null };
// `floating` keeps each value a plain `YYYY-MM-DD`; `null` is "no date yet".
const tasks = ref<Task[]>([
{
id: 1,
title: 'Send the supplier invoice',
urgent: true,
due: fixedDates.tomorrow,
},
{
id: 2,
title: 'Draft the Q3 report',
urgent: false,
due: fixedDates.rangeStart,
},
{ id: 3, title: 'Renew the SSL certificate', urgent: false, due: null },
]);
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
// A plain label needs no date library: slice the string you already have.
const shortDate = (day: string): string =>
`${MONTHS[Number(day.slice(5, 7)) - 1]} ${Number(day.slice(8, 10))}`;
</script>
Leave floating off when the value is an instant that different timezones may
display differently. The model then carries a local offset; add utc only when
the receiving API requires a Z timestamp. type="month" and type="year"
always emit calendar labels (YYYY-MM and YYYY).
<template>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-medium">Card statement for</span>
<!-- `type="month"` emits `YYYY-MM`; the grid shows the twelve months. -->
<BbDatePicker
v-model="statementMonth"
label="Statement month"
:max="fixedDates.month"
type="month"
>
<template #activator="{ props }">
<BbButton
append:icon="lucide:chevron-down"
size="sm"
variant="outline"
v-bind="props"
>
{{ statementMonth ? monthLabel(statementMonth) : 'Pick a month' }}
</BbButton>
</template>
</BbDatePicker>
<span class="opacity-60">in fiscal year</span>
<!-- `type="year"` emits `YYYY`. Neither type carries a day or a timezone. -->
<BbDatePicker v-model="fiscalYear" label="Fiscal year" type="year">
<template #activator="{ props }">
<BbButton
append:icon="lucide:chevron-down"
size="sm"
variant="outline"
v-bind="props"
>
{{ fiscalYear ? `FY ${fiscalYear}` : 'Pick a year' }}
</BbButton>
</template>
</BbDatePicker>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDatePicker } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
// Calendar labels, so no `floating` is needed: these types always emit them.
const statementMonth = ref<string | null>(fixedDates.month);
const fiscalYear = ref<string | null>(fixedDates.year);
// `YYYY-MM` read as the first of the month in UTC, so the label never depends
// on the reader's timezone.
const monthFormat = new Intl.DateTimeFormat('en-US', {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
const monthLabel = (month: string) =>
monthFormat.format(new Date(`${month}-01T00:00:00Z`));
</script>
Ranges and multiple dates
Use range for one continuous window. Keep its model as an array, including
[] for the empty state.
Model: ["2026-07-01","2026-07-31"]
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">Revenue for</span>
<!-- `max` keeps a report out of the future: later days are disabled. -->
<BbDatePicker
v-model="period"
floating
label="Reporting period"
:max="fixedDates.today"
range
>
<template #activator="{ props }">
<BbButton
prepend:icon="lucide:calendar-range"
size="sm"
variant="outline"
v-bind="props"
>
{{
period.length === 2
? rangeLabel(period[0]!, period[1]!)
: 'Pick a period'
}}
</BbButton>
</template>
</BbDatePicker>
<BbButton
size="sm"
variant="ghost"
@click="period = [fixedDates.monthStart, fixedDates.today]"
>
This month
</BbButton>
<BbButton
size="sm"
variant="ghost"
@click="
period = [fixedDates.previousRangeStart, fixedDates.previousRangeEnd]
"
>
Last month
</BbButton>
<BbButton
:disabled="period.length === 0"
size="sm"
variant="ghost"
@click="period = []"
>
Clear
</BbButton>
</div>
<p class="text-xs opacity-70">
Model: <code>{{ JSON.stringify(period) }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDatePicker } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
// An array at every moment, including the empty one: `[]`, never `null`.
// With `floating`, presets write plain `YYYY-MM-DD` strings straight in.
const period = ref<string[]>([
fixedDates.previousRangeStart,
fixedDates.previousRangeEnd,
]);
// Days read as UTC midnights, so the label never depends on the reader's
// timezone: "Jul 1 – Jul 31, 2026".
const dayFormat = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const short = (day: string) => dayFormat.format(new Date(`${day}T00:00:00Z`));
const rangeLabel = (start: string, end: string) =>
`${short(start)} – ${short(end)}, ${end.slice(0, 4)}`;
</script>
Use multiple for a set of independent days, months, or years.
Office closures
Days the booking calendar turns away.
- Mon, Sep 7
- Mon, Sep 21
- Mon, Oct 12
<template>
<section class="flex max-w-md flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div>
<p class="text-sm font-medium">Office closures</p>
<p class="text-xs opacity-70">Days the booking calendar turns away.</p>
</div>
<!-- The panel stays open while the set is built: the next pick is the
point. Picking a selected day removes it. -->
<BbDatePicker
v-model="closures"
floating
label="Office closures"
multiple
>
<template #activator="{ props }">
<BbButton
prepend:icon="lucide:calendar-off"
size="sm"
variant="outline"
v-bind="props"
>
Edit closures
</BbButton>
</template>
</BbDatePicker>
</div>
<ul v-if="closures.length" class="flex flex-wrap gap-1.5">
<li v-for="day in [...closures].sort()" :key="day">
<BbBadge size="sm" variant="outline">{{ dayLabel(day) }}</BbBadge>
</li>
</ul>
<p v-else class="text-sm opacity-70">
No closures: the model is <code>[]</code>.
</p>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbButton, BbDatePicker } from 'bitboss-ui';
import { closedDays } from '~/demo-data';
// The panel opens on the month of the last day in the array, so September
// (two of the three closures) goes last. Adding a day sorts the array, so
// the list below sorts its own copy. `multiple` refuses to combine with `range`
// or `type="datetime"`: both throw rather than guess.
const [sep7, sep21, oct12] = closedDays as [string, string, string];
const closures = ref<string[]>([oct12, sep7, sep21]);
const dayFormat = new Intl.DateTimeFormat('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const dayLabel = (day: string) =>
dayFormat.format(new Date(`${day}T00:00:00Z`));
</script>
multiple cannot be combined with range or type="datetime"; both
combinations throw.
Date and time
Use type="datetime" when the date and time identify one scheduled occurrence.
Publish the release notes
Not before Sun, Aug 30, 14:00
v-model 2026-08-30 · v-model:time 14:30
<template>
<section class="flex max-w-md flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div>
<p class="text-sm font-medium">Publish the release notes</p>
<p class="text-xs opacity-70">
Not before {{ label(fixedDates.today, earliest) }}
</p>
</div>
<!-- On the `min` day the time rail disables everything before 14:00;
earlier days are disabled in the grid. -->
<BbDatePicker
v-model="day"
v-model:time="time"
floating
label="Publish the release notes"
:min="`${fixedDates.today}T${earliest}`"
:step="15"
type="datetime"
>
<template #activator="{ props }">
<BbButton
class="whitespace-nowrap"
prepend:icon="lucide:calendar-clock"
size="sm"
:variant="day ? 'outline' : 'ghost'"
v-bind="props"
>
{{ day ? label(day, time) : 'Schedule' }}
</BbButton>
</template>
</BbDatePicker>
</div>
<p class="text-xs opacity-70">
<code>v-model</code> {{ day ?? 'null' }} · <code>v-model:time</code>
{{ time ?? 'null' }}
</p>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDatePicker } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
// "Now", pinned for the prerendered docs; your app formats the clock.
// With `type="datetime"` a bound takes `YYYY-MM-DDTHH:mm`.
const earliest = '14:00';
// `floating` keeps the date a plain YYYY-MM-DD, so the time exists only in
// the second model. Drop `v-model:time` and every time picked is thrown away.
// 14:30 opens the rail with the disabled 13:00 just above it.
const day = ref<string | null>(fixedDates.today);
const time = ref<string | null>('14:30');
// The day read as a UTC midnight, so the label never depends on the reader's
// timezone; the time is appended only when there is one.
const dayFormat = new Intl.DateTimeFormat('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const label = (date: string, at: string | null) => {
const formatted = dayFormat.format(new Date(`${date}T00:00:00Z`));
return at ? `${formatted}, ${at}` : formatted;
};
</script>
With floating, bind v-model:time: it is the only place the time is stored.
Without floating, the time is already part of the instant. ampm changes only
the display; the time model remains 24-hour.
Give min and max a time, as in 2026-08-30T14:00, when the bound is a
moment: on that day the time rail disables the times outside it.
Available and marked days
Set a continuous window with min and max, then use selectable for rules
such as weekdays or stock availability.
<template>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<BbDatePicker
v-model="booking"
:first-day-of-week="1"
floating
label="Site visit date"
:max="fixedDates.windowEnd"
:min="fixedDates.windowStart"
:selectable="isBookable"
>
<template #activator="{ props, value }">
<BbButton
prepend:icon="lucide:calendar-check"
size="sm"
variant="outline"
v-bind="props"
>
{{ value ?? 'Book a site visit' }}
</BbButton>
</template>
<!-- `day:append` ADDS below the number; `day` would REPLACE it.
Keep decorations cheap: this runs for all 42 visible cells. -->
<template #day:append="{ item }">
<span v-if="eventOn(item)" class="dot" />
</template>
</BbDatePicker>
<span class="text-sm opacity-70">
{{
booking
? (eventLabel ?? 'Nothing booked that day')
: 'Weekdays only; dots mark team events'
}}
</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbDatePicker } from 'bitboss-ui';
import { closedDays, dayEvents, fixedDates } from '~/demo-data';
const booking = ref<string | null>(null);
// The slot hands you the cell's day as its 'YYYY-MM-DD' string.
const eventOn = (item: string): boolean => dayEvents[item] !== undefined;
const eventLabel = computed(() =>
booking.value ? dayEvents[booking.value] : undefined
);
// Gate with `selectable`, never with a click handler on top of it: a disabled
// day is already announced as disabled, and a parallel handler desyncs them.
const isBookable = (day: string): boolean => {
// Parsed as a UTC midnight so the weekday never depends on the reader's zone.
const weekday = new Date(`${day}T00:00:00Z`).getUTCDay();
return weekday !== 0 && weekday !== 6 && !closedDays.includes(day);
};
</script>
<style scoped>
.dot {
background: currentcolor;
border-radius: 999px;
display: block;
height: 0.25rem;
margin: 0.125rem auto 0;
opacity: 0.7;
width: 0.25rem;
}
</style>
Use day:append for a lightweight marker; each week grows to fit it. Use day only
when you need to replace the day number. For one fixed date, name the slot after
it: #2026-12-25:append. Do not add a parallel click guard: rejected dates are
already disabled and announced as such.
Trigger and mobile surface
The activator slot used above is one of three ways to attach the picker. The
activator prop wires a button you already have. v-bb-date turns a table
cell into a trigger with no wrapper per row.
| Customer | Due |
|---|---|
| Harbour Freight Co. | |
| Lindqvist Studio | |
| Orto Verde |
<template>
<div class="flex max-w-md flex-col gap-4 text-sm">
<table class="w-full">
<caption class="mb-2 text-left font-medium">
Open invoices
</caption>
<thead>
<tr class="text-left text-xs uppercase opacity-60">
<th class="py-1 font-medium" scope="col">Customer</th>
<th class="py-1 text-right font-medium" scope="col">Due</th>
</tr>
</thead>
<tbody>
<tr
v-for="invoice in invoices"
:key="invoice.id"
class="border-t border-(--bb-border)"
>
<td class="py-2">{{ invoice.customer }}</td>
<td class="py-2 text-right">
<!-- No wrapper per row: the directive takes the picker's props. -->
<BbButton
v-bb-date="{
modelValue: invoice.due,
// Typed by mode: one date hands a string or null.
'onUpdate:modelValue': (day) => (invoice.due = day),
floating: true,
label: `Due date for ${invoice.customer}`,
offCanvasProps: { title: 'Due date' },
}"
prepend:icon="lucide:calendar"
size="sm"
variant="ghost"
>
{{ invoice.due ? short(invoice.due) : 'Set date' }}
</BbButton>
</td>
</tr>
</tbody>
</table>
<div
class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-(--bb-border) pt-3"
>
<span>Next reminder run: {{ short(reminder) }}</span>
<BbButton
ref="reminderButton"
prepend:icon="lucide:bell"
size="sm"
variant="outline"
>
Reschedule
</BbButton>
<!-- The button already exists, so the picker attaches to it. -->
<BbDatePicker
v-model="reminder"
:activator="reminderButton"
floating
label="Next reminder run"
:off-canvas-props="{ title: 'Next reminder run' }"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { BbButton, BbDatePicker } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
type Invoice = { id: string; customer: string; due: string | null };
const invoices = ref<Invoice[]>([
{
id: 'inv-2041',
customer: 'Harbour Freight Co.',
due: fixedDates.rangeStart,
},
{ id: 'inv-2042', customer: 'Lindqvist Studio', due: fixedDates.rangeEnd },
{ id: 'inv-2043', customer: 'Orto Verde', due: null },
]);
const reminder = ref<string | null>(fixedDates.tomorrow);
const reminderButton = useTemplateRef('reminderButton');
// Parsed as a UTC midnight so the label never depends on the reader's timezone.
const shortFormat = new Intl.DateTimeFormat('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const short = (day: string | null) =>
day ? shortFormat.format(new Date(`${day}T00:00:00Z`)) : 'not set';
</script>
Each way gives the trigger the click handler, the anchor, the accessible name and
the open state; with the slot, those are the props you bind. Give every picker
a specific label. Screen readers announce the trigger as that label
followed by its own text (“Due date for Lindqvist Studio Fri, Sep 11”), so keep the date visible.
On phones, adaptive opens the calendar in a bottom sheet. Title it through
offCanvasProps, as both triggers here do, or set :adaptive="false" for
viewport-independent tests.