Date Picker

A dual-month date range picker built on React Aria's RangeCalendar. A quick-select sidebar (Today, Last week, This month, etc.), two adjacent months, and a footer with an editable date range and Cancel/Apply — the calendar only commits a new range once Apply is pressed. A single-date variant (below) shares the same month panel, day-cell rendering, and editable chip.

Meeting scheduler

A Calendly/Cal.com-style booking panel — Figma node 3881:7618. Host card and meeting summary on the left, a single-month calendar in the middle (the same MonthPanel from shared.tsx), and a timezone / hour-format / time-slot sidebar on the right. Not a popover — a disclosure: the "Schedule a meeting" trigger toggles the whole panel open/closed, with the time-slot list scrolling within a fixed height instead of growing the panel.

Installation

You can add these date pickers using our CLI or manually. Both DateRangePicker and DatePicker import their shared chrome (chevrons, day cell, month panel, editable date chip, trigger/popover surfaces) from shared.tsx, so add that file first.

The CLI copies the component source (with its internal dependencies) into your project and installs any required npm packages.

npx boardui@latest add date-range-picker

Quick select

The sidebar presets are computed from @internationalized/dateeach time the popover opens (so "Today" stays today). Selecting one updates the pending range and highlights the two months to match — it still requires Apply to commit, same as dragging a range directly on the calendar.

// Computed with @internationalized/date, re-derived each time the popover opens:Today, Yesterday, Last week, This month, Last month, This year, Last year, All time // Clicking a preset sets the *pending* range (Apply still commits it):<button onClick={() => setPendingValue(preset.range)}>{preset.label}</button>

date-range-picker.tsx

