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 ancestorstransform 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 — check if 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.

~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:

  1. 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.
  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?