DateRangePicker
Two date fields, start and end, typed with any separators or picked with two clicks in one calendar.
- Days
- 19
- Working days
- 15
- Type both ends and press Tab or Enter; the range is saved once both are dates and the end is not before the start.
- In the calendar the first click sets the start and the band follows the pointer to the second; Escape in between starts over.
- Try to run the beta into the holidays: the frozen days are crossed out and a range cannot cross them.
Field marks
- Import
import { DateRangePicker } from "cennge-ui";- Size
- 10.8 KB gzip for this import, its dependencies aside
- Pattern
- A group of two text fields, and a range calendar in a popover dialog
- Keyboard
- AltArrow down Enter Esc
- State
- Controlled with
valueandonValueChange({ start, end }ornull), or uncontrolled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Form values
- Two ISO dates, in hidden inputs under
startNameandendName - Calendar
- Two months side by side; on a phone they stack
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: date-range-picker
When to use#
Use a DateRangePicker for a period with a start and an end: a sprint, a beta, a code freeze, a report's dates. Each end is a field of its own that reads typed dates in the locale's order, and one calendar with two months picks both ends in two clicks.
The value is a pair of ISO dates, { start: "2026-11-23", end: "2026-12-11" }, or null. It changes only when the whole range is valid: both ends are dates, inside the limits, in order and clear of unavailable days. Until then the text stays in the fields, with the reason under them when there is one.
Similar#
- DatePicker is the same field for one date.
- RangeCalendar is this calendar on its own, always open on the page.
Pick a range#
Type the start, then the end, or open the calendar. After the second click the calendar closes, the fields show both dates and focus returns to the calendar button. Empty both fields to set the value back to null.
value: null
import { useState } from "react";
import { DateRangePicker, type DateRange } from "cennge-ui";
export default function Sprint() {
const [sprint, setSprint] = useState<DateRange | null>(null);
return (
<div className="flex w-full max-w-80 flex-col gap-3">
<DateRangePicker label="Sprint" value={sprint} onValueChange={setSprint} />
<p className="text-sm text-muted">
value: <code className="font-code text-foreground">{sprint ? JSON.stringify(sprint) : "null"}</code>
</p>
</div>
);
}Limits counted from today#
minValue and maxValue bound both ends: days outside are disabled in the calendar, and a typed date outside them is turned down with the reason. Only the browser knows the reader's today, so read it after mount; until then null means no limit.
import { useEffect, useState } from "react";
import { DateRangePicker } from "cennge-ui";
const toIso = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
export default function DeployWindow() {
// Today is the browser's, so it is read after mount; until then null means no limit.
const [limits, setLimits] = useState<{ min: string; max: string } | null>(null);
useEffect(() => {
const now = new Date();
const later = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 120);
setLimits({ min: toIso(now), max: toIso(later) });
}, []);
return (
<DateRangePicker
label="Deploy window"
description="Within the next 120 days"
minValue={limits?.min ?? null}
maxValue={limits?.max ?? null}
className="w-full max-w-80"
/>
);
}In a form#
startName and endName put the two ISO dates in hidden inputs, and Enter in either field reads what was typed before the form is submitted. Inside a Form, an error your server returns for either name shows under the pair.
import { useState } from "react";
import { Button, DateRangePicker, Form } from "cennge-ui";
export default function CodeFreeze() {
const [sent, setSent] = useState<string | null>(null);
return (
<Form
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSent(`from=${data.get("from")} to=${data.get("to")}`);
}}
className="flex w-full max-w-80 flex-col gap-4"
>
<DateRangePicker label="Code freeze" startName="from" endName="to" isRequired />
<div className="flex flex-wrap items-center gap-3">
<Button type="submit" color="primary">
Save
</Button>
{sent ? <code className="font-code text-sm text-muted">{sent}</code> : null}
</div>
</Form>
);
}In another language#
formatError receives one more reason than the DatePicker's: order, for an end before the start. labels names the two fields, the calendar button and the calendar's controls; the order and the separators of the dates follow the locale.
import { CenngeProvider, DateRangePicker, type DateRangePickerError } from "cennge-ui";
const asGerman = (iso?: string) => (iso ? iso.split("-").reverse().join(".") : "");
const errorText = (error: DateRangePickerError) => {
switch (error.type) {
case "invalid":
return "Datum als TT.MM.JJJJ eingeben";
case "order":
return "Das Ende liegt vor dem Anfang";
case "unavailable":
return `Der ${asGerman(error.date)} ist nicht verfügbar`;
case "outOfRange":
return `Daten zwischen ${asGerman(error.min)} und ${asGerman(error.max)} wählen`;
}
};
export default function GermanRangePicker() {
return (
<CenngeProvider locale="de-DE">
<DateRangePicker
label="Wartungsfenster"
minValue="2026-11-01"
maxValue="2027-03-31"
placeholder="TT.MM.JJJJ"
formatError={errorText}
labels={{
chooseDates: "Daten wählen",
start: "Anfang",
end: "Ende",
previousMonth: "Vorheriger Monat",
nextMonth: "Nächster Monat",
month: "Monat",
year: "Jahr",
}}
className="w-full max-w-80"
/>
</CenngeProvider>
);
}Accessibility#
The pair is a group named by the label, and the fields are named "Start date" and "End date". A refusal marks both fields with aria-invalid and describes the group and the fields with the reason. In the calendar, the first Escape drops a half-chosen range or closes an open month or year grid, and the next one closes the calendar.
Props#
Also takes every attribute of <div>, except defaultValue, onChange, color, placeholder, which mean something else here.