Skip to content
Beta · ExperimentalReact 19No peer dependencies

Date Picker

DatePicker and DateRangePicker — a text field people can type into, plus a calendar that works from the keyboard.

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

Installation

bash
npx gbs-add-block@latest -a DatePicker -beta

The block copies the date-picker folder into your project, along with the small shared folder that every component imports. You own the code and can change it freely. There are no peer dependencies other than React.

Requirements

  • React 19 and @types/react 19
  • TypeScript target ES2022 or newer, with "jsx": "react-jsx"
  • Browsers from 2024 or newer (the styles use light-dark(), :has() and the Popover API)

Import the stylesheet once, for example in your global CSS:

css
@import "../components/date-picker/styles.css";

or in your root layout / entry file:

ts
import "@/components/date-picker/styles.css";

DatePicker selects one date and DateRangePicker selects a start and an end. Both are a text field plus a calendar: people can type a date in their own date order or pick one, and the whole calendar works from the keyboard. They share one engine, one stylesheet and one set of keyboard rules. The calendar renders in the browser's top layer, so it is never clipped by a scrolling parent — including inside a DataGrid cell.

Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

DatePicker

Live preview

DateRangePicker

Live preview

Quick Start

tsx
"use client";
 
import { useState } from "react";
import {
  DatePicker,
  DateRangePicker,
  type DateRange,
} from "@/components/date-picker";
 
export default function Example() {
  const [date, setDate] = useState<Date | null>(null);
  const [range, setRange] = useState<DateRange>({ start: null, end: null });
 
  return (
    <>
      <DatePicker label="Delivery date" value={date} onChange={setDate} />
 
      <DateRangePicker
        label="Reporting period"
        value={range}
        onChange={setRange}
      />
    </>
  );
}

Both components are controlled with value + onChange, or uncontrolled with defaultValue. The range picker also reports whether the range is finished:

tsx
<DateRangePicker onChange={(range, complete) => complete && refetch(range)} />
Pass locale explicitly when you render on a server. Without it the component uses the runtime's locale, and a server whose locale differs from the browser's would format the field differently on the first paint.

Values and Formatting

value, defaultValue, min and max accept a Date, an ISO yyyy-mm-dd string or a timestamp. onChange always gives back Date objects.

ts
type DateInput = Date | string | number;
 
interface DateRange {
  start: Date | null;
  end: Date | null;
}

Every date is a local date at midnight, so a day never shifts across time zones. ISO strings are read as local days, not UTC — the mistake behind "my date is one day off" bugs.

The field's text comes from format, which takes Intl options or your own function:

tsx
<DatePicker format={{ dateStyle: "medium" }} />
<DatePicker format={(date) => date.toISOString().slice(0, 10)} />

Store values with toISODate(date) (from the same package) rather than toISOString(), which converts to UTC first.

Props Table

Shared

PropTypeDefaultDescription
min / maxDateInputEarliest and latest selectable day. Arrows and panels stop there.
isDateDisabled(date: Date) => booleanCalled per rendered day; return true to block it (weekends, holidays).
localestringruntime localeBCP-47 tag. Sets month names, the typing order and the first day of the week.
weekStartsOn06the locale's first day0 is Sunday.
formatIntl.DateTimeFormatOptions | (date, locale) => string{ year: "numeric", month: "short", day: "numeric" }How the chosen date is written in the field.
numberOfMonthsnumber1 (range: 2)Months shown side by side.
showWeekNumbersbooleanfalseAdds a leading ISO week-number column.
showTodaybooleantrue (range: false)The "Today" shortcut in the footer.
allowInputbooleantrueLet people type. When false the field is read-only and opens on click.
fixedWeeksbooleantrueAlways six week rows, so the popover never changes height.
labelReactNodeField label above the control.
placeholderstringthe locale's patternDefaults to dd/mm/yyyy, mm/dd/yyyy, … to match locale.
descriptionReactNodeHint below the control.
errorReactNodeError message below the control; also marks the control invalid.
requiredbooleanfalseMarks the label and the input.
disabledbooleanfalseBlocks opening, typing and clearing.
readOnlybooleanfalseShows the value but blocks changes.
clearablebooleantrueShow the clear button when something is selected.
size"sm" | "md" | "lg""md"Control height and font size (30 / 36 / 44 px).
namestringPosts yyyy-mm-dd in a hidden input. See Forms.
idstringgeneratedId of the input; also the base for the popover and message ids.
onOpenChange(open: boolean) => voidFires when the calendar opens or closes.
classNamestringClass for the root element.
classNamesPartial<Record<DatePickerSlot, string>>Classes per slot: root, label, control, input, popover, calendar, day, footer, presets.
styleCSSPropertiesInline style for the root element (e.g. CSS variables).
localeTextPartial<DatePickerLocaleText>EnglishOverrides UI text. See Locale Text.
refRef<DatePickerHandle<T>>Imperative API. See Imperative API.

