Skip to content
cennge-ui
GitHubv0.3.1, changelog

DatePicker

A date field: type the date in your locale's order, or pick it from a calendar that opens under the field.

Last day for merges
A weekday, not before the freeze

cennge-ui 0.4.0 ships 6 days after the code freeze.

  1. Type 11192026, 11/19/26 or 2026-11-19, then Tab or Enter: the field writes it out in the locale's format.
  2. In Release, type a date before the freeze, or 02/30/2026: the text stays, with the reason under it; Escape puts the saved date back.
  3. Alt + Down or the calendar button opens the calendar on the chosen day; arrow to another day, and Enter picks it and closes the calendar.
Live: the component itself, not a picture of it.

Field marks

Import
import { DatePicker } from "cennge-ui";
Size
10.2 KB gzip for this import, its dependencies aside
Pattern
Text field, and a calendar in a popover dialog
Keyboard
AltArrow down Enter Esc
State
Controlled with value and onValueChange (an ISO date or null), or uncontrolled
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Typing
The locale's order, any separators or none: 11/19/2026, 11.19.26, 111926 in en-US; ISO in any locale
Form value
The ISO date, in a hidden input under name
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: date-picker

When to use#

Use a DatePicker for a date in a form: a release, a due date, a birthday. Some people type dates and others click them, and it takes both. The value is an ISO string with no time zone, "2026-11-19", the same as <input type="date"> gives, so it goes to a server unchanged.

Typed text is read when the field loses focus or on Enter, in the order the locale writes dates and with any separators. Text that is not a date, or a date it cannot take, stays in the field with the reason under it.

Similar#

  • Calendar is the same month grid, always open on the page.
  • DateRangePicker takes a start and an end in one calendar.
  • TimeInput takes the time of day; put the two side by side for a date and a time.

Type it or pick it#

The calendar button opens the calendar on the chosen day, or on today. Picking a day closes it and returns focus to the button. An emptied field sets the value to null.

value: null

basic.tsx
import { useState } from "react";
import { DatePicker } from "cennge-ui";

export default function ReleaseDate() {
  const [date, setDate] = useState<string | null>(null);

  return (
    <div className="flex w-full max-w-64 flex-col gap-3">
      <DatePicker label="Release date" value={date} onValueChange={setDate} />
      <p className="text-sm text-muted">
        value: <code className="font-code text-foreground">{date ? `"${date}"` : "null"}</code>
      </p>
    </div>
  );
}

Order and separators from the locale#

The placeholder, the way the field writes the date and the order in which it reads typed numbers come from Intl for the locale: month first in the United States, day first in Britain, year first in Japan. Set it once on CenngeProvider, or per field. These three share one value: type in any of them.

locales.tsx
import { useState } from "react";
import { DatePicker } from "cennge-ui";

const locales = ["en-US", "en-GB", "ja-JP"];

export default function OneDateThreeLocales() {
  const [date, setDate] = useState<string | null>("2026-11-19");

  return (
    <div className="grid w-full gap-4 sm:grid-cols-3">
      {locales.map((locale) => (
        <DatePicker key={locale} label={locale} locale={locale} value={date} onValueChange={setDate} />
      ))}
    </div>
  );
}

Limits counted from today#

minValue and maxValue disable the days outside them in the calendar and turn a typed date outside them down with the reason. isDateUnavailable crosses single days out of the calendar and turns them down when typed. Only the browser knows the reader's today, so read it after mount; until then null means no limit.

A weekday in the next 90 days
from-today.tsx
import { useEffect, useState } from "react";
import { DatePicker } from "cennge-ui";

const toIso = (date: Date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;

const isWeekend = (iso: string) => {
  const day = new Date(`${iso}T00:00:00Z`).getUTCDay();
  return day === 0 || day === 6;
};

export default function DueDate() {
  // 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 inNinetyDays = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 90);
    setLimits({ min: toIso(now), max: toIso(inNinetyDays) });
  }, []);

  return (
    <DatePicker
      label="Due date"
      description="A weekday in the next 90 days"
      minValue={limits?.min ?? null}
      maxValue={limits?.max ?? null}
      isDateUnavailable={isWeekend}
      className="w-full max-w-64"
    />
  );
}