// components/base/date-picker/date-range-picker.tsx"use client"; import { useMemo, useState } from "react";import { Button as AriaButton, Dialog, DialogTrigger, Popover, RangeCalendar,} from "react-aria-components";import { CalendarDate, endOfMonth, endOfYear, getLocalTimeZone, isSameDay, startOfMonth, startOfYear, today,} from "@internationalized/date";import { RiCalendarLine } from "@remixicon/react";import { AnimatePresence, motion } from "motion/react";import { Button } from "@/components/base/buttons/button";import { DateChipInput, MonthPanel, formatTriggerDate, popoverClassName, triggerButtonClassName,} from "@/components/base/date-picker/shared";import { cx } from "@/utils/cx"; /** * Figma source: Board UI → "Calendar" (node 3871:5738). * * A dual-month date range picker: trigger button opens a popover with a * quick-select sidebar, two adjacent months (react-aria's * `visibleDuration={{ months: 2 }}`), and a footer with read-only date chips, * a "N days selected" pill, and Cancel/Apply. * * Two deliberate departures from the raw Figma frame: * - The prev/next chevrons in Figma appear on *both* sides of *both* month * headers (4 total), pre-rotated generic vector exports. A dual-month * calendar only has one navigation state (the two months always stay * adjacent), so only the outer two chevrons (prev on month 1, next on * month 2) are wired up; the inner two slots are kept as invisible * spacers rather than dead click targets, preserving Figma's header * spacing without fake buttons. * - The connecting range background (Figma: one hardcoded `bg-blue-100` * rectangle sized for the one example range shown) is generalized into a * per-cell layered background driven by react-aria's `isSelectionStart` / * `isSelectionEnd` state, so any range length/position renders correctly. * * Colors/radii/spacing (blue-100/300/500/600, radius/2lg/xl/2xl/3xl, * shadow-xs, background/secondary/default, background/tertiary/default, * border/button/default) are Tailwind v4 defaults or existing semantic * tokens — see styles/theme.css. * * Shared chrome (chevrons, day cell, month panel, editable date chip, trigger * + popover surfaces) lives in `./shared` — `DatePicker` (single-month, * non-range) reuses the same pieces. */ export interface DateRangeValue { start: CalendarDate; end: CalendarDate;} export interface DateRangePickerProps { value?: DateRangeValue | null; defaultValue?: DateRangeValue | null; onChange?: (value: DateRangeValue | null) => void; isDisabled?: boolean; className?: string; "aria-label"?: string;} function daysInRange(value: DateRangeValue) { const tz = getLocalTimeZone(); const ms = value.end.toDate(tz).getTime() - value.start.toDate(tz).getTime(); return Math.round(ms / 86_400_000) + 1;} function useQuickSelectPresets() { return useMemo(() => { const now = today(getLocalTimeZone()); const lastMonth = now.subtract({ months: 1 }); const lastYear = now.subtract({ years: 1 }); return [ { label: "Today", range: { start: now, end: now } }, { label: "Yesterday", range: { start: now.subtract({ days: 1 }), end: now.subtract({ days: 1 }) } }, { label: "Last week", range: { start: now.subtract({ days: 7 }), end: now.subtract({ days: 1 }) } }, { label: "This month", range: { start: startOfMonth(now), end: endOfMonth(now) } }, { label: "Last month", range: { start: startOfMonth(lastMonth), end: endOfMonth(lastMonth) } }, { label: "This year", range: { start: startOfYear(now), end: endOfYear(now) } }, { label: "Last year", range: { start: startOfYear(lastYear), end: endOfYear(lastYear) } }, { label: "All time", range: { start: now.subtract({ years: 10 }), end: now } }, ]; }, []);} function isPresetActive(value: DateRangeValue | null, range: DateRangeValue) { return !!value && isSameDay(value.start, range.start) && isSameDay(value.end, range.end);} function QuickSelect({ value, onSelect,}: { value: DateRangeValue | null; onSelect: (range: DateRangeValue) => void;}) { const presets = useQuickSelectPresets(); return ( <div className="flex w-[118px] shrink-0 flex-col gap-1.5"> {presets.map((preset) => ( <button key={preset.label} type="button" onClick={() => onSelect(preset.range)} className={cx( "w-full cursor-pointer rounded-2lg px-2 py-1.5 text-left text-body-medium text-text-primary transition-colors duration-150 ease", isPresetActive(value, preset.range) ? "bg-background-tertiary-default" : "hover:bg-background-secondary-hover", )} > {preset.label} </button> ))} </div> );} function Footer({ value, onChange, onCancel, onApply,}: { value: DateRangeValue | null; onChange: (value: DateRangeValue) => void; onCancel: () => void; onApply: () => void;}) { return ( <div className="flex items-center justify-between pt-3 pr-4"> <div className="flex items-center gap-2.5"> <AnimatePresence> {value && ( <motion.div key="range-summary" initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }} className="flex items-center gap-2.5" > <div className="flex items-center gap-[5px]"> <DateChipInput date={value.start} label="Start date" onCommit={(start) => onChange({ start, end: start.compare(value.end) > 0 ? start : value.end }) } /> <span className="text-body-medium text-text-secondary">-</span> <DateChipInput date={value.end} label="End date" onCommit={(end) => onChange({ start: end.compare(value.start) < 0 ? end : value.start, end }) } /> </div> <span className="rounded-xl bg-background-tertiary-default px-2 py-2 text-body-medium text-text-secondary"> {daysInRange(value)} day{daysInRange(value) === 1 ? "" : "s"} selected </span> </motion.div> )} </AnimatePresence> </div> <div className="flex items-center gap-2.5"> <Button variant="secondary" onClick={onCancel}> Cancel </Button> <Button onClick={onApply} disabled={!value}> Apply </Button> </div> </div> );} export function DateRangePicker({ value, defaultValue = null, onChange, isDisabled, className, "aria-label": ariaLabel = "Date range",}: DateRangePickerProps) { const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState<DateRangeValue | null>(defaultValue); const committedValue = isControlled ? (value ?? null) : internalValue; const [pendingValue, setPendingValue] = useState<DateRangeValue | null>(committedValue); const commit = (next: DateRangeValue | null) => { if (!isControlled) setInternalValue(next); onChange?.(next); }; return ( <DialogTrigger onOpenChange={(isOpen) => { if (isOpen) setPendingValue(committedValue); }} > <AriaButton isDisabled={isDisabled} className={cx(triggerButtonClassName, className)}> <RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden /> <span className="flex items-center justify-center px-1 text-body-medium text-text-primary"> {committedValue ? `${formatTriggerDate(committedValue.start)} - ${formatTriggerDate(committedValue.end)}` : "Select date range"} </span> </AriaButton> <Popover offset={4} className={popoverClassName}> <Dialog aria-label={ariaLabel} className="outline-none"> {({ close }) => ( <RangeCalendar aria-label={ariaLabel} visibleDuration={{ months: 2 }} value={pendingValue} onChange={setPendingValue} > <div className="flex gap-3"> <div className="pt-4 pl-4"> <QuickSelect value={pendingValue} onSelect={(range) => setPendingValue(range)} /> </div> <div className="flex flex-col pt-2 pr-2 pb-3"> <div className="flex gap-2"> <MonthPanel offset={0} showPrev /> <MonthPanel offset={1} showNext /> </div> <Footer value={pendingValue} onChange={setPendingValue} onCancel={() => { setPendingValue(committedValue); close(); }} onApply={() => { commit(pendingValue); close(); }} /> </div> </div> </RangeCalendar> )} </Dialog> </Popover> </DialogTrigger> );}

Controlled & uncontrolled value

Pass value + onChange for a controlled range, or defaultValue for uncontrolled. Either way, the popover keeps its own pending draft internally — onChange only fires when the user presses Apply; Cancel discards the draft and reverts to the last committed value. The start/end chips in the footer are editable inputs (DD/MM/YYYY) that update the same pending draft.

