Date adapters & the /headless entry
@kalyx/react ships with date-fns wired up out of the box. If you're starting
fresh, you don't need to think about adapters — install, import, render, done.
This guide is for the second case: you already ship dayjs, luxon, or
Temporal in your app, and you'd rather not bundle a second date library just
because Kalyx is here.
Default (date-fns)
pnpm add @kalyx/react
import { DatePicker } from '@kalyx/react';
<DatePicker value={iso} onChange={setIso}>
<DatePicker.Input />
<DatePicker.Popover>
<DatePicker.Calendar />
</DatePicker.Popover>
</DatePicker>
The main entry auto-installs DateFnsAdapter. You pay for date-fns (the
seven functions Kalyx actually calls — about 2 KB gzipped after tree-shaking)
and get the calendar working immediately.
This is the right choice for most apps. Keep reading only if you have a reason to switch.
Why would I switch?
You should switch to the /headless entry when:
- You already ship
dayjs/luxon/Temporal. Bundlingdate-fnsalongside is dead weight — a second parser, a second arithmetic engine, a second formatter. - You need a date library Kalyx doesn't bundle. Pass your own adapter and Kalyx will route every date operation through it.
- You need a deterministic clock for tests. A stub adapter whose
today()always returns the same ISO string makes calendar snapshots stable.
If none of these apply, stay on the default — switching costs more bytes of your attention than it saves of your bundle.
Using a custom adapter
Import from @kalyx/react/headless and pass an adapter prop. The component
surface is otherwise identical to @kalyx/react:
import { DatePicker } from '@kalyx/react/headless';
import { DateFnsAdapter } from '@kalyx/adapter-date-fns';
// or your own:
// import { DayjsAdapter } from './my-dayjs-adapter';
<DatePicker adapter={DateFnsAdapter} value={iso} onChange={setIso}>
<DatePicker.Input />
<DatePicker.Popover>
<DatePicker.Calendar />
</DatePicker.Popover>
</DatePicker>
Every Root component (DatePicker, RangePicker, TimePicker,
DateTimePicker, MonthPicker, YearPicker, WeekPicker) accepts the same
adapter prop. Every hook (useDatePicker, useRangePicker,
useTimePicker) accepts an adapter option.
If you forget the adapter prop on the headless entry, the Root throws a
clear error at render time:
[@kalyx/react/headless] DatePicker requires an adapter.
Pass one via <DatePicker adapter={...}>.
If you don't need a custom adapter, import from '@kalyx/react' instead.
This is intentional — catching the mistake at render is much friendlier than
crashing later inside a addMonths call with a stack trace pointing at the
Calendar grid.
Mixing entries
You can use @kalyx/react (with the default adapter) for most of your app and
@kalyx/react/headless (with a custom adapter) for the one screen that needs
it. They compose freely — the component implementations are the same code,
only the default-adapter installation differs.
Writing your own adapter
The DateAdapter interface has 21 methods. All of them take ISO 8601 UTC
strings as input and return either ISO strings, booleans, or numbers. Native
Date objects never cross the boundary.
import type { DateAdapter } from '@kalyx/react/headless';
interface DateAdapter {
// Parsing & formatting
parse(value: string, format?: string): string;
format(iso: string, formatStr: string, timezone?: string): string;
// Arithmetic
addDays(iso: string, n: number): string;
addMonths(iso: string, n: number): string;
addYears(iso: string, n: number): string;
// Comparison
isBefore(a: string, b: string): boolean;
isAfter(a: string, b: string): boolean;
isSameDay(a: string, b: string, timezone?: string): boolean;
isSameMonth(a: string, b: string): boolean;
// Boundaries
startOfDay(iso: string, timezone?: string): string;
startOfMonth(iso: string): string;
endOfMonth(iso: string): string;
startOfWeek(iso: string, weekStartsOn?: 0 | 1): string;
endOfWeek(iso: string, weekStartsOn?: 0 | 1): string;
// Clock
now(): string; // Current instant
today(timezone?: string): string; // Civil midnight in the given zone
// Validation
isValid(value: string): boolean;
// Component access
getYear(iso: string): number;
getMonth(iso: string): number; // 0-indexed (matches Date.getUTCMonth)
getDate(iso: string): number;
getDay(iso: string): number; // 0=Sunday
}
dayjs reference implementation
Sketch — works for most non-DST-edge use cases. Install
dayjs, dayjs/plugin/utc, dayjs/plugin/timezone,
dayjs/plugin/customParseFormat.
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import type { DateAdapter } from '@kalyx/react/headless';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
// date-fns tokens (yyyy, MM, dd, HH, mm) → dayjs tokens (YYYY, MM, DD, HH, mm).
// Kalyx passes date-fns-style format strings everywhere, so we translate at the edge.
function toDayjsFormat(fmt: string): string {
return fmt
.replace(/yyyy/g, 'YYYY')
.replace(/yy/g, 'YY')
.replace(/dd/g, 'DD')
.replace(/d/g, 'D');
}
export const DayjsAdapter: DateAdapter = {
parse: (value, format) =>
format ? dayjs.utc(value, toDayjsFormat(format)).toISOString() : dayjs.utc(value).toISOString(),
format: (iso, fmt, tz) => {
const d = tz ? dayjs.utc(iso).tz(tz) : dayjs.utc(iso);
return d.format(toDayjsFormat(fmt));
},
addDays: (iso, n) => dayjs.utc(iso).add(n, 'day').toISOString(),
addMonths: (iso, n) => dayjs.utc(iso).add(n, 'month').toISOString(),
addYears: (iso, n) => dayjs.utc(iso).add(n, 'year').toISOString(),
isBefore: (a, b) => dayjs.utc(a).isBefore(dayjs.utc(b)),
isAfter: (a, b) => dayjs.utc(a).isAfter(dayjs.utc(b)),
isSameDay: (a, b, tz) => {
const da = tz ? dayjs.utc(a).tz(tz) : dayjs.utc(a);
const db = tz ? dayjs.utc(b).tz(tz) : dayjs.utc(b);
return da.isSame(db, 'day');
},
isSameMonth: (a, b) => dayjs.utc(a).isSame(dayjs.utc(b), 'month'),
startOfDay: (iso, tz) => {
const d = tz ? dayjs.utc(iso).tz(tz) : dayjs.utc(iso);
return d.startOf('day').toISOString();
},
startOfMonth: (iso) => dayjs.utc(iso).startOf('month').toISOString(),
endOfMonth: (iso) => dayjs.utc(iso).endOf('month').toISOString(),
startOfWeek: (iso, weekStartsOn = 0) => {
// dayjs.startOf('week') is locale-dependent. Compute manually to match Kalyx's
// weekStartsOn contract (0 = Sunday, 1 = Monday).
const d = dayjs.utc(iso);
const dow = d.day();
const diff = (dow - weekStartsOn + 7) % 7;
return d.subtract(diff, 'day').startOf('day').toISOString();
},
endOfWeek: (iso, weekStartsOn = 0) => {
const d = dayjs.utc(iso);
const dow = d.day();
const diff = (weekStartsOn + 6 - dow + 7) % 7;
return d.add(diff, 'day').endOf('day').toISOString();
},
now: () => dayjs.utc().toISOString(),
today: (tz) => {
const d = tz ? dayjs().tz(tz) : dayjs.utc();
return d.startOf('day').toISOString();
},
isValid: (v) => dayjs(v).isValid(),
getYear: (iso) => dayjs.utc(iso).year(),
getMonth: (iso) => dayjs.utc(iso).month(), // already 0-indexed
getDate: (iso) => dayjs.utc(iso).date(),
getDay: (iso) => dayjs.utc(iso).day(),
};
Then:
import { DatePicker } from '@kalyx/react/headless';
import { DayjsAdapter } from './my-dayjs-adapter';
<DatePicker adapter={DayjsAdapter} value={iso} onChange={setIso}>
<DatePicker.Calendar />
</DatePicker>
Things to get right
- Always return ISO 8601 UTC strings (ending in
Z). Local-time strings will silently drift on the next operation. getMonthis 0-indexed. MatchDate.getUTCMonth(). luxon's.monthis 1-indexed — subtract 1.startOfDay/todaytake a timezone. When provided, return the civil-midnight instant of that zone, not UTC midnight. Without it, return UTC midnight of the same calendar day. The TimePicker and Calendar both rely on this distinction across DST boundaries.formattokens follow date-fns (yyyy,MM,dd,HH,mm). If your library uses different tokens, translate at the adapter boundary as shown above.
Testing your adapter
The fastest sanity check is to render <DatePicker.Calendar /> with the
adapter and step through a month with arrow keys. If the dates align with
what your library reports, the contract holds. For full confidence, copy the
test cases from packages/adapter-date-fns/src/__tests__/ and run them
against your adapter — they cover leap years, DST transitions, and
end-of-month rollover.