DatePicker

PropTypeDefaultDescription
valueDateInput | nullSelected date (controlled).
defaultValueDateInput | nullnullStarting date (uncontrolled).
onChange(date: Date | null) => voidFires on choose, on clear (null) and while a typed date parses.
closeOnSelectbooleantrueClose the calendar after choosing.

DateRangePicker

PropTypeDefaultDescription
value{ start, end }Selected range (controlled). Each end is a DateInput or null.
defaultValue{ start, end }emptyStarting range (uncontrolled).
onChange(range: DateRange, complete: boolean) => voidcomplete is false after the first of the two clicks.
presetsDatePreset[]Shortcuts beside the calendar. See Range Presets.
closeOnSelectbooleantrueClose once both ends are chosen.

Typed Entry

People can type instead of picking, and the calendar follows along as they type.

  • Numbers are read in the locale's own field order: 03/04/2026 is 3 April in en-GB and 4 March in en-US.
  • ISO (2026-03-12) is accepted in every locale.
  • Month names work, long or short: 12 Mar 2026, March 12, 2026.
  • A missing year falls back to the year on screen, and a lone number is a day in the visible month: 15/8 and 5.
  • Two-digit years use the usual pivot: 26 is 2026, 95 is 1995.
  • Separators are free: /, -, . or spaces.

Text that isn't a date, or a date outside min / max, shows a message when the field loses focus and leaves the value untouched. Set allowInput={false} for a pick-only field.

Limits and Blocked Days

tsx
<DatePicker
  min="2026-01-01"
  max={new Date(2026, 11, 31)}
  isDateDisabled={(day) => day.getDay() === 0 || day.getDay() === 6}
/>

min / max stop the arrows, the month and year panels and typed entry. isDateDisabled blocks individual days: they stay visible but cannot be chosen. Keep the function stable (module level or useMemo) so the months are not rebuilt on every render.

Range Presets

tsx
import {
  addDays,
  startOfMonth,
  type DatePreset,
} from "@/components/date-picker";
 
const presets: DatePreset[] = [
  {
    label: "Last 7 days",
    range: { start: addDays(new Date(), -6), end: new Date() },
  },
  {
    label: "This month",
    range: { start: startOfMonth(new Date()), end: new Date() },
  },
];
 
<DateRangePicker presets={presets} value={range} onChange={setRange} />;

Picking a range takes two clicks: the first sets the start, the second the end, with a preview band in between. Clicking in reverse order is fine — the range is put the right way round. A third click starts a new range.

Forms

With a name, the value posts as a hidden input so FormData and server actions pick it up. Dates post as yyyy-mm-dd; the range posts two fields:

tsx
<form
  onSubmit={(event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    console.log(data.get("invoice")); // "2026-03-12"
    console.log(data.get("period-start"), data.get("period-end"));
  }}
