Troubleshooting
Common issues and how to fix them.
Installation
Cannot find module '@kalyx/react'
Make sure you have the correct peer dependencies installed:
pnpm add @kalyx/react react react-dom
Kalyx requires React 19+. Check your version:
pnpm list react
TypeScript errors after install
Kalyx ships its own .d.ts files. If you see type errors, ensure your tsconfig.json has:
{
"compilerOptions": {
"moduleResolution": "bundler" // or "node16" / "nodenext"
}
}
The legacy "node" resolution mode doesn't support package.json exports — upgrade to "bundler" or "node16".
SSR / Next.js
useLayoutEffect warning in Next.js
Kalyx does not use useLayoutEffect. If you see this warning, it's from another library in your tree. Kalyx uses only useEffect and useId for SSR safety.
ReferenceError: window is not defined
This should never happen with Kalyx components — all window/document access is inside useEffect. If you encounter it:
- Check that you're using
@kalyx/react(not importing from@kalyx/coredirectly in a server component) - Ensure you're not destructuring Kalyx components in a Server Component file — wrap them in a Client Component:
'use client';
import { DatePicker } from '@kalyx/react';
export function MyDatePicker() {
return (
<DatePicker>
<DatePicker.Input />
<DatePicker.Popover>
<DatePicker.Calendar />
</DatePicker.Popover>
</DatePicker>
);
}
Hydration mismatch
If you see a hydration mismatch, check:
- Are you using
displayTimezone? The server and client must resolve the same timezone. Avoid relying on the system timezone — always pass an explicit IANA zone string. - Are you conditionally rendering based on
new Date()? The server timestamp differs from the client's. UsedefaultValueinstead of computing a value during render.
Popover / Positioning
Popover appears in the wrong position
Kalyx uses Floating UI with flip and shift middleware. If the popover is mispositioned:
- Check for
overflow: hiddenon ancestors — Floating UI detects overflow boundaries. A parent withoverflow: hiddencan clip or misposition the popover. - Check CSS transforms on ancestors —
transformcreates a new containing block, which can offsetposition: fixedelements. - In a modal/dialog? — The popover renders as a sibling, not a portal. If your modal clips overflow, the popover may be clipped.
Popover doesn't close on outside click
This can happen if an element calls event.stopPropagation() before the click reaches the document listener. Check your modal or dropdown wrappers.
Timezone
Selected date is off by one day
This is the single most-reported datepicker bug (react-datepicker #1018 is a decade-old example). It almost always comes from one of two causes.
Cause 1 — you passed a native Date instead of an ISO string. A Date is interpreted in the runtime's local zone, which differs between the user's browser and your server:
// ❌ off-by-one waiting to happen
const picked = new Date(2026, 3, 15); // local midnight → "2026-04-14T15:00:00.000Z" in UTC+9
save(picked.toISOString()); // server reads April 14
Kalyx never takes a Date — its value contract is an ISO-8601 UTC string, so this class of bug is structurally removed. Always read the value from onChange:
// ✅ value is already a correct UTC ISO string
<DatePicker value={value} onChange={setValue}>...</DatePicker>
Cause 2 — you display a UTC instant in a different civil zone. "2026-04-15T00:00:00.000Z" is April 15 in UTC but still April 15 in Seoul; "2026-04-15T15:00:00.000Z" is April 16 in Seoul. If you want the calendar to commit and highlight by civil day in a specific zone, set displayTimezone:
<DatePicker
value={value}
onChange={setValue}
displayTimezone="Asia/Seoul" // commit + highlight by Seoul civil day
>
...
</DatePicker>
// click "April 15" → onChange emits the UTC instant equal to Seoul April 15 00:00
Diagnosis checklist:
- Are you ever constructing
new Date(...)and passing.toISOString()intovalue? → stop; letonChangeown the value. - Is the stored string correct but the displayed day wrong? → set
displayTimezoneto the zone you want to display in. - Is the stored string itself wrong? → check the code that wrote it (often a server default of
00:00local instead of UTC).
See the Timezone concept page for the full model.
Disabled dates or min/max boundaries are off by one under displayTimezone
Same root cause as above, one layer down. disabled rules and the isDateDisabled helper compare instants. A boundary you wrote by hand as '2026-01-15T00:00:00.000Z' is a UTC coordinate, not civil midnight in your zone — so with displayTimezone set, the boundary day itself can fall on the wrong side of the rule.
import { civilMidnightFromUtcDay } from '@kalyx/core';
const tz = 'America/New_York';
// ❌ a raw UTC coordinate — in New_York this instant is still Jan 14 locally
disabled={[{ before: '2026-01-15T00:00:00.000Z' }]}
// ✅ the same civil day, expressed as the instant the picker itself uses
disabled={[{ before: civilMidnightFromUtcDay('2026-01-15T00:00:00.000Z', tz) }]}
The reliable rule: when displayTimezone is set, every date you hand the picker should be a value it could have emitted — one you got back from onChange, or one you built with civilMidnightFromUtcDay. Inside a custom grid, use the isDisabled flag getCalendarDays already computed for each cell instead of calling isDateDisabled yourself.
DST transition causes unexpected behavior
During DST transitions (e.g., US "spring forward"), 2:00 AM doesn't exist. Kalyx handles this internally with two-pass offset correction. If you're doing manual timezone math, use @kalyx/core's startOfDayInTimezone instead of computing midnight yourself.
Styling
Components have no styles at all
This is by design — Kalyx is headless. You must provide styles via classNames props or className. See the Tailwind recipe for a complete example.
classNames prop doesn't work
Make sure you're passing an object, not a string:
// ❌ Wrong — className (string) only applies to the root element
<DatePicker.Calendar className="my-calendar" />
// ✅ Right — classNames (object) targets internal slots
<DatePicker.Calendar
classNames={{
root: 'my-calendar',
day: 'my-day',
daySelected: 'my-day-selected',
}}
/>
Both className (root element) and classNames (slots) are supported. Use classNames when you need to style internal elements.
Forms
Value is not submitted with the form
In uncontrolled mode, pass a name prop to DatePicker.Input. The Input is what
renders the hidden field carrying the ISO value, and name is not a prop on the
Root. Note that DatePicker is the only picker with form-submission support —
MonthPicker, YearPicker, WeekPicker, RangePicker and DateTimePicker have none.
<DatePicker defaultValue="2026-04-15T00:00:00.000Z">
<DatePicker.Input name="startDate" />
...
</DatePicker>
react-hook-form integration
See the dedicated React Hook Form recipe.
The input shows the raw string instead of a formatted date
The value you passed is not a parseable ISO 8601 string. Rather than guessing, the picker leaves it alone: the calendar opens on the current month and the input echoes your string back so the bad data stays visible.
This is the usual symptom of an empty string or a null column reaching value.
Pass null for "no selection" instead of '':
// The row has no date yet
<DatePicker value={row.startsOn ?? null} onChange={save}>
<DatePicker.Input />
</DatePicker>
Anything new Date(value) cannot parse counts as malformed — '', 'null',
'2026-02-30T00:00:00.000Z' (February has no 30th), or a bare '2026-01-15'
that was concatenated rather than normalized.
Performance
Calendar re-renders on every state change
This is normal — the calendar grid is lightweight (~42 cells). If you're experiencing jank:
- Profile with React DevTools — check if the re-render is actually slow
- Avoid creating new objects on every render in parent components:
// ❌ Creates a new array on every render
<DatePicker disabled={[{ dayOfWeek: [0, 6] }]}>
// ✅ Stable reference
const DISABLED = [{ dayOfWeek: [0, 6] }] as const;
<DatePicker disabled={DISABLED}>
Bundle size seems larger than expected
You will see two different numbers, and both are correct — they measure different things.
~18.5 KB is the published artifact. That is what the badge and the CI ceiling track: the gzipped size of @kalyx/react's own dist/index.js, with its dependencies left external. It is the number Kalyx controls and gates on: 20 KB for the default entry in both ESM and CJS. The opt-in headless entry is gated separately at 22 KB, since it ships the same components plus all seven hooks.
16–25 KB is what a consumer actually ships, depending on how much you import. Your bundler resolves the dependencies the artifact only references, so the graph also pulls in @kalyx/core, @kalyx/adapter-date-fns (and the date-fns functions it uses), and @floating-ui/react. Run pnpm check-tree-shaking in this repository for the measured scenarios — currently ~16.2 KB gzipped for TimePicker alone, ~19.9 KB for the heaviest single picker, and ~25.0 KB for all seven plus the hooks.
The consumer figure is always the larger of the two, because the artifact number excludes dependencies the consumer must resolve. How much larger depends on your imports: roughly 6.5 KB over the artifact if you import everything, and less if you import one picker. Quote the scenario that matches your usage when comparing against libraries that publish a single all-in number.
If your own bundle is larger than that:
- Inspect your production bundler report. Unused pickers are eliminated (TimePicker alone measures ~16.2 KB against ~25.0 KB for all seven), but the pickers share a substantial base — context, popover, calendar math — so importing one is not a seventh of importing all.
- The default entry includes the date-fns adapter. If your app already ships another date library, compare the explicit
/headlessentry with the same consumer setup so date-fns isn't counted twice. - Run
pnpm check-bundlefor artifact ceilings andpnpm check-tree-shakingfor the consumer scenarios.
Still stuck?
- Search existing issues
- Open a bug report
- Request a feature
- Ask a question in Discussions