Use it for
Use BbDatePickerInput when people type or pick a date in a labelled form field.
Use something else when
BbDatePicker: the calendar opens from your own cell, chip, or buttonBbCalendar: the calendar sits in the page, always visible, with no field around itBbTimePickerInput: the value is a recurring wall-clock time with no dateBbSelect: the choice is a short fixed list of periods
Also see: Validated forms.
Choose the value contract before adding validation or layout.
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.
Calendar day or instant
Use floating when “2027-01-01” must stay that day everywhere. Use the default
offset timestamp for an instant, and utc only when the receiving API requires
Z.
Same day, three contracts.
nullnullnull<template>
<div class="flex max-w-md flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<p class="text-sm opacity-70">Same day, three contracts.</p>
<BbButton size="sm" variant="outline" @click="fillAll">
Fill all with Jan 1, 2027
</BbButton>
</div>
<div class="flex flex-col gap-2">
<BbDatePickerInput
id="output-zoned"
v-model="zoned"
compact
label="Default (local offset)"
name="zoned"
/>
<code class="text-xs opacity-70">{{ zoned ?? 'null' }}</code>
</div>
<div class="flex flex-col gap-2">
<BbDatePickerInput
id="output-utc"
v-model="asUtc"
compact
label="utc"
name="as_utc"
utc
/>
<code class="text-xs opacity-70">{{ asUtc ?? 'null' }}</code>
</div>
<div class="flex flex-col gap-2">
<BbDatePickerInput
id="output-floating"
v-model="floatingDay"
compact
floating
label="floating"
name="floating_day"
/>
<code class="text-xs opacity-70">{{ floatingDay ?? 'null' }}</code>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbDatePickerInput } from 'bitboss-ui';
// All three start empty on purpose. A seeded zoned value is displayed in the
// reader's own timezone, so the prerendered HTML and the hydrated page would
// disagree about the digits and, near midnight, about the day. Seed
// `floating` values or nothing at all in a server-rendered page.
const zoned = ref<string | null>(null);
const asUtc = ref<string | null>(null);
const floatingDay = ref<string | null>(null);
const pad = (n: number) => String(n).padStart(2, '0');
// Runs on a click, in the browser, so it can use the reader's timezone: it
// writes the strings each field emits for local midnight on 1 January 2027.
const fillAll = () => {
const midnight = new Date(2027, 0, 1);
const offset = -midnight.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = pad(Math.floor(Math.abs(offset) / 60));
zoned.value = `2027-01-01T00:00:00${sign}${hours}:${pad(Math.abs(offset) % 60)}`;
asUtc.value = midnight.toISOString();
floatingDay.value = '2027-01-01';
};
</script>
The default emits a local-offset ISO timestamp. utc emits the same instant in
UTC. floating emits only YYYY-MM-DD; it cannot be combined meaningfully with
utc.
Coming from v2The default timezone output changed
v2 emitted UTC Z timestamps. v3 emits the local offset by default. Add utc
to preserve the v2 wire format, or choose floating for a calendar day. Reading
accepts all three forms, so stored values do not need rewriting.
For server-rendered pages, seed a floating value or start empty. A zoned instant
may display different local digits on the server and in the browser.
A date field
Use a stable id, a visible label, and name when native form submission or
FormData must include the value.
<template>
<form class="grid max-w-md gap-4 sm:grid-cols-2" @submit.prevent>
<BbDatePickerInput
id="invoice-issued-at"
v-model="issuedAt"
floating
label="Issue date"
name="issued_at"
/>
<!-- `min` follows the issue date, so earlier days are disabled. -->
<BbDatePickerInput
id="invoice-due-at"
v-model="dueAt"
clearable
floating
hint="Net 30 unless agreed otherwise."
label="Due date"
:min="issuedAt ?? undefined"
name="due_at"
/>
<BbDatePickerInput
id="invoice-created-at"
class="sm:col-span-2"
description="Set by the system when the draft was saved."
floating
label="Created"
model-value="2026-08-28"
name="created_at"
readonly
/>
<p class="text-xs opacity-70 sm:col-span-2">
Models: <code>{{ JSON.stringify({ issuedAt, dueAt }) }}</code>
</p>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
// `fixedDates.today` is a pinned constant, not the clock. Every page on this
// site is prerendered, so a demo that read the real today would put one date
// in the static HTML and another in the hydrated page. Your application reads
// the clock; a documentation demo cannot.
const issuedAt = ref<string | null>(fixedDates.today);
const dueAt = ref<string | null>('2026-09-29');
</script>
Bind min to another field, as the due date does here, and use
readonly for dates the system sets.
Inside a BbForm the field joins the form by its name and checks itself with
rules; formless keeps a filter date out of the submitted values.
To require it, add rules="required", and the asterisk follows. Inside a form the required prop is a hint that marks the field and checks nothing; outside one it is the native attribute.
Label, message and state props work as on BbTextInput.
variant="none" drops every colour, focus ring included, for a field you style yourself, like the flat search field on BbTextInput.
Date ranges
Set range for a check-in/check-out window and keep the model as an array.
4 nights: ["2026-09-07","2026-09-11"]
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbDatePickerInput
id="stay-dates"
v-model="stay"
clearable
floating
label="Stay dates"
:min="fixedDates.today"
name="stay"
placeholder="Check-in → check-out"
range
/>
<p class="text-sm opacity-70">
<template v-if="days.length === 2">
{{ nights }} night{{ nights === 1 ? '' : 's' }}:
<code>{{ JSON.stringify(days) }}</code>
</template>
<template v-else> No dates yet. </template>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { daysBetween, fixedDates } from '~/demo-data';
const stay = ref<string[] | null>([fixedDates.rangeStart, fixedDates.rangeEnd]);
const days = computed(() => stay.value ?? []);
// `daysBetween` parses two YYYY-MM-DD strings as UTC midnights, so it never
// reads the clock and never depends on the reader's timezone. In an app this
// is one dayjs call.
const nights = computed(() =>
days.value.length === 2 ? daysBetween(days.value[0]!, days.value[1]!) : 0
);
</script>
Seed and clear it with []. A reversed typed range is swapped on commit and
emits end_before_start.
Bounds and invalid input
Use min and max for a continuous window and selectable for rules such as
weekdays or closures.
Bookable Aug 30 to Oct 31. Closed Sep 7, Sep 21, Oct 12.
Type 09/07/2026 and leave the field: the closure is cleared and the field says why.
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbDatePickerInput
id="appointment"
v-model="appointment"
description="Weekdays only, and not on a company closure."
:errors="message ? [message] : []"
:first-day-of-week="1"
floating
label="Appointment"
:max="fixedDates.windowEnd"
:min="fixedDates.windowStart"
name="appointment"
:selectable="isBookable"
@active="message = ''"
@error="onError"
/>
<p class="text-sm opacity-70">
Bookable {{ short(fixedDates.windowStart) }} to
{{ short(fixedDates.windowEnd) }}. Closed
{{ closedDays.map(short).join(', ') }}.
</p>
<p class="text-xs opacity-60">
Type <code>09/07/2026</code> and leave the field: the closure is cleared
and the field says why.
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import type { DatePickerInputError } from 'bitboss-ui';
import { closedDays, fixedDates } from '~/demo-data';
const appointment = ref<string | null>(null);
// Cleared on `active` rather than on the next value change: the reset that
// produced the message *is* a value change.
const message = ref('');
// Parsed as a UTC midnight so the weekday never depends on the reader's
// timezone, and never on the clock.
const asUtc = (day: string) => new Date(`${day}T00:00:00Z`);
const shortFormat = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
const short = (day: string) => shortFormat.format(asUtc(day));
const isWeekend = (day: string): boolean => {
const weekday = asUtc(day).getUTCDay();
return weekday === 0 || weekday === 6;
};
// `min` and `max` draw the window; `selectable` handles everything the window
// cannot express. It receives the same string shape the field emits.
const isBookable = (day: string): boolean =>
!isWeekend(day) && !closedDays.includes(day);
// A day `selectable` rejects has nowhere to snap to, so `normalized` is null;
// a day outside `min`/`max` snaps to the nearest bound.
const onError = (error: DatePickerInputError) => {
if (error.code !== 'disabled_date') return;
message.value = error.normalized
? `${short(error.original)} is outside the booking window, so it moved to ${short(error.normalized)}.`
: `${short(error.original)} ${closedDays.includes(error.original) ? 'is a company closure' : 'falls on a weekend'}. Pick another weekday.`;
};
</script>
Malformed bounds throw during setup. A typed disabled date is normalized on
commit and reported through error; turn that payload into an application
message with errors. Clear the message on active, not on the model update
that normalization itself caused.
Other calendar values
Set type="month" or type="year" for calendar labels. They emit YYYY-MM or
YYYY, force floating, and ignore timezone props.
Models: {"expiry":"2028-03","founded":"2014"}
<template>
<div class="grid max-w-md gap-4 sm:grid-cols-2">
<!-- An expired card is not a choice: `min` is the current month. -->
<BbDatePickerInput
id="card-expiry"
v-model="expiry"
label="Card expiry"
:min="fixedDates.month"
name="card_expiry"
type="month"
/>
<BbDatePickerInput
id="company-founded"
v-model="founded"
label="Year founded"
:max="fixedDates.year"
name="founded"
type="year"
/>
<p class="text-xs opacity-70 sm:col-span-2">
Models: <code>{{ JSON.stringify({ expiry, founded }) }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
// Calendar labels: `YYYY-MM` and `YYYY`, with no day and no timezone.
const expiry = ref<string | null>('2028-03');
const founded = ref<string | null>('2014');
</script>
Use type="datetime" only when a time belongs to a specific date.
v-model → 2026-08-31 · v-model:time → 09:30
12-hour fields, quarter-hour column — and still 14:30 in the model.
<template>
<div class="flex max-w-md flex-col gap-4">
<div class="flex flex-col gap-2">
<BbDatePickerInput
id="reminder"
v-model="reminderDay"
v-model:time="reminderTime"
floating
label="Reminder"
name="reminder"
type="datetime"
/>
<p class="text-xs opacity-70">
<code>v-model</code> → {{ reminderDay ?? 'null' }} ·
<code>v-model:time</code> → {{ reminderTime ?? 'null' }}
</p>
</div>
<div class="flex flex-col gap-2">
<BbDatePickerInput
id="pickup"
v-model="pickupDay"
v-model:time="pickupTime"
ampm
floating
label="Pickup slot"
name="pickup"
:step="15"
type="datetime"
/>
<p class="text-xs opacity-70">
12-hour fields, quarter-hour column — and still
<code>{{ pickupTime ?? 'null' }}</code> in the model.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { fixedDates, fixedTimes } from '~/demo-data';
// Under `floating` the date string stays plain YYYY-MM-DD, so `v-model:time`
// is the only place the time exists. Drop it and the time is silently lost.
const reminderDay = ref<string | null>(fixedDates.tomorrow);
const reminderTime = ref<string | null>(fixedTimes.standup);
// `ampm` changes the fields, never the model: this stays 24-hour.
const pickupDay = ref<string | null>(fixedDates.tomorrow);
const pickupTime = ref<string | null>(fixedTimes.slot);
</script>
With floating, bind v-model:time, or the picked time is lost.
The companion model is always a 24-hour HH:mm or HH:mm:ss string. A bound
with a time, such as min="2026-08-30T14:00", disables the earlier times on
that day.
Typing and mobile
Use disable-calendar where typing is faster than paging through months,
as for a birth date, and disable-writing="mobile" where touch users should pick
from the calendar.
<template>
<form class="grid max-w-md gap-4 sm:grid-cols-2" @submit.prevent>
<!-- A birth date is faster to type than to reach by paging back
through decades, so the calendar is switched off everywhere. -->
<BbDatePickerInput
id="traveller-birth-date"
v-model="birthDate"
disable-calendar
floating
hint="As printed on your passport."
label="Date of birth"
:max="fixedDates.today"
name="birth_date"
/>
<!-- Typing off on phones, where the calendar sheet is easier than
three small segments; typing on wherever there is a keyboard. -->
<BbDatePickerInput
id="traveller-departure"
v-model="departure"
disable-writing="mobile"
floating
label="Departure"
:min="fixedDates.today"
name="departure"
:off-canvas-props="{ title: 'Departure date' }"
/>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { fixedDates } from '~/demo-data';
const birthDate = ref<string | null>('1988-04-17');
const departure = ref<string | null>(fixedDates.rangeStart);
</script>
Segments follow the plugin's locale: month first for en, day first otherwise.
On phones, adaptive opens the calendar in a bottom sheet. Pass sheet options
through offCanvasProps, or set :adaptive="false" for viewport-independent
tests.
Coming from v2Renamed and removed date-field props
- Replace
datetimewithtype="datetime". - Replace
allow-writingwith the inverteddisable-writing. - Replace
hide-popoverwithdisable-calendar. - Remove
width; constrain the calendar with CSS only when needed. - Replace
has-warningwithhas-warnings.
The v2 token not-mobile becomes mobile for the inverted writing prop and
desktop for disable-calendar.
The adaptive mobile sheet is now enabled by default. Set :adaptive="false" to
retain a popover on every viewport.