DateRangePicker
A span between two days. Two months side by side, with the band drawn as the pointer moves — before the second click lands.
import { DateRangePicker } from 'neba';
<DateRangePicker label="Stay" startPlaceholder="Check in" endPlaceholder="Check out" clearable />;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| value | DateRange | null | — | The chosen range, as { start, end } with either end possibly null |
| defaultValue | DateRange | null | — | The initial range, for an uncontrolled picker |
| onValueChange | (value: DateRange) => void | — | Always called with an object. A cleared range is { start: null, end: null } |
| defaultMonth | Date | — | Which month the calendar opens on when there is no value. Defaults to this one |
| minDate | Date | null | — | The earliest day that may be chosen, in both panels |
| maxDate | Date | null | — | The other end of the same span |
| shouldDisableDate | (date: Date) => boolean | — | Blocks individual days inside the range — weekends, holidays, a room already booked. The cell stays in the grid, unavailable |
| weekStartsOnshared | 0 | 1 | 2 | 3 | 4 | 5 | 6 | — | Which day the week starts on, Sunday being 0. Defaults to whatever the locale says |
| monthCount | 1 | 2 | 2 | How many months are on screen at once. Two by default, because a range that crosses a month boundary is the ordinary case |
| startPlaceholder | ReactNode | — | Shown in the first half of the trigger while the start is unchosen |
| endPlaceholder | ReactNode | — | The same, for the end |
| presets | readonly DateRangePreset[] | — | Shortcuts listed beside the calendars — "Last 7 days", "This month". A function value is computed when it is pressed |
| open | boolean | — | Whether the popup is open. Use with onOpenChange for a controlled one |
| defaultOpen | boolean | false | Whether it starts open |
| onOpenChange | (open: boolean) => void | — | Called when the popup opens or closes |
| locale | string | — | BCP 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 |
| format | Intl.DateTimeFormatOptions | — | How the trigger writes the value. Passed straight to Intl |
| clearable | boolean | false | Offers the × that empties the control |
| closeOnSelect | boolean | true | Closes the popup once both ends are chosen |
| labels | Partial<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 |
| name | string | — | Identifies the field when a form is submitted. Two hidden inputs of the same name, so the ends arrive as FormData.getAll(name) |
| fullWidth | boolean | false | Stretches to the width of the container |
| label | ReactNode | — | The label, wired to the control by Base UI's Field |
| description | ReactNode | — | Helper text |
| error | ReactNode | — | Error message. Its presence turns the control invalid and re-points the colour family at danger |
| invalid | boolean | !!error | Forces the invalid state without a message, for when a form library owns validity |
| readOnly | boolean | false | Shown but not changeable. Keeps its colour and edge, drains the saturation |
| disabled | boolean | false | Unavailable. Drops the colour family for neutral grey |
The value is one object
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:
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"ofrole="gridcell"buttons named with the full date, and each keeps a tab stop of its own —Tabmoves 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.