<Calendar />Installation
npx bearnie add calendarbunx bearnie add calendarpnpm dlx bearnie add calendaryarn dlx bearnie add calendarUsage
import { Calendar } from "@/components/bearnie/calendar"<Calendar defaultValue="2026-09-15" name="date" />Clicking a day selects it (clicking again deselects). With a name, a hidden input carries the ISO date so the calendar works inside plain HTML forms. Navigate with the arrow keys, jump months with PageUp/PageDown, and select with Enter or Space.
Examples
Range selection
mode="range" selects a from/to pair: the first click sets the start, the second sets the end, and a third starts over.
<Calendar mode="range" defaultFrom="2026-09-07" defaultTo="2026-09-11" name="stay" />Two months side by side
months={2} shows linked months next to each other (stacked on small screens) — the booking-site layout for picking a stay.
<Calendar mode="range" months={2} defaultFrom="2026-09-24" defaultTo="2026-10-02" name="trip" />Range picker with presets
The calendar listens for a calendar-set CustomEvent, so preset buttons (“Last 7 days”, “Month to date”…) can drive the selection from outside. Compose it with a sidebar and a footer for the full analytics-style range picker.
<div class="flex w-max flex-col overflow-hidden rounded-lg border bg-background">
<div class="flex">
<div class="flex flex-col gap-0.5 border-r p-2">
<button type="button" data-range-preset="0" class="rounded-md px-2.5 py-1.5 text-left text-sm hover:bg-accent data-active:bg-primary data-active:text-primary-foreground">Today</button>
<button type="button" data-range-preset="7" class="...">Last 7 days</button>
<button type="button" data-range-preset="30" class="...">Last 30 days</button>
<button type="button" data-range-preset="mtd" class="...">Month to date</button>
<button type="button" data-range-preset="ytd" class="...">Year to date</button>
</div>
<Calendar id="range-presets-calendar" mode="range" months={2} class="rounded-none border-0" />
</div>
<div class="flex items-center gap-2 border-t p-3">
<span data-range-label class="mr-auto text-sm text-muted-foreground"></span>
<Button variant="outline" size="sm">Cancel</Button>
<Button size="sm">Apply</Button>
</div>
</div>
<script>
const cal = document.getElementById("range-presets-calendar");
const buttons = [...document.querySelectorAll("[data-range-preset]")];
const label = document.querySelector("[data-range-label]");
const iso = (d) => d.toISOString().slice(0, 10);
let fromPreset = false;
function applyPreset(preset, btn) {
const to = new Date();
let from = new Date(to);
if (preset === "mtd") from = new Date(to.getFullYear(), to.getMonth(), 1);
else if (preset === "ytd") from = new Date(to.getFullYear(), 0, 1);
else from.setDate(from.getDate() - Number(preset));
fromPreset = true;
cal.dispatchEvent(new CustomEvent("calendar-set", {
detail: { from: iso(from), to: iso(to) },
}));
buttons.forEach((b) => b.toggleAttribute("data-active", b === btn));
}
buttons.forEach((btn) =>
btn.addEventListener("click", () =>
applyPreset(btn.getAttribute("data-range-preset"), btn),
),
);
// Clear the active preset when the user picks days manually
cal.addEventListener("calendar-change", (e) => {
const { from, to } = e.detail;
if (label) label.textContent = from && to
? `${new Date(from).toLocaleDateString()} – ${new Date(to).toLocaleDateString()}`
: "Pick a range";
if (!fromPreset) buttons.forEach((b) => b.removeAttribute("data-active"));
fromPreset = false;
});
</script>Min and max
Days outside the bounds are disabled, and month navigation stops at the edges.
<Calendar min="2026-09-05" max="2026-10-20" defaultValue="2026-09-10" />Date picker
Compose with Popover for a date picker. Listen for calendar-change to update the trigger label.
<Popover>
<PopoverTrigger>
<Button variant="outline" id="date-picker-label">Pick a date</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar class="border-0" id="date-picker-calendar" />
</PopoverContent>
</Popover>
<script>
document
.getElementById("date-picker-calendar")
?.addEventListener("calendar-change", (e) => {
const { date } = (e as CustomEvent).detail;
const label = document.getElementById("date-picker-label");
if (label && date) label.textContent = new Date(date).toLocaleDateString();
});
</script>Week starts on Sunday
<Calendar weekStartsOn={0} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| mode | “single” | “range” | “single” | Select one date or a from/to pair |
| months | number | 1 | Months shown side by side (stacked on small screens) |
| defaultValue | string | - | Preselected date, ISO format YYYY-MM-DD (single mode) |
| defaultFrom / defaultTo | string | - | Preselected range bounds, ISO format (range mode) |
| min / max | string | - | Earliest/latest selectable date, ISO format |
| weekStartsOn | 0 | 1 | 1 | First day of the week (0 = Sunday, 1 = Monday) |
| name | string | - | Form field name — renders hidden inputs (name, or name-from/name-to in range mode) |
| class | string | - | Additional CSS classes |
Events
The calendar dispatches a bubbling calendar-change CustomEvent on every selection: detail is { date } in single mode and { from, to } in range mode, all ISO strings (or null).
It also listens for a calendar-set CustomEvent to set the selection programmatically — dispatch it on the calendar element with detail: { date } (single mode) or detail: { from, to } (range mode). The view jumps to show the new selection and a calendar-change event fires in response.
Accessibility
- The grid uses
role="grid"with labelledgridcellbuttons andaria-selectedon chosen days. - Roving tabindex: Tab enters the grid once, ArrowLeft/Right/Up/Down move by day and week, PageUp/PageDown by month, Home/End to the start/end of the week, Enter/Space selects.
- Month and weekday names come from the visitor’s locale, and the month label announces changes via
aria-live.