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 buttonBbTimePickerInput: the value is a recurring wall-clock time with no dateBbSelect: the choice is a short fixed list of periods
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Choose the value contract before adding validation or layout.
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.
Pick the same day — say 1 January 2027 — in all three fields and compare what each one emits.
nullnullnull<template>
<div class="flex max-w-md flex-col gap-4">
<p class="text-sm opacity-70">
Pick the same day — say 1 January 2027 — in all three fields and compare
what each one emits.
</p>
<div class="flex flex-col gap-2">
<BbDatePickerInput
id="output-zoned"
v-model="zoned"
compact
label="Default — ISO with the 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 — ISO in UTC, as v2 emitted"
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 — the calendar day alone"
name="floating_day"
/>
<code class="text-xs opacity-70">{{ floatingDay ?? 'null' }}</code>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { 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);
</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.
Model: "2026-08-30"
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbDatePickerInput
id="invoice-issued-at"
v-model="issuedAt"
floating
label="Issue date"
name="issued_at"
prepend:icon="lucide:calendar"
/>
<p class="text-sm opacity-70">
Model: <code>{{ issuedAt === null ? 'null' : `"${issuedAt}"` }}</code>
</p>
</div>
</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);
</script>
The field uses separate day, month, and year inputs. Typing auto-advances, separators move to the next segment, and a pasted date is distributed across them. Incomplete input does not update the model.
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"
prepend:icon="lucide:calendar-range"
range
/>
<p class="text-sm opacity-70">
<template v-if="stay.length === 2">
{{ nights }} night{{ nights === 1 ? '' : 's' }} —
<code>{{ JSON.stringify(stay) }}</code>
</template>
<template v-else>
No dates yet — the model is <code>[]</code>, never <code>null</code>.
</template>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { daysBetween, fixedDates } from '~/demo-data';
// Range mode needs an array at every moment, including the empty one. Seeding
// `null` here throws at setup.
const stay = ref<string[]>([fixedDates.rangeStart, fixedDates.rangeEnd]);
// `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(() =>
stay.value.length === 2 ? daysBetween(stay.value[0]!, stay.value[1]!) : 0
);
</script>
Use [] for an empty range; null and scalar values throw during setup. 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 between 2026-08-30 and 2026-10-31. Closed: 2026-09-07, 2026-09-21, 2026-10-12.
<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."
:first-day-of-week="1"
floating
label="Appointment"
:max="fixedDates.windowEnd"
:min="fixedDates.windowStart"
name="appointment"
prepend:icon="lucide:calendar-check"
:selectable="isBookable"
/>
<p class="text-sm opacity-70">
Bookable between <code>{{ fixedDates.windowStart }}</code> and
<code>{{ fixedDates.windowEnd }}</code
>. Closed: <code>{{ closedDays.join(', ') }}</code
>.
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbDatePickerInput } from 'bitboss-ui';
import { closedDays, fixedDates } from '~/demo-data';
const appointment = ref<string | null>(null);
// Parsed as a UTC midnight so the weekday never depends on the reader's
// timezone — and never on the clock.
const isWeekend = (day: string): boolean => {
const weekday = new Date(`${day}T00:00:00Z`).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);
</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.
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"
prepend:icon="lucide:calendar-clock"
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; otherwise the selected time has no carrier.
The companion model is always a 24-hour HH:mm or HH:mm:ss string.
Migrating from v2
Coming from v2Renamed and removed date-field props
- Replace
datetimewithtype="datetime". - Replace
allow-writingwith the inverteddisable-writing. - Replace
hide-popoverwithdisable-calendar. - Replace
offcanvas-propswithoff-canvas-props. - 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.
adaptive opens the calendar in a bottom sheet on mobile. Pass options through
offCanvasProps, or set :adaptive="false" for viewport-independent tests.
Shared label, message, state, and validated-field props follow
BbTextInput.