Skip to content

DateRangePicker

A span between two days. Two months side by side, with the band drawn as the pointer moves — before the second click lands.

tsx
import { DateRangePicker } from 'neba';

<DateRangePicker label="Stay" startPlaceholder="Check in" endPlaceholder="Check out" clearable />;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface: filled, hairline, or none
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth. 0 means no shadow at all
valueDateRange | nullThe chosen range, as { start, end } with either end possibly null
defaultValueDateRange | nullThe initial range, for an uncontrolled picker
onValueChange(value: DateRange) => voidAlways called with an object. A cleared range is { start: null, end: null }
defaultMonthDateWhich month the calendar opens on when there is no value. Defaults to this one
minDateDate | nullThe earliest day that may be chosen, in both panels
maxDateDate | nullThe other end of the same span
shouldDisableDate(date: Date) => booleanBlocks individual days inside the range — weekends, holidays, a room already booked. The cell stays in the grid, unavailable
weekStartsOnshared0 | 1 | 2 | 3 | 4 | 5 | 6Which day the week starts on, Sunday being 0. Defaults to whatever the locale says
monthCount1 | 22How many months are on screen at once. Two by default, because a range that crosses a month boundary is the ordinary case
startPlaceholderReactNodeShown in the first half of the trigger while the start is unchosen
endPlaceholderReactNodeThe same, for the end
presetsreadonly DateRangePreset[]Shortcuts listed beside the calendars — "Last 7 days", "This month". A function value is computed when it is pressed
openbooleanWhether the popup is open. Use with onOpenChange for a controlled one
defaultOpenbooleanfalseWhether it starts open
onOpenChange(open: boolean) => voidCalled when the popup opens or closes
localestringBCP 47 tag deciding the month and weekday names, the order of the header's two buttons, and how the trigger writes the value. Defaults to the browser's
formatIntl.DateTimeFormatOptionsHow the trigger writes the value. Passed straight to Intl
clearablebooleanfalseOffers the × that empties the control
closeOnSelectbooleantrueCloses the popup once both ends are chosen
labelsPartial<PickerLabels>The strings a screen reader hears. One object rather than eighteen props, because they are a set — the date names are not among them, those come from Intl
namestringIdentifies the field when a form is submitted. Two hidden inputs of the same name, so the ends arrive as FormData.getAll(name)
fullWidthbooleanfalseStretches to the width of the container
labelReactNodeThe label, wired to the control by Base UI's Field
descriptionReactNodeHelper text
errorReactNodeError message. Its presence turns the control invalid and re-points the colour family at danger
invalidboolean!!errorForces the invalid state without a message, for when a form library owns validity
readOnlybooleanfalseShown but not changeable. Keeps its colour and edge, drains the saturation
disabledbooleanfalseUnavailable. Drops the colour family for neutral grey

The value is one object

ts
interface DateRange {
  start: Date | null;
  end: Date | null;
}

An object rather than a [Date, Date] tuple, and rather than two props. A range is one value — it is chosen in one gesture, cleared in one gesture and validated as a whole — and the two names are what stop a caller writing the end into the start.

onValueChange is always called with an object, never with null, so a cleared range is { start: null, end: null } and a caller never has to test for two kinds of empty.

Half a range is a real state: it is what the picker holds between the first click and the second, and it is reported as { start, end: null }. Abandoning it — closing the popup without a second click — throws it away rather than leaving a range with one end in a form.

Two panels, one calendar

A range that crosses a month boundary is the ordinary case, not the exception, so monthCount is 2 by default. The two panels are one calendar in two halves: the left one has no forward stepper, the right one has no back stepper, and either header's month and year buttons move the pair.

The panels deliberately do not draw the leading and trailing days of the neighbouring months, which a single DatePicker does. With both panels showing six full weeks, the 1st of August would appear twice — once as a trailing day of July and once as itself — and two cells with the same name in one popup is ambiguous to a pointer and outright broken to a screen reader.

Clicking backwards is not a mistake

The second click may land before the first. That is the same range typed in the other order, not an error to reject, so the two ends are sorted before they are reported. The click after a finished range starts a new one.

Examples

Presets

The shortcut column is what a reporting UI is actually used through — nobody picks "the last 30 days" one day at a time.

A preset's value may be a range or a function returning one. Prefer the function: a range computed at render time would be wrong for anyone who left the tab open overnight.

One panel, and bounds

Submitting

name renders two hidden inputs of the same name, so the two ends arrive together:

ts
const form = new FormData(event.currentTarget);
const [start, end] = form.getAll('stay'); // '2026-07-03', '2026-07-09'

Both are local YYYY-MM-DD, the shape a native <input type="date"> submits.

Accessibility

  • Each panel is a role="grid" of role="gridcell" buttons named with the full date, and each keeps a tab stop of its own — Tab moves between the two grids rather than through eighty-four cells.
  • Both ends carry aria-selected; the days between them carry the band and nothing else, because being inside a range is not the same as being chosen.
  • The footer says which end the next click will fill. The trigger says the same thing with its two halves, but the trigger is behind the popup while the popup is up.

Released under the MIT License