No range committed yet

const [value, setValue] = useState<DateRangeValue | null>(null); <DateRangePicker aria-label="Report period" value={value} onChange={setValue} />

Disabled

isDisabled dims the trigger button and blocks opening the popover.

<DateRangePicker aria-label="Report period" isDisabled />

Single date

DatePicker is the non-range sibling — Figma node 3879:6708. Same trigger, popover, month panel, day-cell rendering, and editable date chip as DateRangePicker (see shared.tsx below), built on a plain react-aria Calendar instead of RangeCalendar. There's only one month, so — unlike the dual view, where the two inner chevrons are inert spacers — both nav chevrons are fully functional. The Figma frame has no quick-select sidebar.

import { DatePicker } from "@/components/base/date-picker/date-picker"; export function Example() { return <DatePicker aria-label="Meeting date" />;}

Same pending-vs-committed value pattern as the range picker, just with a single CalendarDate instead of a { start, end } pair.

No date committed yet

const [value, setValue] = useState<CalendarDate | null>(null); <DatePicker aria-label="Due date" value={value} onChange={setValue} />

date-picker.tsx

// components/base/date-picker/date-picker.tsx"use client"; import { useState } from "react";import { Button as AriaButton, Calendar, Dialog, DialogTrigger, Popover } from "react-aria-components";import type { CalendarDate } from "@internationalized/date";import { RiCalendarLine } from "@remixicon/react";import { AnimatePresence, motion } from "motion/react";import { Button } from "@/components/base/buttons/button";import { DateChipInput, MonthPanel, formatTriggerDate, popoverClassName, triggerButtonClassName,} from "@/components/base/date-picker/shared";import { cx } from "@/utils/cx"; /** * Figma source: Board UI → "Calendar_single" (node 3879:6708). * * A single-date picker: trigger button opens a popover with one month * (react-aria's plain `Calendar`, so both nav chevrons are fully functional — * unlike `DateRangePicker`'s dual view, there's only one month to navigate) * and a footer with an editable "DD/MM/YYYY" chip plus Cancel/Apply. No * quick-select sidebar in this Figma frame. * * Reuses the exact chevrons, day-cell rendering, month-panel chrome, editable * date chip, and trigger/popover surfaces built for `DateRangePicker` — see * `./shared`. A plain `Calendar`'s `CalendarCellRenderProps` always reports * `isSelectionStart === isSelectionEnd === isSelected` for the one chosen * day, so the shared `DayCell` renders it as a single fully-rounded pill * with no connecting range background, matching Figma without any * single-vs-range branching in the cell itself. */ export interface DatePickerProps { value?: CalendarDate | null; defaultValue?: CalendarDate | null; onChange?: (value: CalendarDate | null) => void; isDisabled?: boolean; className?: string; "aria-label"?: string;} export function DatePicker({ value, defaultValue = null, onChange, isDisabled, className, "aria-label": ariaLabel = "Date",}: DatePickerProps) { const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState<CalendarDate | null>(defaultValue); const committedValue = isControlled ? (value ?? null) : internalValue; const [pendingValue, setPendingValue] = useState<CalendarDate | null>(committedValue); const commit = (next: CalendarDate | null) => { if (!isControlled) setInternalValue(next); onChange?.(next); }; return ( <DialogTrigger onOpenChange={(isOpen) => { if (isOpen) setPendingValue(committedValue); }} > <AriaButton isDisabled={isDisabled} className={cx(triggerButtonClassName, className)}> <RiCalendarLine className="size-5 shrink-0 text-foreground-icon-primary" aria-hidden /> <span className="flex items-center justify-center px-1 text-body-medium text-text-primary"> {committedValue ? formatTriggerDate(committedValue) : "Select date"} </span> </AriaButton> <Popover offset={4} className={popoverClassName}> <Dialog aria-label={ariaLabel} className="outline-none"> {({ close }) => ( <Calendar aria-label={ariaLabel} value={pendingValue} onChange={setPendingValue}> <div className="flex flex-col pt-2 pr-2 pb-3 pl-2"> <MonthPanel offset={0} showPrev showNext /> <div className="flex items-center justify-between pt-3 pr-4 pl-4"> <div> <AnimatePresence> {pendingValue && ( <motion.div key="date-summary" initial={{ opacity: 0, y: -12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }} > <DateChipInput date={pendingValue} label="Date" onCommit={setPendingValue} /> </motion.div> )} </AnimatePresence> </div> <div className="flex items-center gap-2.5"> <Button variant="secondary" onClick={() => { setPendingValue(committedValue); close(); }} > Cancel </Button> <Button onClick={() => { commit(pendingValue); close(); }} disabled={!pendingValue} > Apply </Button> </div> </div> </div> </Calendar> )} </Dialog> </Popover> </DialogTrigger> );}