Skip to content
cennge-ui
GitHubv0.3.1, changelog

DateRangePicker

Two date fields, start and end, typed with any separators or picked with two clicks in one calendar.

ai-niche-scout beta
Frozen from December 21 to January 3
Days
19
Working days
15
  1. Type both ends and press Tab or Enter; the range is saved once both are dates and the end is not before the start.
  2. In the calendar the first click sets the start and the band follows the pointer to the second; Escape in between starts over.
  3. Try to run the beta into the holidays: the frozen days are crossed out and a range cannot cross them.
Live: the component itself, not a picture of it.

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 value and onValueChange ({ start, end } or null), 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 startName and endName
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#

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.

Sprint

value: null

basic.tsx
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.

Deploy window
Within the next 120 days
from-today.tsx
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.

Code freeze
in-a-form.tsx
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.

Wartungsfenster
translated.tsx
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#

PropTypeDefault
label
React.ReactNode
–
description
React.ReactNode
–
errorMessage
React.ReactNodeShown instead of description while isInvalid.
–
value
DateRange | nullISO dates; null when empty.
–
defaultValue
DateRange | null
–
onValueChange
(range: DateRange | null) => void
–
minValue
string | nullEarliest date of the range, ISO. null: no limit, e.g. while today is not known yet.
–
maxValue
string | nullLatest date of the range, ISO. null: no limit, e.g. while today is not known yet.
–
isDateUnavailable
(date: string) => booleanReturn true for dates that cannot be chosen. A range cannot run across them.
–
locale
string
the provider's locale, else "en-US"
firstDayOfWeek
number
–
visibleMonths
numberMonths side by side in the calendar; they wrap on narrow screens.
2
formatError
(error: DateRangePickerError) => stringText under the field when typed dates are refused. Override to translate.
–
labels
Partial<DateRangePickerLabels>
–
placeholder
stringPlaceholder of both fields.
the locale's pattern, e.g. "DD.MM.YYYY"
startName
stringForm field names for the two ISO dates.
–
endName
string
–
isInvalid
boolean
–
isRequired
boolean
–
isDisabled
boolean
–
isReadOnly
boolean
–
variant
"bordered""flat""faded""underlined"
"flat"
color
"default""primary""secondary""success""warning""danger""info"
"primary"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
classNames
Partial<Record<DateRangePickerSlots, string>>
–

Also takes every attribute of <div>, except defaultValue, onChange, color, placeholder, which mean something else here.