I write my date pickers by hand — no react-day-picker, no date-fns, no dayjs. Radix supplies popover positioning and focus management; the 42-cell month, the week offset and the month arithmetic are about forty lines of pure functions. That held fine for one grid. The moment I rendered two months side by side, the arrow keys started teleporting. Each grid draws six full weeks, so January's trailing cells and February's leading cells are the same fourteen days, and data-date existed twice in the DOM. querySelector returns the first match, so focus kept landing in the wrong grid. The fix is to render the adjacent-month days as inert blanks — but only when a second grid is on screen.
The symptom
DateRangePicker in my UI kit. Two months side by side, keyboard-first. January on the left, February on the right, focus on 31 January. Press ArrowRight.
Focus should move one grid over, onto the real 1 February. Instead the ring stays in the January pane, on the greyed-out 1 that January draws as trailing filler in that same week row, and the February pane is skipped entirely. Keep pressing and you walk the rest of the first week of February inside the January grid, then jump back.
Nothing errored. TypeScript was green, ESLint was green. The single-month picker in the same kit — same helpers, same key handler, copy-pasted — was flawless.
What the calendar actually is
Worth establishing, because the bug is downstream of it. There is no calendar library and no date library. src/components/ui/DateRangePicker.tsx opens with these:
function pad(n: number) {
return String(n).padStart(2, "0");
}
function toISO(d: Date) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Midday anchor: immune to DST transitions that happen at 00:00. */
function parseISO(iso: string) {
return new Date(`${iso}T12:00:00`);
}
function isISO(s: string | undefined): s is string {
return !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
}
function addDays(iso: string, n: number) {
const d = parseISO(iso);
d.setDate(d.getDate() + n);
return toISO(d);
}
function weekOffset(d: Date, weekStartsOn: number) {
return (d.getDay() - weekStartsOn + 7) % 7;
}String in, string out. value, min, max and both arguments of onChange are ISO calendar-date strings, "YYYY-MM-DD", never Date objects. Those strings sort lexicographically in chronological order, which is why every bound check in the file is a bare comparison:
function isDisabledDay(iso: string) {
return (isISO(min) && iso < min) || (isISO(max) && iso > max);
}A month pane is built from that: the first of the month, walk back to the start of its week, then 42 cells.
const panes = useMemo(
() =>
Array.from({ length: months }, (_, i) => {
const monthISO = addMonths(paneStartISO, i);
const first = parseISO(monthISO);
const gridStart = addDays(monthISO, -weekOffset(first, weekStartsOn));
return {
monthISO,
month: first.getMonth(),
title: fmt.title.format(first),
// Six rows even when five would do, and the same six for both panes:
// the popover must not change height as the months go by, and two
// grids of different heights would not line up.
weeks: Array.from({ length: 6 }, (_, w) =>
Array.from({ length: 7 }, (_, d) => addDays(gridStart, w * 7 + d)),
),
};
}),
[paneStartISO, months, weekStartsOn, fmt],
);Six rows, always. That comment is about layout stability. It is also, unknowingly, the bug.
What I ruled out
My first suspicion was the state model. Two grids means the roving day can be in either one, and I assumed I had let focusedISO drift out of the rendered window — the classic way a roving tabindex dies. So I went after paneStartISO, the derived leading month:
const paneStartISO = useMemo(() => {
const offset = monthSpan(viewISO, focusedISO);
if (offset < 0) return monthStartISO(focusedISO);
if (offset > months - 1) return addMonths(monthStartISO(focusedISO), 1 - months);
return viewISO;
}, [viewISO, focusedISO, months]);That is correct, and I confirmed it by logging: whatever the nav arrows asked for in viewISO, the window slides until it contains focusedISO. The focused date is always inside one of the rendered months. The invariant held. The state was never wrong.
So the state was right and the DOM was wrong. That narrows it to exactly one place — the two lines that translate a date string into a DOM node.
The mechanism
Focus is moved by attribute lookup. There is no ref per cell; there are 84 cells across two panes and a ref map would be dead weight:
function focusCell(iso: string) {
gridRef.current?.querySelector<HTMLButtonElement>(`[data-date="${iso}"]`)?.focus();
}querySelector returns the first match in document order. That is fine when data-date is unique. With two panes drawing six full weeks each, it is not.
Take the default view, January and February 2026, week starting Monday. Run the pane builder above on each:
pane A (Jan 2026): 2025-12-29 → 2026-02-08
pane B (Feb 2026): 2026-01-26 → 2026-03-08
duplicated: 14 days, 2026-01-26 → 2026-02-08Fourteen dates exist twice in that popover. 1 to 8 February are rendered by the January pane as trailing filler and by the February pane as real days. January 26 to 31 likewise, as February's leading filler.
That produces two distinct failures from one cause.
The first is the teleport. focusCell("2026-02-01") walks the DOM from the top and hits January's filler cell before February's real one, so focus lands in the left grid. Note that the collision is asymmetric: for the six duplicated January days the first match is also the correct pane, because January is drawn first. Only the eight days on the February side resolve wrong. That asymmetry is why it read as intermittent — arrowing backwards over the seam looked fine, arrowing forwards did not.
The second is that the tab stop doubles, because tabbability is decided per cell against the same state:
tabIndex={iso === focusedISO ? 0 : -1}Both copies of 1 February evaluate that to 0. The point of a roving tabindex is that a 42-cell grid is one tab stop; for all fourteen duplicated days it was two — and each pane is its own role="grid", labelled by its own month caption, so the same date was sitting inside the grid announced as January and the grid announced as February at once.
The single-month picker never showed any of this, because with one grid data-date is unique by construction. The bug did not exist until the second pane did.
The fix
Not in focusCell. Scoping the query per pane would fix the focus jump and leave the duplicated tab stop standing, and it would still leave two elements claiming to be the same date. The honest fix is to stop rendering the duplicates at all:
// Each grid draws six full weeks, so January's trailing
// cells and February's leading cells are the SAME days.
// Draw both and `data-date` exists twice in the DOM —
// and focusCell takes the first match, which would park
// the roving tab stop in the wrong grid and set the
// arrow keys jumping between the two. So with a second
// grid on screen the adjacent-month days become inert
// blanks: no button, no `data-date`, nothing to match
// twice, and the six-row geometry still holds. With a
// single grid there is nothing to collide with, so they
// stay visible and clickable exactly as before. Do not
// "tidy" the two branches into one.
if (twoMonths && !inMonth) {
return <span key={iso} role="gridcell" className="aspect-square" />;
}The span keeps its grid cell so the six-row geometry and the two panes' heights are unchanged. It carries no data-date, so nothing matches twice. After this, the buttons in the popover are exactly January 1–31 and February 1–28: every selectable date appears once, and paneStartISO guarantees the roving day is always in one of the two rendered months, so focusCell always has exactly one node to find.
The comment is that long on purpose. twoMonths && !inMonth looks like a styling branch someone will happily collapse.
Days now run straight across the seam: ArrowRight on 31 January focuses 1 February in the right-hand pane. Step past 28 February and paneStartISO slides the window to February–March before the focus effect runs, so the cell exists by the time it is queried.
The other decision that paid for itself
While I was in there I checked the date arithmetic, because a calendar that renders the wrong day is a worse bug than one that focuses the wrong cell. Two rules do all the work.
Dates never become Date objects at the boundary. They cross as "YYYY-MM-DD". This is not stylistic. new Date("2026-03-29") — an ISO date with no time — is parsed as UTC midnight by spec, so in any negative-offset zone it is the previous day locally. On this machine, which sits in America/Santiago:
new Date("2026-03-29").getDate() // 28
new Date("2026-03-29T12:00:00").getDate() // 29Parsing anchors at 12:00 local, never 00:00. Midnight is not a time that exists on every calendar day. Brazil used to start DST at midnight, and it is still in the tz database — in America/Recife, 8 October 2000 has no 00:00 hour at all. (Node on Linux and macOS; a runtime TZ assignment is not honoured by V8 on Windows, so run this one in a container if that is your machine.)
process.env.TZ = "America/Recife";
new Date("2000-10-07T23:59:59").toString();
// 'Sat Oct 07 2000 23:59:59 GMT-0300' ← standard time
new Date("2000-10-08T01:00:00").toString();
// 'Sun Oct 08 2000 01:00:00 GMT-0200' ← summer time, one second laterAsk for the hour in between and you do not get an error, you get something else:
new Date("2000-10-08T00:00:00").toString();
// 'Sun Oct 08 2000 01:00:00 GMT-0200' ← an hour you did not ask for
new Date("2000-10-08T12:00:00").toString();
// 'Sun Oct 08 2000 12:00:00 GMT-0200' ← exactly what you asked forEvery day in the grid is produced by setDate walking from a parsed anchor, and every cell's key, data-date and label come back out through toISO. Feeding that pipeline a timestamp the engine had to disambiguate is a bet on which way it disambiguated. The midday anchor does not answer that question, it deletes it — which is also why addMonths writes the hour out longhand instead of letting it default to zero:
const target = new Date(d.getFullYear(), d.getMonth() + n, 1, 12);Localisation costs nothing either. Intl.DateTimeFormat supplies month names, weekday names and the dateStyle: "full" label on every cell, so locale="fr-FR" translates every date the calendar renders without a dependency. The chrome around it — "Previous month", "Clear", the range separator — stays a labels prop with English defaults, because a formatter cannot invent those. The locale default is the fixed string "en-US" rather than the runtime's, because an undefined locale resolves to the server's during SSR and the browser's on hydration.
The keyboard model
The grid follows the ARIA grid pattern with a roving tabindex — arrows for days, PageUp/PageDown for months, Shift for years, Home/End for the ends of the focused week, and one tab stop for the whole calendar. Two details in there are not obvious.
Horizontal arrows are direction-relative, read off the live computed style rather than a prop:
const rtl =
typeof window !== "undefined" && gridRef.current
? getComputedStyle(gridRef.current).direction === "rtl"
: false;
const back = rtl ? 1 : -1;And out-of-range days are aria-disabled, never disabled:
// aria-disabled, not disabled: a natively disabled cell
// cannot take focus and would dead-end the roving grid.
aria-disabled={dayDisabled || undefined}A natively disabled button cannot receive focus. Put one in a roving grid and the arrow keys walk into it and stop.
How to catch it next time
Open the popover and count, in the browser console, with the calendar on screen:
const cells = [...document.querySelectorAll('[data-date]')].map(n => n.dataset.date);
new Set(cells).size === cells.length // must be true
document.querySelectorAll('[data-date][tabindex="0"]').length // must be 1Two assertions, no test harness. The first catches the duplication at its source; the second catches every way a roving tabindex can go wrong, including ones that have nothing to do with duplicate attributes. Both were false in my build and both are things I would never have thought to look at, because the state they are derived from was correct the whole time.
The general shape: the moment you find a component addressing DOM nodes by a data attribute, the attribute has become a primary key, and nothing in React or TypeScript will tell you when it stops being unique. Ask what renders twice.
Both pickers, the time picker and the rest of the primitives are in Anvil UI, a free MIT component kit for Next.js 16 — copy the file, it imports nothing but React, Radix Popover and a three-line cn(). Every component is running at ui.violettadev.com if you want to try the keyboard before reading the code.
Verified on react@19.2.7, next@16.2.10 and @radix-ui/react-popover@1.1.23.