Skip to content

Customizing pickers

Props cover the common changes: renderCell, renderTrigger, presets, footer, tokens and data-* attributes. When they are not enough, compose your own picker from the public parts. The parts and their data-* attributes are stable; the finished pickers’ internal markup may change in minor releases, which is why composing is safer than styling their internals.

This is the complete source of the demo above. It uses only @lunisolar-picker/react:

MyDateRangePicker.tsx
import type { DateRangeValue } from '@lunisolar-picker/calendar';
import type { RangeLimitMode } from '@lunisolar-picker/core';
import {
Calendar,
DateRangeField,
Popover,
RangeCalendar,
useRangeCalendarContext,
type ValueChangeDetails,
} from '@lunisolar-picker/react';
import { useState } from 'react';
export interface MyDateRangePickerProps {
readonly endName?: string;
readonly label: string;
readonly limitMode?: RangeLimitMode;
readonly max?: string;
readonly maxDays?: number;
readonly onValueChange: (value: DateRangeValue | null, details: ValueChangeDetails) => void;
readonly startName?: string;
readonly value: DateRangeValue | null;
}
function ConfirmButtons() {
const calendar = useRangeCalendarContext();
return (
<div data-lsp-part="picker-footer">
<Popover.Close>Cancel</Popover.Close>
<Popover.Close disabled={!calendar.validity.isValid} onClick={() => calendar.commit()}>
Apply
</Popover.Close>
</div>
);
}
export function MyDateRangePicker({
endName,
label,
limitMode,
max,
maxDays,
onValueChange,
startName,
value,
}: MyDateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const constraints = { limitMode, max, maxDays };
return (
<div data-lsp-part="date-range-picker">
<DateRangeField.Root
aria-label={label}
value={value}
onValueChange={onValueChange}
startName={startName}
endName={endName}
{...constraints}
/>
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
<Popover.Trigger aria-label={`${label}: choose in calendar`}>
<CalendarIcon />
</Popover.Trigger>
<Popover.Content aria-label={label}>
<RangeCalendar.Root value={value} onValueChange={onValueChange} commitMode="confirm" {...constraints}>
<Calendar.Layout />
<ConfirmButtons />
</RangeCalendar.Root>
</Popover.Content>
</Popover.Root>
</div>
);
}
function CalendarIcon() {
return (
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
>
<rect x="2" y="3" width="12" height="11" rx="2" />
<path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" />
</svg>
);
}

How the pieces fit:

  • The field and the calendar are siblings that share one controlled value. Do not wrap the field in RangeCalendar.Root.
  • RangeCalendar.Root lives inside Popover.Content, which unmounts on close, so an unconfirmed draft disappears when the popover closes.
  • Form names (startName, endName) go on the field only, which is always mounted. Putting them on both parts would submit every value twice.
  • Children of RangeCalendar.Root replace its default layout, so the recipe renders <Calendar.Layout /> itself.
  • Popover.Content has no default name. Give it an aria-label.

The finished DateRangePicker adds a clear button, a bottom sheet on phones, form reset and the presets slot on top of this.

Close on pick instead of confirming. Drop commitMode="confirm" and the buttons, control the popover, and close it in onPick, which runs after onValueChange whenever the user completes a range:

<RangeCalendar.Root value={value} onValueChange={onValueChange} onPick={() => setIsOpen(false)}>
<Calendar.Layout />
</RangeCalendar.Root>

Presets. Inside RangeCalendar.Root, useRangeCalendarContext().setRange(range) shows a range, and validity tells you whether the draft is acceptable. (usePickerSelection belongs to the finished DateRangePicker.)

function LastWeek({ range }: { range: DateRangeValue }) {
const calendar = useRangeCalendarContext();
return <button type="button" onClick={() => calendar.setRange(range)}>Last 7 days</button>;
}

Holiday names in cells. Pass renderCell to Calendar.Layout, and getCellLabel to the root so screen readers hear the holiday:

<RangeCalendar.Root value={value} onValueChange={onValueChange} getCellLabel={(cell) => holidays[cell.date]}>
<Calendar.Layout
renderCell={(info) => (
<>
{info.day}
{holidays[info.date] && <small>{holidays[info.date]}</small>}
</>
)}
/>
</RangeCalendar.Root>

A filter chip trigger. Pass your chip to Popover.Trigger’s render prop. The chip receives the trigger’s ARIA attributes, event handlers and ref. On a finished picker, use renderTrigger and spread triggerProps on your button.

Two months. Set numberOfMonths={2} on the root. Calendar.Layout renders both; in your own layout put <Calendar.Heading monthIndex={1} /> and <Calendar.Grid monthIndex={1}> in a second data-lsp-part="calendar-month" element while the day grid is showing, as described in Calendar parts.

A warning for long ranges. With limitMode="flag", read validity.reasons from onValueChange, or from useRangeCalendarContext().validity inside the popover, and render your own message.