Date Components
Our own dayjs-based calendar — single date and range, one or two months, with shortcuts — the three grid pickers that select a period without ever showing a day, and the field pickers built on both.
Calendar — single date
A self-contained, dayjs-based month calendar — no react-day-picker / date-fns. Locale-aware : month / weekday names and the first day of week follow the language (switch 🇫🇷 / 🇬🇧). Click the month or year in the header to jump quickly (month grid → year grid → back to days).
Selected : Thu Oct 22 2026
clearable — click the selected day again, or press Esc, to clear.
The bounds also stop navigation : the ‹ › arrows go dead rather than walking into a month that is entirely out. Same in the quick pickers — open the month grid and its year chevrons are bounded, open the year grid and so is its 12-year pagination. Only min / max bound navigation ; a month blocked by disabledMonths stays reachable, otherwise the month behind it would not be.
Bounds set to 2026 → 2028, so the year and page arrows are reachable enough to try. Click the year in the header : the 12-year page stops paging once even its last year falls short of the bound.
No value selected — the view starts on January 2030 via defaultMonth.
The month slides in the direction the arrows point, through the browser's View Transition API. Off by default. The two calendars below differ by that single prop — use the arrows on both and compare. A reader who asks for reduced motion gets a fade instead of the travel, and a browser without the API simply changes the month as it always did.
Independent of the locale : weekStartsOn="sun" starts weeks on Sunday even in 🇫🇷 (which is Monday by default). Accepts 0–6 or 'sun'…'sat'.
Accepts 0–6 (0 = Sunday) or 'sun'…'sat', alone or in an array. The numbers are absolute — unlike the grid order, they never follow weekStartsOn. The two calendars also show the two renderings : a blocked weekday is muted only (it is the shape of the calendar), a blackout date stays struck through (it is an exception).
Click the month or the year in the header to open the quick pickers — that is where the rules show. disabledMonths takes 0–11 (that month every year), a { year , month } pair, an array of those or a ( year , month ) predicate ; disabledYears takes a year, a { from , to } range (either bound optional), an array or a ( year ) predicate. Rules cascade downwards : a blocked year greys its twelve months, a blocked month greys all its days. Navigation stays free — you can still walk through a blocked month to reach the next one.
Every day of March 2027 is blacked out, but nothing declares the month itself blocked — so by default its button in the quick picker stays live and lands you on a fully greyed grid. deriveEmptyMonths makes the picker scan the month and strike it through instead. It is opt-in because it costs that scan, and it is never applied to years. Open the month picker on 2027 to compare.
Calendar — range
The same calendar in range mode, over one or two months, with a live hover preview and optional shortcuts (presets).
Shortcut labels follow the language. On mobile they become a swipeable strip.
From — to —
The 10th and the 18th–22nd are blocked (struck through, inert). By default a range stops before a blocked day; the second calendar sets allowDisabledInRange so a range may span them. A spanned day keeps the band's square corners but never its primary fill — it is crossed, not selected.
Week-ends are blocked. The rule applies to the range exactly as a blackout date does : by default the selection stops on the Friday, so no range can be longer than a working week. Add allowDisabledInRange to let it span the week-ends — select a fortnight on the right and the two week-ends show as neutral gaps inside the band : crossed, not selected.
Each shortcut carries an Icon; a { divider: true } item splits the list (a vertical rule in the mobile strip, a horizontal one in the column).
Input date picker
The masked field and the calendar share one value : typing updates the calendar, picking a day fills the field and closes. The popover opens as a dropdown anchored to the field on desktop and as a centered modal on mobile — force either with display="dropdown" | "modal".
Type or pick; × to clear.
Out-of-range days are disabled
Selected (responsive) : —
Custom mode + separator
disabledDates, disabledWeekdays, disabledMonths and disabledYears are now first-class props of the picker : they reach the calendar, and with strict they also police what you type. Without it the keyboard accepts what the click refuses. Try typing a Saturday in both fields — the left one emits it, the right one keeps the text, goes into error and stays silent on onDate.
The grid refuses them, the keyboard does not
Typing a Saturday puts the field in error
Lenient emitted : — — strict emitted : — — last refused : —
Input date range picker
The range mirror : it maps the field's { start, end } to the calendar's { from, to } and only closes once both endpoints are picked. Same responsive popover, with a wider dual-month dropdown on desktop.
Type or pick two days; × to clear.
Out-of-range days are disabled
Selected (responsive) : —
rangeSeparator=' → '
Same four props, same strict. It validates the endpoints, not the span : whether a range may cross a blocked day is allowDisabledInRange's business, and that belongs to the grid. Type a period starting on a Saturday.
Either endpoint on a week-end is refused
The 10th and the 18th–22nd of this month are refused
With a footer, picking is deferred : the field commits on Apply and reverts on Cancel / Esc. Choose where it shows : "always", "mobile" (below md only), or "desktop" (md+ only). Resize the window to see the mobile / desktop ones toggle.
Footer only below md
Footer only at md+
Both fields get an onDate / onDateRange arrow recreated on every render, whose setState is never idempotent (a counter plus a fresh object). The emit counters must settle — one emit on mount, one per real change. If they run away (« Maximum update depth exceeded »), the parse effect is looping again.
onDate emits : 0 · last : —
onDateRange emits : 0 · last : —
Input date-time picker
A date field + a time field sharing one popover that hosts the calendar and the time columns. The value is the combined string (e.g. 25/12/2026 14:30); the parsed Date comes via onDateTime. The popover stays open and closes on outside-click / Esc — or via the optional footer.
Pick a day then a time; × to clear.
Picks are deferred until Apply
Selected : —
Width — a month that fills what it is given
A day cell is no longer a fixed square : the seven columns are minmax( cellMin , cellMax ) , so a month grows into the width it is given, stops at its ceiling, and centres in whatever is left. Where nothing imposes a width — inside a picker's popover, which sizes itself to its content — the columns fall back to their floor and the calendar is exactly what it has always been.
Drag the handle at the bottom-right corner of the box below.
Three boxes of the same width, and the only difference is the ceiling. The last one has it set to the floor, which is how the whole behaviour is turned off — one prop, no second switch to remember.
default ceiling
cellMax={ 64 } — roomier
cellMax={ CALENDAR_CELL_MIN } — off
The quick month and year grids follow the same width, and two months share it — open the header of a wide calendar, or set months=2, and the panel must not jump.
Date label
« 2017-05-31 » names a day, with no hour and no zone. It is read in the application's time zone, so a server running UTC and a browser elsewhere write the same text. With a pattern carrying the hour it still reads midnight — an hour nobody stated ; datePattern writes such a value without it.
Reload and watch these two : they are written in full for an instant — what the server sent — then turn relative, once the page is past hydration.
This one takes its instant after mount, so it starts at « a few seconds ago » and moves on its own — every 5 seconds here, once a minute by default. It carries nothing but its icon until the effect has run.
Meta dates
The pair wraps rather than overflowing — the box below is 260 px wide.
A record carrying neither date renders nothing — there is no row below this line.
Pickers inside a Modal
The Popover panel portals into the host dialog[open](body-level content under a modal dialog is inert and paints below the top layer). Escape closes the topmost surface only : the picker when open, then « Éditer ».
Date : — · Heure : — · Date-heure : —
Pickers inside a Popover
Le calendrier d'un sélecteur est rendu à part, dans la page, et pourtant un clic sur un jour ne ferme pas le panneau qui le contient : pour le Popover, ce clic vient de l'intérieur.
Du — au —
Year picker
The 12-year grid the calendar navigates with, turned into a control that owns a value : clicking a year selects it instead of jumping somewhere. The value is a plain number. Paged ±12 by the « » chevrons.
Selected : 2026
clearable — click the selected year again, or press Esc, to clear.
The min and max bounds take a Date or a year, and a year bound covers the whole year. They also stop the paging : a page whose every year is out is unreachable rather than empty. disabledYears greys years out without blocking navigation — here, everything that is not a leap year.
Selected : —
Month picker
A month of the year with no year attached — a recurring month, an anniversary, a season. The value is the month index, from 0 to 11. Names and their order follow the language (switch 🇫🇷 / 🇬🇧).
Selected : 9 — octobre
clearable — click the selected month again, or press Esc, to clear.
Abbreviations fit four to a row ; whole names ask for three, through the columns and the labelFormat props. Blocked months come from the disabledMonths rule, which takes an index, a { year , month } pair, an array of those, or a predicate.
Selected : —
Month and year picker
The calendar's quick navigation promoted to a control of its own : the months grid with its ‹ year › header, and the year itself opening the 12-year grid behind it. The same chain as the calendar, one step shorter — years → month, where the calendar goes years → month → day. Only the month commits ; a year merely carries you to its months. The value is a Date on the first day of the month.
Selected : —
Click the year in the header to open the year grid.
With min on the 1st of January and max on today, the ‹ › chevrons of the header go dead at both ends and the months still to come are out of reach — a billing period cannot be in the future.
Billing period : janvier 2026
In a popover, or a modal
The three are plain blocks with no opinion about where they live : a page, a card, or the Popover, which is a dropdown anchored to its trigger on md+ screens and a bottom-sheet below (display="responsive"). Closing on pick is the caller's decision — a picker that closes itself could not be used inline — which is exactly what the close handed to the panel function is for.
Both buttons below are a PopoverButton, which owns the position hook, the open state and the anchoring. Nothing is left at the call site but the button and the panel — the trigger is its own anchor, and it carries the type="button", the aria-haspopup and the aria-expanded a hand-written one forgets.
Input month and year picker
The packaged version of what the section above assembles by hand : the masked field InputDate in its mode mm/yyyy, with the grid in the same responsive popover. The twin of InputDatePicker one granularity up — same props, same handlers, no days. Typing moves the grid, picking a month fills the field and closes the popover, and onDate always hands back the first day of the month, whichever side it came from.
Type 09/2026, or open the grid
Text : —
Date : —
Bounded to today — a billing period cannot be in the future.
A month typed at the keyboard never goes through the grid, so the rules are asked here too. The second half of the year is blocked : typing a 09/2026 puts the field into error rather than emitting. The question asked is the getMonthReason one, never the day rules — a month partly inside the bounds is not blocked, its days are.
Accepted : —
Last refused : —
Bounds given as years cover the whole year.
Input year picker
One granularity further up : four digits and nothing to separate. It speaks the language of the grid it wraps — onYear hands back a plain number, as YearPicker does, rather than a Date standing in for an integer. An onDate on the 1st of January is offered beside it for symmetry with the rest of the family. Only the rule disabledYears is accepted : neither a day nor a month exists here.
Type four digits, or open the grid
Text : —
Year : —
Bounded 1950 → this year, and every decade's first year greyed out. The mask rewrites a year typed outside the bounds onto the nearest one, so the field and the value never disagree.
useDateFormat — fr · Europe/Paris
| Instant (UTC) | useDateFormat | UTC | Machine (sans fuseau) |
|---|---|---|---|
2026-09-17T08:32:00Z | 17 septembre 2026 10:32 | 17 septembre 2026 08:32 | |
2026-12-17T08:32:00Z | 17 décembre 2026 09:32 | 17 décembre 2026 08:32 | |
2026-09-17T21:45:00Z | 17 septembre 2026 23:45 | 17 septembre 2026 21:45 | |
2026-09-17T22:15:00Z | 18 septembre 2026 00:15 | 17 septembre 2026 22:15 |
| Pattern | fr | en |
|---|---|---|
LL | 17 septembre 2026 | September 17, 2026 |
LLL | 17 septembre 2026 10:32 | September 17, 2026 10:32 AM |
L | 17/09/2026 | 09/17/2026 |
DD/MM/YYYY | 17/09/2026 | 17/09/2026 |
dddd LL HH:mm | jeudi 17 septembre 2026 10:32 | Thursday September 17, 2026 10:32 |
Valeurs vides : null → nullundefined → null"" → null"not a date" → null