Range Calendar
Dates & Timeimport { RangeCalendar } from "bits-ui" Booking range
A two-month availability view with formatted start and end values
Check in
Check out
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
30 | 31 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
27 | 28 | 29 | 30 | 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
1 | 2 | 3 | 4 | 5 | 6 | 7 |
<script lang="ts">
import { RangeCalendar } from 'bits-ui';
import {
CalendarDate,
DateFormatter,
getLocalTimeZone,
today,
type DateValue
} from '@internationalized/date';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
type DateRange = {
start: DateValue | undefined;
end: DateValue | undefined;
};
const timeZone = getLocalTimeZone();
const current = today(timeZone);
const start = current.add({ days: 2 });
const placeholder = new CalendarDate(start.year, start.month, 1);
const minValue: DateValue = current;
const maxValue: DateValue = current.add({ days: 60 });
const unavailableDates = new Set([
current.add({ days: 9 }).toString(),
current.add({ days: 10 }).toString(),
current.add({ days: 17 }).toString()
]);
const formatter = new DateFormatter('en-US', { month: 'short', day: 'numeric' });
let value = $state<DateRange>({
start,
end: current.add({ days: 6 })
});
function isDateUnavailable(date: DateValue) {
return unavailableDates.has(date.toString());
}
function formatDate(date: DateValue | undefined) {
return date ? formatter.format(date.toDate(timeZone)) : 'Choose a date';
}
</script>
<div class="grid gap-4">
<div class="grid gap-2 sm:grid-cols-2">
<div class="card p-3">
<p class="muted text-[0.7rem] font-medium tracking-wider uppercase">Check in</p>
<output class="mt-1 block text-sm font-semibold text-ink-50">{formatDate(value.start)}</output>
</div>
<div class="card p-3">
<p class="muted text-[0.7rem] font-medium tracking-wider uppercase">Check out</p>
<output class="mt-1 block text-sm font-semibold text-ink-50">{formatDate(value.end)}</output>
</div>
</div>
<RangeCalendar.Root
bind:value
{placeholder}
{minValue}
{maxValue}
{isDateUnavailable}
numberOfMonths={2}
pagedNavigation
fixedWeeks
calendarLabel="Choose check-in and check-out dates"
class="panel w-full p-4"
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="mb-4 flex items-center justify-between">
<RangeCalendar.PrevButton class="btn btn-ghost btn-sm size-9 p-0" aria-label="Previous two months">
<ChevronLeft class="size-4" aria-hidden="true" />
</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="text-sm font-semibold text-ink-50" />
<RangeCalendar.NextButton class="btn btn-ghost btn-sm size-9 p-0" aria-label="Next two months">
<ChevronRight class="size-4" aria-hidden="true" />
</RangeCalendar.NextButton>
</RangeCalendar.Header>
<div class="grid gap-6 sm:grid-cols-2">
{#each months as month (month.value.toString())}
<RangeCalendar.Grid class="w-full table-fixed border-collapse">
<RangeCalendar.GridHead>
<RangeCalendar.GridRow>
{#each weekdays as day}
<RangeCalendar.HeadCell class="h-7 text-center text-[0.65rem] font-medium text-ink-600">
{day}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates, weekIndex (weekIndex)}
<RangeCalendar.GridRow>
{#each weekDates as date (date.toString())}
<RangeCalendar.Cell {date} month={month.value} class="p-0 text-center">
<RangeCalendar.Day
class="inline-flex size-8 items-center justify-center rounded-md text-xs text-ink-200 outline-none transition hover:bg-ink-800 focus-visible:ring-2 focus-visible:ring-brand-500 data-[disabled]:pointer-events-none data-[disabled]:opacity-30 data-[outside-month]:text-ink-700 data-[range-middle]:bg-brand-600/25 data-[range-middle]:text-brand-400 data-[range-start]:bg-brand-500 data-[range-start]:font-semibold data-[range-start]:text-ink-950 data-[range-end]:bg-brand-500 data-[range-end]:font-semibold data-[range-end]:text-ink-950 data-[selection-start]:ring-2 data-[selection-start]:ring-brand-400 data-[unavailable]:text-accent-500 data-[unavailable]:line-through data-[today]:ring-1 data-[today]:ring-brand-400"
/>
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
</div>
{/snippet}
</RangeCalendar.Root>
<div class="flex flex-wrap gap-2 text-xs">
<span class="chip"><span class="size-2 rounded-full bg-brand-500"></span>Range boundary</span>
<span class="chip"><span class="size-2 rounded-full bg-brand-600/50"></span>Stay</span>
<span class="chip"><span class="size-2 rounded-full bg-accent-500"></span>Sold out</span>
</div>
</div>
Length and exclusion rules
minDays and maxDays bound the stay; excludeDisabled rejects ranges that cross maintenance
Workshop residency
Sep 2 – Sep 6
Select between 3 and 7 days. The maintenance date cannot be crossed.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
30 | 31 | 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 1 | 2 | 3 |
<script lang="ts">
import { RangeCalendar } from 'bits-ui';
import {
CalendarDate,
DateFormatter,
getLocalTimeZone,
today,
type DateValue
} from '@internationalized/date';
import { ChevronLeft, ChevronRight, RotateCcw } from '@lucide/svelte';
type DateRange = {
start: DateValue | undefined;
end: DateValue | undefined;
};
const timeZone = getLocalTimeZone();
const current = today(timeZone);
const suggestedStart: DateValue = current.add({ days: 2 });
const suggestedEnd: DateValue = current.add({ days: 6 });
const placeholder = new CalendarDate(
suggestedStart.year,
suggestedStart.month,
1
);
const maintenanceDate: DateValue = current.add({ days: 9 });
const formatter = new DateFormatter('en-US', { month: 'short', day: 'numeric' });
let value = $state<DateRange>({ start: suggestedStart, end: suggestedEnd });
function isDateDisabled(date: DateValue) {
return date.toString() === maintenanceDate.toString();
}
function formatDate(date: DateValue | undefined) {
return date ? formatter.format(date.toDate(timeZone)) : 'Open';
}
function resetSuggestion() {
value = { start: suggestedStart, end: suggestedEnd };
}
</script>
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_18rem] sm:items-start">
<div class="grid gap-3">
<div class="card p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="muted text-xs font-medium tracking-wider uppercase">Workshop residency</p>
<p class="mt-1 text-base font-semibold text-ink-50">
{formatDate(value.start)} – {formatDate(value.end)}
</p>
</div>
<button class="btn btn-ghost btn-sm" onclick={resetSuggestion} aria-label="Restore suggested range">
<RotateCcw class="size-3.5" aria-hidden="true" />
Reset
</button>
</div>
<p class="muted mt-3 text-xs">
Select between 3 and 7 days. The maintenance date cannot be crossed.
</p>
</div>
<div class="flex flex-wrap gap-2 text-xs">
<span class="chip">3-day minimum</span>
<span class="chip">7-day maximum</span>
<span class="chip">Maintenance: {formatDate(maintenanceDate)}</span>
</div>
</div>
<RangeCalendar.Root
bind:value
{placeholder}
minValue={current}
minDays={3}
maxDays={7}
{isDateDisabled}
excludeDisabled
calendarLabel="Choose workshop residency dates"
class="panel w-full p-4"
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="mb-4 flex items-center justify-between">
<RangeCalendar.PrevButton class="btn btn-ghost btn-sm size-9 p-0" aria-label="Previous month">
<ChevronLeft class="size-4" aria-hidden="true" />
</RangeCalendar.PrevButton>
<RangeCalendar.Heading class="text-sm font-semibold text-ink-50" />
<RangeCalendar.NextButton class="btn btn-ghost btn-sm size-9 p-0" aria-label="Next month">
<ChevronRight class="size-4" aria-hidden="true" />
</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as month (month.value.toString())}
<RangeCalendar.Grid class="w-full table-fixed border-collapse">
<RangeCalendar.GridHead>
<RangeCalendar.GridRow>
{#each weekdays as day}
<RangeCalendar.HeadCell class="h-7 text-center text-[0.65rem] font-medium text-ink-600">
{day}
</RangeCalendar.HeadCell>
{/each}
</RangeCalendar.GridRow>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each month.weeks as weekDates, weekIndex (weekIndex)}
<RangeCalendar.GridRow>
{#each weekDates as date (date.toString())}
<RangeCalendar.Cell {date} month={month.value} class="p-0 text-center">
<RangeCalendar.Day
class="inline-flex size-8 items-center justify-center rounded-md text-xs text-ink-200 outline-none transition hover:bg-ink-800 focus-visible:ring-2 focus-visible:ring-brand-500 data-[disabled]:pointer-events-none data-[disabled]:text-ink-700 data-[disabled]:line-through data-[outside-month]:text-ink-700 data-[range-middle]:bg-brand-600/25 data-[range-middle]:text-brand-400 data-[range-start]:bg-brand-500 data-[range-start]:font-semibold data-[range-start]:text-ink-950 data-[range-end]:bg-brand-500 data-[range-end]:font-semibold data-[range-end]:text-ink-950 data-[highlighted]:bg-ink-800 data-[today]:ring-1 data-[today]:ring-brand-400"
/>
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Root>
</div>
The bound value is a pair of immutable DateValue objects from @internationalized/date: { start, end }. Either endpoint can be undefined while a range is being composed. The placeholder is separate navigation state that controls the visible month; it is not either selected endpoint in value.
excludeDisabled resets a candidate range that contains a date returned by isDateDisabled. It does not apply to isDateUnavailable dates; use the disabled predicate when a range must not cross the blocked date. Range boundaries and middle days expose separate data attributes for styling.