>
  <DatePicker name="invoice" label="Invoice date" required error={error} />
  <DateRangePicker name="period" label="Period" />
  <button type="submit">Save</button>
</form>

error shows a message below the control, colors the border and sets aria-invalid. Validation is yours; the component only displays the state.

Imperative API (ref)

tsx
const picker = useRef<DatePickerHandle<Date | null>>(null);
 
<DatePicker ref={picker} />;
 
picker.current?.open();
MethodSignatureDescription
open() => voidOpen the calendar.
close() => voidClose the calendar.
toggle() => voidOpen or close.
focus() => voidFocus the field without opening.
clear() => voidRemove the selection.
getValue() => Date | nullSelected date. DateRange for the range picker.

Keyboard

KeysAction
Enter, Open the calendar from the field.
/ Previous / next day. Swapped in right-to-left layouts.
/ Same weekday, previous / next week.
Home / EndFirst / last day of the displayed week.
Page Up / Page DownPrevious / next month.
Shift + Page Up / Page DownPrevious / next year.
Enter, SpaceChoose the focused day.
EscapeClose and return focus to the calendar button.
TabLeave the calendar; it closes.

Accessibility: each month is a table role="grid" where only the focused day is tabbable, which is the WAI-ARIA pattern for a date picker. Days carry a full spoken label ("Thursday, 12 March 2026"), today is aria-current="date", selected days are aria-selected, and the calendar is a role="dialog" labelled by calendarLabel. Changes are announced through a status region.

Styling and Theming

All rules are in the CSS components layer, so utility classes passed through className / classNames override them.

tsx
<DatePicker
  className="max-w-sm"
  classNames={{ control: "shadow-sm", day: "font-mono" }}
/>

CSS variables

Override them on .dp-root, on any ancestor, or through style. Each variable falls back to the shared --gbs-* of the same name, then to the DataGrid's --dg-* when that stylesheet is loaded, and finally to the built-in palette.

VariableUsed for
--dp-font-sizeBase font size.
--dp-heightControl height (set by size).
--dp-pxHorizontal padding inside the control.
--dp-day-sizeWidth and height of a day cell.
--dp-bg, --dp-fgBackground and text color.
--dp-mutedPlaceholder, weekday names and outside days.
--dp-borderBorders and separators.
--dp-hoverHover background of days and buttons.
--dp-input-bgField background.
--dp-accent, --dp-accent-fgSelected day and today's outline.
--dp-accent-soft, --dp-accent-strongThe band between the two ends of a range.
--dp-focusFocus ring.
--dp-dangerError state.
--dp-shadowCalendar shadow.
--dp-radiusCorner radius.
css
.dp-root {
  --dp-accent: #7c3aed;
  --dp-day-size: 38px;
}

Dark mode

Colors follow the page's color-scheme. To force a scheme, put class="dark" or data-theme="dark" (or "light") on an ancestor such as <html>.

Data attributes

ElementAttributes
Root (.dp-root)data-size, data-state (open / closed), data-invalid
Control (.dp-control)data-disabled, data-invalid
Day (.dp-day)data-today, data-selected, data-outside, data-weekend
Day cell (.dp-cell)data-in-range, data-range-start, data-range-end
Month / year item (.dp-panel-item)data-selected

Locale Text

tsx
<DatePicker
  localeText={{
    today: "Heute",
    clear: "Löschen",
    invalidDate: "Bitte ein gültiges Datum eingeben",
  }}
/>
KeyDefault
calendarLabel"Choose a date"
openCalendar"Open calendar"
clear"Clear"
today"Today"
previousMonth / nextMonth"Previous month" / "Next month"
previousYears / nextYears"Previous years" / "Next years"
chooseMonthYear"Choose month and year"
monthPanelLabel / yearPanelLabel"Choose a month" / "Choose a year"
weekNumber"Week"
startDate / endDate"Start date" / "End date"
invalidDate"Enter a valid date"
outOfRange"That date is outside the allowed range"
presets"Shortcuts"
selectedDate(date) => "Selected: {date}"
selectedRange(start, end) => "Selected: {start} to {end}"