In a form#

With a name, a hidden input carries the ISO date. Enter in the field reads the typed date before the form is submitted, so typing and pressing Enter sends it. Inside a Form, isRequired and errors from your server show under the field.

in-a-form.tsx
import { useState } from "react";
import { Button, DatePicker, Form } from "cennge-ui";

export default function Milestone() {
  const [due, setDue] = useState<string | null>(null);

  return (
    <Form
      onSubmit={(event) => {
        event.preventDefault();
        setDue(String(new FormData(event.currentTarget).get("due")));
      }}
      className="flex w-full max-w-64 flex-col gap-4"
    >
      <DatePicker name="due" label="Milestone due" isRequired />
      <div className="flex flex-wrap items-center gap-3">
        <Button type="submit" color="primary">
          Save
        </Button>
        {due ? <code className="font-code text-sm text-muted">due={due}</code> : null}
      </div>
    </Form>
  );
}

In another language#

formatError turns each refusal into your text: it receives invalid with the locale's pattern, outOfRange with the limits, or unavailable with the date. labels names the calendar button and the calendar's controls, and placeholder replaces the pattern shown in the empty field.

translated.tsx
import { CenngeProvider, DatePicker, type DatePickerError } from "cennge-ui";

const asGerman = (iso?: string) => (iso ? iso.split("-").reverse().join(".") : "");

const errorText = (error: DatePickerError) => {
  switch (error.type) {
    case "invalid":
      return "Datum als TT.MM.JJJJ eingeben";
    case "unavailable":
      return "An diesem Tag nicht möglich";
    case "outOfRange":
      return `Datum zwischen ${asGerman(error.min)} und ${asGerman(error.max)} wählen`;
  }
};

export default function GermanDatePicker() {
  return (
    <CenngeProvider locale="de-DE">
      <DatePicker
        label="Veröffentlichung"
        minValue="2026-11-01"
        maxValue="2026-12-31"
        placeholder="TT.MM.JJJJ"
        formatError={errorText}
        labels={{
          chooseDate: "Datum wählen",
          previousMonth: "Vorheriger Monat",
          nextMonth: "Nächster Monat",
          month: "Monat",
          year: "Jahr",
        }}
        className="w-full max-w-64"
      />
    </CenngeProvider>
  );
}

Accessibility#

The field is a text input with its label, so screen readers announce it as one, and a refusal sets aria-invalid with the reason as its description. The calendar button is named "Choose date", and so is the dialog it opens. The calendar moves focus to the chosen day when it opens. The first Escape closes an open month or year grid, the next one the calendar.

Props#

PropTypeDefault
label
React.ReactNode
–
description
React.ReactNode
–
errorMessage
React.ReactNodeShown instead of description while isInvalid.
–
value
string | nullISO date, e.g. "2026-10-19"; null when empty.
–
defaultValue
string | null
–
onValueChange
(value: string | null) => void
–
minValue
string | nullEarliest date that can be chosen or typed, ISO. null: no limit, e.g. while today is not known yet.
–
maxValue
string | nullLatest date that can be chosen or typed, ISO. null: no limit, e.g. while today is not known yet.
–
isDateUnavailable
(date: string) => booleanReturn true for dates that cannot be chosen, e.g. fully booked days.
–
locale
stringBCP 47 tag: the typed format, month and weekday names.
the provider's locale, else "en-US"
firstDayOfWeek
number0 = Sunday … 6 = Saturday.
the locale's
formatError
(error: DatePickerError) => stringText under the field when a typed date is refused. Override to translate.
"Enter a date as DD.MM.YYYY", "Choose a date from … to …", "This date is not available"
labels
Partial<DatePickerLabels>Accessible names of the calendar button and the calendar's controls.
–
isInvalid
boolean
–
isRequired
boolean
–
isDisabled
boolean
–
isReadOnly
boolean
–
variant
"bordered""flat""faded""underlined"
"flat"
color
"default""primary""secondary""success""warning""danger""info"Focus colour and the selected day's colour.
"primary"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
startContent
React.ReactNode
–
classNames
Partial<Record<DatePickerSlots, string>>
–

Also takes every attribute of <input>, except size, color, value, defaultValue, onChange, min, max, type, which mean something else here.