Installation
npx @grampro/kit add DatePickerThe 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. The files land in component-lib/, or src/component-lib/ when your project has a src/ folder; pass --dest to put them somewhere else, and later installs follow the first.
There are no peer dependencies other than React.
Requirements
- React 19 and
@types/react19 - 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:
@import "../component-lib/gbs.css";or in your root layout / entry file:
import "@/component-lib/gbs.css";DatePicker selects one date, DateRangePicker a start and an end, and DateTimePicker a date with a time of day. Each is 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.
DatePicker
DateRangePicker
Quick Start
"use client";
import { useState } from "react";
import {
DatePicker,
DateRangePicker,
type DateRange,
} from "@/component-lib/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:
<DateRangePicker onChange={(range, complete) => complete && refetch(range)} />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.
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:
<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
| Prop | Type | Default | Description |
|---|---|---|---|
min / max | DateInput | — | Earliest and latest selectable day. Arrows and panels stop there. |
isDateDisabled | (date: Date) => boolean | — | Called per rendered day; return true to block it (weekends, holidays). |
locale | string | runtime locale | BCP-47 tag. Sets month names, the typing order and the first day of the week. |
weekStartsOn | 0–6 | the locale's first day | 0 is Sunday. |
format | Intl.DateTimeFormatOptions | (date, locale) => string | { year: "numeric", month: "short", day: "numeric" } | How the chosen date is written in the field. |
numberOfMonths | number | 1 (range: 2) | Months shown side by side. |
showWeekNumbers | boolean | false | Adds a leading ISO week-number column. |
showToday | boolean | true (range: false) | The "Today" shortcut in the footer. |
allowInput | boolean | true | Let people type. When false the field is read-only and opens on click. |
fixedWeeks | boolean | true | Always six week rows, so the popover never changes height. |
label | ReactNode | — | Field label above the control. |
placeholder | string | the locale's pattern | Defaults to dd/mm/yyyy, mm/dd/yyyy, … to match locale. |
description | ReactNode | — | Hint below the control. |
error | ReactNode | — | Error message below the control; also marks the control invalid. |
required | boolean | false | Marks the label and the input. |
disabled | boolean | false | Blocks opening, typing and clearing. |
readOnly | boolean | false | Shows the value but blocks changes. |
clearable | boolean | true | Show the clear button when something is selected. |
size | "sm" | "md" | "lg" | "md" | Control height and font size (30 / 36 / 44 px). |
name | string | — | Posts yyyy-mm-dd in a hidden input. See Forms. |
id | string | generated | Id of the input; also the base for the popover and message ids. |
onOpenChange | (open: boolean) => void | — | Fires when the calendar opens or closes. |
className | string | — | Class for the root element. |
classNames | Partial<Record<DatePickerSlot, string>> | — | Classes per slot: root, label, control, input, popover, calendar, day, footer, presets. |
style | CSSProperties | — | Inline style for the root element (e.g. CSS variables). |
localeText | Partial<DatePickerLocaleText> | English | Overrides UI text. See Locale Text. |
ref | Ref<DatePickerHandle<T>> | — | Imperative API. See Imperative API. |
DatePicker
| Prop | Type | Default | Description |
|---|---|---|---|
value | DateInput | null | — | Selected date (controlled). |
defaultValue | DateInput | null | null | Starting date (uncontrolled). |
onChange | (date: Date | null) => void | — | Fires on choose, on clear (null) and while a typed date parses. |
closeOnSelect | boolean | true | Close the calendar after choosing. |
DateRangePicker
| Prop | Type | Default | Description |
|---|---|---|---|
value | { start, end } | — | Selected range (controlled). Each end is a DateInput or null. |
defaultValue | { start, end } | empty | Starting range (uncontrolled). |
onChange | (range: DateRange, complete: boolean) => void | — | complete is false after the first of the two clicks. |
presets | DatePreset[] | — | Shortcuts beside the calendar. See Range Presets. |
closeOnSelect | boolean | true | Close once both ends are chosen. |
DateTimePicker
Takes the shared props, with min / max as moments, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
value | DateInput | null | — | Selected date and time (controlled). An ISO yyyy-mm-ddTHH:mm string works. |
defaultValue | DateInput | null | null | Starting value (uncontrolled). |
onChange | (date: Date | null) => void | — | Fires on a day, a time change, clear (null) and while typed text parses. |
hourCycle | 12 | 24 | the locale's clock | 12 adds the AM/PM switch. |
showSeconds | boolean | false | A seconds field as well. |
minuteStep | number | 1 | How far the minute moves per arrow press. |
defaultTime | string | "00:00" | The time a day receives when picked before any time is set. |
showNow | boolean | true | A "Now" button beside the time. |
closeOnSelect | boolean | false | Close once a day is picked. |
name | string | — | Posts yyyy-mm-ddTHH:mm (:ss with showSeconds). |
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/2026is 3 April inen-GBand 4 March inen-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/8and5. - Two-digit years use the usual pivot:
26is 2026,95is 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.
Type a date or pick one. Field order, month names and the first day of the week follow the locale, while ISO and written months (“12 Mar 2026”) are always accepted.
Limits and Blocked Days
<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.
A bookable window with weekends blocked, week numbers on and the week starting on Sunday.
Date and Time
const [meeting, setMeeting] = useState<Date | null>(null);
<DateTimePicker
label="Meeting"
value={meeting}
onChange={setMeeting}
min="2026-03-15T09:00"
minuteStep={15}
defaultTime="09:00"
/>;DateTimePicker adds a time row under the calendar: the hour, the minute and, with showSeconds, the second, each a spin button the arrow keys step. On a 12-hour clock there is an AM/PM switch. The clock follows locale — 2:30 PM in en-US, 14:30 in en-GB — unless hourCycle says otherwise.
- The value is wall-clock time.
onChangegives aDatein local time; no time zone is chosen or converted.namepostsyyyy-mm-ddTHH:mm, the shape a nativedatetime-localinput posts. minandmaxare moments, not days. Earlier days cannot be picked, and on the first allowed day an earlier time is moved up tomin.- Picking a day keeps the time already chosen. Before any time is set, a picked day takes
defaultTime(00:00unless given). The calendar stays open after a day is picked, since the time usually comes next; Enter in the time row, or Escape, closes it. - Typing works too: a date in the locale's order followed by a time —
15/03/2026 14:30,Mar 15, 2026, 2:30 PM— and ISO2026-03-15T14:30everywhere.
A meeting scheduler with 15-minute steps and a bounded window, and a 24-hour field with seconds.
Range Presets
import {
addDays,
startOfMonth,
type DatePreset,
} from "@/component-lib/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.
Two months side by side, presets down the edge, and the preview band that follows the cursor while the second date is still open.
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:
<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.
Uncontrolled fields posting ISO dates through hidden inputs — the range posts period-start and period-end.
Imperative API (ref)
const picker = useRef<DatePickerHandle<Date | null>>(null);
<DatePicker ref={picker} />;
picker.current?.open();| Method | Signature | Description |
|---|---|---|
open | () => void | Open the calendar. |
close | () => void | Close the calendar. |
toggle | () => void | Open or close. |
focus | () => void | Focus the field without opening. |
clear | () => void | Remove the selection. |
getValue | () => Date | null | Selected date. DateRange for the range picker. |
Keyboard
| Keys | Action |
|---|---|
| Enter, ↓ | Open the calendar from the field. |
| ← / → | Previous / next day. Swapped in right-to-left layouts. |
| ↑ / ↓ | Same weekday, previous / next week. |
| Home / End | First / last day of the displayed week. |
| Page Up / Page Down | Previous / next month. |
| Shift + Page Up / Page Down | Previous / next year. |
| Enter, Space | Choose the focused day. |
| Escape | Close and return focus to the calendar button. |
| Tab | Leave the calendar; it closes. |
| ↑ / ↓ in the time row | Step the hour, minute or second; the minute by minuteStep. |
| Page Up / Page Down in the time row | Five steps at once. |
| Enter in the time row | Done: close the calendar. |
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
Rules ship unlayered, so a framework reset cannot strip them and a utility class passed through className still overrides them. Import the generated gbs.css once — see Theming.
<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.
| Variable | Used for |
|---|---|
--dp-font-size | Base font size. |
--dp-height | Control height (set by size). |
--dp-px | Horizontal padding inside the control. |
--dp-day-size | Width and height of a day cell. |
--dp-bg, --dp-fg | Background and text color. |
--dp-muted | Placeholder, weekday names and outside days. |
--dp-border | Borders and separators. |
--dp-hover | Hover background of days and buttons. |
--dp-input-bg | Field background. |
--dp-accent, --dp-accent-fg | Selected day and today's outline. |
--dp-accent-soft, --dp-accent-strong | The band between the two ends of a range. |
--dp-focus | Focus ring. |
--dp-danger | Error state. |
--dp-shadow | Calendar shadow. |
--dp-radius | Corner radius. |
.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
| Element | Attributes |
|---|---|
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
<DatePicker
localeText={{
today: "Heute",
clear: "Löschen",
invalidDate: "Bitte ein gültiges Datum eingeben",
}}
/>| Key | Default |
|---|---|
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}" |
time | "Time" |
hours / minutes / seconds | "Hours" / "Minutes" / "Seconds" |
dayPeriod | "AM or PM" |
now | "Now" |
Month names, weekday names, the typing order and the first day of the week come from locale, not from localeText.
Core Exports
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:
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 @/component-lib/date-picker/core and can also run on a server:
| Export | Description |
|---|---|
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 / weekdayNames | Localized 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 / isInRange | Range helpers. |
toISODate / parseISODate / toDate | Local-day conversion, never shifted to UTC. |
addDays / addMonths / startOfMonth / clampDate / compareDay / isSameDay | Day arithmetic and comparison. |
getISOWeek / resolveWeekStart / yearPage | Week numbers, the locale's first weekday, year paging. |
parseDateTime(text, locale?, reference?) / parseTime(text) | Reads a typed date and time, or a time alone (14:30, 2:30 pm). Returns null when it isn't one. |
formatDateTime(date, locale, options) / hourCycleOf(locale?) | The field's display, and whether a locale uses a 12- or 24-hour clock. |
toISODateTime / parseISODateTime / toDateTime / withTime / clampDateTime | Local date-time conversion and bounds, to the second. A zone or offset is never read. |
subscribeToToday / getToday / getServerToday | The shared current day, for a useSyncExternalStore of your own. It rolls over at midnight, and the server snapshot is null. |
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.
Today's date is never part of the server's HTML. The server and the browser can sit on opposite sides of midnight, or in different time zones, so a day marked on the server is a day the browser might disagree with. The calendar therefore renders with no data-today until hydration has finished, and marks the day in the render straight after — which costs nothing visible, because the calendar lives in a popover the user has to open first. A page with no server rendering marks it on the very first pass.
The same value updates itself at the next local midnight, so a dashboard left open overnight does not go on pointing at yesterday. One timer serves every picker on the page. To pin the date in a test, pass today to the core helpers (buildMonth, buildMonths); null marks no day at all.
Migrating from the Previous DatePicker
| Previous | New |
|---|---|
selectedDateValue={date} | value / defaultValue, which also accept ISO strings and timestamps |
onDateChange={fn} | onChange={fn} — still (date: Date | null) => void |
minDate / maxDate | min / 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, disabled | Same names |
name posted Date.toDateString() | Posts yyyy-mm-dd |
Value always shown as en-GB | format and locale |
| "Today" and "Close" buttons | showToday; Escape, Tab or a click outside closes |
| The × clear button | clearable (on by default), plus "Clear" in the footer |
@grampro/framework-free-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 parents | Native Popover API: top layer, flips up when space is tight, light dismiss |
| Mouse only | Full WAI-ARIA grid keyboard control |
| No typed entry | Type in the locale's order, ISO or month names |
| No range | <DateRangePicker> |
Returned null until mounted | Renders on the server; pass locale for identical output |
| Tailwind classes baked into the markup | styles.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.