Skip to main content

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:

  1. Check that you're using @kalyx/react (not importing from @kalyx/core directly in a server component)
  2. Ensure you're not destructuring Kalyx components in a Server Component file. Wrap them in a Client Component:
components/MyDatePicker.tsx
'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. Use defaultValue instead 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:

  1. Check for overflow: hidden on ancestors. Floating UI detects overflow boundaries. A parent with overflow: hidden can clip or misposition the popover.
  2. Check CSS transforms on ancestors. transform creates a new containing block, which can offset position: fixed elements.
  3. 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:

  1. Are you ever constructing new Date(...) and passing .toISOString() into value? → stop; let onChange own the value.
  2. Is the stored string correct but the displayed day wrong? → set displayTimezone to the zone you want to display in.
  3. Is the stored string itself wrong? → check the code that wrote it (often a server default of 00:00 local 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:

  1. Profile with React DevTools and check whether the re-render is actually slow
  2. 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.

~19.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.

About 16 to 26 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. pnpm check-tree-shaking in this repository measures that: esbuild minify with those dependencies bundled and React and React DOM external, then gzip. Current figures are ~16.36 KB for TimePicker alone, ~18.90 KB for DatePicker alone, ~20.16 KB for the heaviest single picker (DateTimePicker), and ~25.65 KB for all seven plus the three main-entry 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 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:

  1. Inspect your production bundler report. Unused pickers are eliminated (TimePicker alone measures ~16.36 KB against ~25.65 KB for all seven plus the three main-entry hooks), but the pickers share a substantial base (context, popover, calendar math), so importing one is not a seventh of importing all.
  2. The default entry includes the date-fns adapter. If your app already ships another date library, compare the explicit /headless entry with the same consumer setup so date-fns isn't counted twice.
  3. Run pnpm check-bundle for artifact ceilings and pnpm check-tree-shaking for the consumer scenarios.

Still stuck?​