Month names, weekday names, the typing order and the first day of the week come from locale, not from localeText.

Headless Use

useDatePicker() holds the whole engine — selection, the visible months, typed input, keyboard movement and the popover state — and is exported for building a different UI on the same behavior:

tsx
const controlRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
 
const picker = useDatePicker({
  range: false,
  defaultValue: { start: null, end: null },
  onSelectionChange: (next) => …,
  min: null,
  max: null,
  numberOfMonths: 1,
  fixedWeeks: true,
  disabled: false,
  readOnly: false,
  closeOnSelect: true,
  text: defaultDatePickerText,
  controlRef,
  triggerRef,
});

It returns open, view, months, weekdays, years, title, focusedDate, selection, selectDate, applyRange, clear, goToToday, goToMonth, goToYear, selectMonth, selectYear, onGridKeyDown, getDayState, inputValue, onInputChange, onInputBlur, inputError, onTriggerClick and onPopoverClose.

The framework-free helpers are exported from @/components/date-picker/core and can also run on a server:

ExportDescription
buildMonth(year, month, options)One month as weeks of days, with today, outside, weekend and disabled flags.
buildMonths(start, count, options)Consecutive months for a multi-month view.
parseDate(text, locale?, reference?)Reads typed text in the locale's order. Returns null when it isn't a date.
formatDate(date, locale?, options?)Cached Intl formatting.
inputPlaceholder(locale?) / fieldOrder(locale?)The locale's pattern and field order.
monthNames / weekdayNamesLocalized names, weekdays ordered from the first day of the week.
moveByKey(date, key, options)Where an arrow, Home/End or Page key moves the focus.
normalizeRange / isInRangeRange helpers.
toISODate / parseISODate / toDateLocal-day conversion, never shifted to UTC.
addDays / addMonths / startOfMonth / clampDate / compareDay / isSameDayDay arithmetic and comparison.
getISOWeek / resolveWeekStart / yearPageWeek numbers, the locale's first weekday, year paging.

Next.js

The components are client components: "use client" is already at the top of the files that need it. The core folder imports nothing from React, so a Server Component or a route handler can build months, parse and format dates with the same code.

Pass locale when the page is rendered on the server, so the server and the browser produce the same text on the first paint.

Migrating from the Previous DatePicker

PreviousNew
selectedDateValue={date}value / defaultValue, which also accept ISO strings and timestamps
onDateChange={fn}onChange={fn} — still (date: Date | null) => void
minDate / maxDatemin / max
yearLimitStart / yearLimitEnd (years counted from today)min / max. The year panel pages 12 at a time instead of listing a fixed span
placeholder, name, error, disabledSame names
name posted Date.toDateString()Posts yyyy-mm-dd
Value always shown as en-GBformat and locale
"Today" and "Close" buttonsshowToday; Escape, Tab or a click outside closes
The × clear buttonclearable (on by default), plus "Clear" in the footer
@grampro/headless-helpers (useDatePickerState, applyScrollbarStyles, months, isAtYearLimit)No dependency besides React. The calendar maths lives in core and is unit tested
Popup positioned with mt-48, clipped by scrolling parentsNative Popover API: top layer, flips up when space is tight, light dismiss
Mouse onlyFull WAI-ARIA grid keyboard control
No typed entryType in the locale's order, ISO or month names
No range<DateRangePicker>
Returned null until mountedRenders on the server; pass locale for identical output
Tailwind classes baked into the markupstyles.css with --dp-* variables, classNames slots and data-* attributes

Notes

  • Dates only: no time-of-day or time-zone picking. Values are local midnight.
  • One range per field; several disjoint ranges are not supported.
  • The Gregorian calendar only, though month and weekday names, the field order and the first day of the week all follow locale.