react-locale-datepicker
v0.5.1
Published
A React date picker that localizes itself from the Intl API — every locale, no locale files, RTL-aware.
Maintainers
Readme
react-locale-datepicker
A React date picker that localizes itself from the Intl API — every locale
the browser knows, no locale files to register, right-to-left aware, zero runtime
dependencies beyond React.
Live demo — locale switcher (including RTL), disabled days, themes and dark mode.

| Light | Dark | RTL (Arabic) |
| --- | --- | --- |
|
|
|
|
| Month view | Year view | Disabled days |
| --- | --- | --- |
|
|
|
|
| Custom theme (tokens) | Japanese | Error state |
| --- | --- | --- |
|
|
|
|
All captured from the real component by scripts/capture-screenshots.mjs
(npm run screenshots), including a 320 px variant at
docs/assets/picker-mobile.png.
Install
npm install react-locale-datepicker
# or: pnpm add react-locale-datepicker
# or: yarn add react-locale-datepickerPeers: react and react-dom >=18.
Quick start
import { useState } from "react";
import { LocaleDatePicker } from "react-locale-datepicker";
import "react-locale-datepicker/styles.css";
export function Example() {
const [value, setValue] = useState<Date | null>(null);
return (
<LocaleDatePicker
value={value}
onChange={setValue}
locale="de"
placeholder="dd.mm.yyyy"
aria-label="Appointment date"
/>
);
}Import the stylesheet once (app root or the module that mounts the picker). Without it the component is intentionally unstyled — that is the headless escape hatch.
CommonJS
const { LocaleDatePicker, resolveLocale } = require("react-locale-datepicker");
require("react-locale-datepicker/styles.css");Why another date picker
Most React date pickers ask you to import and register a locale bundle per
language. That is fine for two or three languages and painful for thirty. This
one derives month names, weekday names, the first day of the week and the
long-form date echo from Intl.DateTimeFormat at runtime, so adding a language
means passing a different string.
- Localization with no locale files. Names, week start and echo from
Intl. - RTL by construction. Arabic and Hebrew lay out correctly.
- One tap to select. Clicking a day commits and closes — no confirm step.
- Business-timezone "today".
timeZone="America/New_York"(or atodayDate) anchors the today marker and default view to the seller's calendar day;todayInTimeZoneis exported for matchingshouldDisableDaterules. Values stay local-midnightDates — never converted. - Typing that survives mobile. Digits mask into
dd.MM.yyyy; localized digits normalize to ASCII for every numbering systemIntlknows; separators (.,/-, Arabic and ideographic commas) close and pad the segment; the open calendar follows a fully typed date live. - Readable echo. The committed date is restated in words under the field.
- Timezone-safe values. Local-midnight
Dateobjects — never a silent day-shift from a UTC round trip. - Caller-owned disabled days.
shouldDisableDateis the single authority. - Accessible. Keyboard map (arrows, Page/Shift+Page, Home/End, Enter, Escape), ARIA pass-through, focus returns to the input on close.
- Self-contained CSS.
--rldp-*tokens, light/dark (OS + class/attribute),classNames/iconsoverrides. Zero runtime dependencies.
Props (essentials)
| Prop | Type | Notes |
| --- | --- | --- |
| value | Date \| null | Local midnight. Read with getDate / getMonth / getFullYear. |
| onChange | (date: Date \| null) => void | Fires on commit, not every keystroke. |
| locale | string | Any BCP 47 tag. Non-standard aliases are normalized (e.g. ua → uk). |
| placeholder | string | Required. Display format is fixed dd.MM.yyyy; supply a matching hint. |
| shouldDisableDate | (date: Date) => boolean | Sole authority on selectability. |
| minDate / maxDate | Date \| null | Bound month/year navigation only — do not override the predicate. |
| defaultCalendarMonth | Date \| null | Month shown when opening with no value. |
| timeZone | string | IANA zone "today" is derived in (seller's calendar day). Never converts the value. |
| today | Date | Inject "today" outright — wins over timeZone. |
| disabled | boolean | |
| hasError | boolean | Visual only. |
| showEcho / showWeekdayHeader / showTodayMarker | boolean | Opt out of a built-in. All default to true — today's behaviour. |
| onBlur | (current: Date \| null) => void | Receives the just-committed value. |
| onDisabledOpenAttempt | () => void | Fires when a disabled picker is tapped. |
| onValidationError | (reason) => void | Why a typed entry did not commit: "missing", "impossible-date", "not-selectable". |
| aria-label / aria-invalid / aria-describedby | | Pass through to the input. |
| className | string | Root element. |
| themeName | "default" \| "minimal" \| "soft" \| "high-contrast" | Selects a shipped theme. Unset inherits an ancestor's. |
| classNames | Partial<Record<Slot, string>> | Per-slot class overrides (appended after built-ins). |
| styles | Partial<Record<Slot, CSSProperties>> | Per-slot inline styles, same keys. |
| labels | Partial<Labels> | Strings Intl cannot supply — English defaults; override for non-English UIs. See below. |
| icons | Partial<Record<IconName, ReactNode>> | Substitute calendar / chevron glyphs. |
| portal | boolean \| HTMLElement | Opt-in escape from overflow: hidden ancestors. Default false keeps the 0.3.x in-tree popover. |
| manualEntryOnTouch | "second-tap" \| "immediate" | When the on-screen keyboard may appear, touch only. Default "second-tap". See below. |
Full contract: docs/API.md.
The on-screen keyboard (touch)
On a phone the keyboard costs roughly half the viewport, and most taps on a date field only ever wanted the grid. Since 0.5.0 the default is:
tap the field → calendar opens, no keyboard
tap the text again (calendar open) → keyboard: deliberate manual entry
tap the icon → calendar opens, no keyboard
pick a day → closes, no keyboardThe field carries inputMode="none" until that second tap. Typing is never
removed — inputMode governs only the virtual keyboard, so hardware
keyboards, paste and every a11y affordance keep working, and a fine pointer is
unaffected entirely. Pointer type is read live from (pointer: coarse), so a
hybrid device is judged per interaction, not once at mount.
Pass manualEntryOnTouch="immediate" for the pre-0.5 behaviour, where any tap
on the field raises the keyboard.
Two related behaviours are not optional, because both were plain defects: picking a day never returns focus to the input on touch (it did, and that raised the keyboard on the tap meant to finish the job — keyboard and assistive-tech activation still get focus back), and the popover's above/below decision is made once per open and frozen, so a viewport shrinking under the keyboard cannot flip a calendar the visitor is reading over the top of the field.
Locale helper
import { resolveLocale } from "react-locale-datepicker";
resolveLocale("ua"); // "uk" — safe for your own Intl calls
resolveLocale("en_US"); // "en" — malformed tags fall back instead of throwingNever pass a raw caller-supplied locale into Intl without this (or equivalent)
normalization: a bad tag throws and can take down a whole React island.
Labels — what you must translate
Month names, weekday names, the long-form echo, day accessible names, and the
previous / next navigation labels come from Intl for the locale you
pass. When labels.previousMonth / labels.nextMonth are omitted, the nav
buttons are named with the month and year they navigate to (e.g.
"серпень 2026" under locale="uk") — not a static "Previous month" string.
You do not need to translate those two.
The other four defaults are English and will ship English into a non-English page unless you override them:
| Key | Default | When it is heard |
| --- | --- | --- |
| keyboardHelp | "Use the arrow keys…" | Once, when keyboard focus first enters the day grid |
| openCalendar | "Open calendar" | Trigger name while empty |
| changeDate | "Change date" | Trigger name prefix while a date is committed |
| closeCalendar | "Close calendar" | Trigger name while the calendar is open |
Worked example for a Ukrainian UI (the rest of the calendar still follows
locale="uk" / locale="ua" via Intl):
<LocaleDatePicker
value={value}
onChange={setValue}
locale="ua"
placeholder="дд.мм.рррр"
aria-label="Дата початку подорожі"
labels={{
keyboardHelp:
"Клавіші зі стрілками — між днями, Page Up/Down — місяць, Enter — вибір.",
openCalendar: "Відкрити календар",
changeDate: "Змінити дату",
closeCalendar: "Закрити календар",
}}
/>Do not invent machine translations inside the package — the consumer owns these four strings across their locales.
Popover inside overflow: hidden
The default popover is position: absolute inside the component root. A card
shell like overflow-hidden rounded-2xl clips it — verified in the
browser matrix. Pass portal to escape:
// Portal to document.body with position:fixed coordinates.
<LocaleDatePicker portal /* ... */ />
// Or into a host you already use for modals / stacking.
<LocaleDatePicker portal={modalRootEl} /* ... */ />Default stays in-tree so existing layouts do not reflow. Keyboard model, Escape-to-close and outside-click close keep working with either mode. Inside a cross-origin iframe the portal targets the iframe's document (the only document the script can reach).
Constraints example
<LocaleDatePicker
value={value}
onChange={setValue}
locale="en"
placeholder="dd.mm.yyyy"
minDate={new Date(2026, 0, 1)}
maxDate={new Date(2026, 11, 31)}
shouldDisableDate={(d) => d.getDay() === 0 || d.getDay() === 6}
aria-label="Appointment date"
/>Theming
Full guide: docs/THEMING.md.
Import the stylesheet, then override tokens anywhere up the tree — on an
ancestor, or on the root via className. The nearest declaration wins.
.my-form {
--rldp-accent: oklch(0.55 0.18 250);
--rldp-radius: 0.5rem;
}Four themes ship — default, minimal, soft, high-contrast — selectable
from React or from CSS alone, and they nest:
<LocaleDatePicker themeName="soft" /* ... */ /><div data-rldp-theme="high-contrast">…</div>Dark mode is CSS-only:
- follows the OS via
color-scheme+light-dark(); - override with a
.dark/.lightclass or[data-theme="dark|light"]on an ancestor (compatible with next-themes and similar).
Tailwind v4
Tailwind v4's @theme reads plain CSS variables, so one block bridges the two
in either direction. Nothing Tailwind-specific ships in the package.
@import "tailwindcss";
@import "react-locale-datepicker/styles.css";
@theme inline {
--color-rldp-accent: var(--rldp-accent); /* picker tokens -> utilities */
}
.my-form {
--rldp-accent: var(--color-indigo-600); /* your palette -> the picker */
--rldp-radius: var(--radius-lg);
}Slot overrides, per docs/ANATOMY.md:
<LocaleDatePicker
classNames={{
input: "my-input",
daySelected: "my-selected-day",
echo: "my-echo",
}}
styles={{ popover: { borderRadius: 16 } }}
/* ... */
/>Tailwind preflight beats the package's own styling
The stylesheet is built so consumer CSS always wins: every rule sits in the
rldp cascade layer and is written with :where(). Both halves work exactly
as designed. The half that is easy to miss is what "consumer CSS" includes —
Tailwind's preflight is unlayered consumer CSS, so preflight wins too, on
every property it resets.
Two independent barriers, and preflight clears both:
- Cascade layer. Unlayered CSS beats any cascade layer regardless of
selector. In Tailwind v3 preflight is emitted unlayered, so it outranks
@layer rldpeven where the package rule is more specific. Layer order is decided before specificity is ever consulted. - Specificity.
:where(…)scores 0,0,0, so preflight'sbutton, input, …at 0,0,1 would win on specificity as well.
The rules that collide, from Tailwind's preflight.css:
| Preflight rule | Effect on the picker |
| --- | --- |
| button, input, … { color: inherit } | The field and every day cell take the host's text colour. --rldp-foreground and --rldp-disabled-foreground never land. |
| button, input, … { font-size: 100% } | The field renders at the browser default, not the picker's. |
| button { background-color: transparent } | A themed field background disappears. |
| *, ::before, ::after { border-width: 0 } | The field can lose its border entirely. |
None of this throws, and none of it looks wrong in a default light theme in English — which is exactly why it reaches production. Three real ways it has:
- a dark-mode visitor got a dark field with the host's near-black text on it, i.e. the date they had just typed, invisible;
- disabled days rendered pixel-identical to selectable ones, because
--rldp-disabled-foregroundnever applied — a click on a closed date simply did nothing, with no visual reason why; - the field lost its border and read as a different control from the inputs beside it.
The rule to work from: anything this package styles on a <button> or an
<input> needs an explicit class from you. That is what classNames is for,
and a Tailwind utility is unlayered too, so it lands where the package cannot:
<LocaleDatePicker
classNames={{
field: "border-2 border-gray-200 bg-white",
input: "text-base text-gray-900",
dayDisabled: "text-gray-400 cursor-not-allowed",
}}
/* ... */
/>Reaching the same result without touching the component: put Tailwind in a
layer of its own and order it under rldp. Tailwind v4 does this natively —
@import "tailwindcss" layer(tw); then @layer tw, rldp;. Under v3 the
equivalent is wrapping @tailwind base in a layer via PostCSS. Either way the
package stops being outranked, which is the fix if you would rather not repeat
host styling in classNames.
Doing that ordering for the consumer is on the roadmap
(docs/ROADMAP.md); until then the table above is the
contract.
Keyboard
| Key | Action | | --- | --- | | Arrow keys | Move by day / week (RTL-aware) | | PageUp / PageDown | Previous / next month | | Shift+PageUp / PageDown | Previous / next year | | Home / End | Start / end of locale week | | Enter / Space | Commit focused day | | Escape | Close and return focus to the input | | ArrowDown (from input) | Open, then enter the grid |
Display format
Today the typed/display format is fixed dd.MM.yyyy. A locale-derived or
custom format contract is on the roadmap (docs/ROADMAP.md).
Always set placeholder to match.
Browser support
Modern evergreen browsers with Intl.DateTimeFormat and Intl.Locale week
info (Chrome, Firefox, Safari, Edge). Tested in CI across Chromium, Firefox and
WebKit at 320 / 768 / 1280 px.
Development
npm install
npm run check # typecheck + lint + unit tests
npm run test:tz # unit suite under UTC, America/Los_Angeles, Asia/Tokyo, Asia/Kathmandu
npm run test:e2e # Playwright matrix
npm run build # tsup → dist/ (ESM + CJS + d.ts + styles.css)Demo
examples/ is a small Vite app consuming the built package,
with a locale switcher (including RTL), disabled days and theme toggles. It
resolves dist/, so build the package first:
npm run build && cd examples && npm install && npm run devDocumentation
| Document | What it covers |
| --- | --- |
| docs/API.md | Full public API and contracts |
| docs/ANATOMY.md | Published parts, slots and state attributes |
| docs/THEMING.md | Tokens, named themes, dark mode, Tailwind bridge |
| docs/PLAN.md | Implementation plan and status |
| docs/DECISIONS.md | Design decisions |
| docs/EXTRACTION.md | Parity contract (must not regress) |
| docs/TESTING.md | Required test matrix |
| docs/ROADMAP.md | Feature and theming roadmap |
| docs/RELEASING.md | Versioning and release process |
| CHANGELOG.md | What changed in each release |
Contributing and support
Maintained as time allows: issues and pull requests are welcome, and there is
no response-time promise. Start with CONTRIBUTING.md —
the most valuable reports are locale rendering problems, day-shift timezone
bugs and screen-reader findings. Security reports go through
SECURITY.md, never a public issue.
License
MIT © 2026 Yevhen Salitrynskyi
