DateTimePicker

A day and a time of day, picked in one panel.

A day and a time of day, picked in one panel and carried in one Date.

A date field beside a time field is two decisions the reader has to make separately and then hold together — and the two halves can disagree, which is how a booking ends up on the right day at a time that has already passed. Here the calendar and the scale are the same panel over one value, so what is on screen is the answer rather than two thirds of it.

The layout follows from that: the calendar is the coarse choice and takes the room, the time sits under it behind a hairline, and one Done finishes both.

Installation

DateTimePicker ships with the library — no separate install.

import { DateTimePicker, Frame, Text } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add date-time-picker

Usage

const [when, setWhen] = useState<Date>();

<DateTimePicker value={when} onValueChange={setWhen} />

Composition

<DateTimePicker>
  <DateTimePicker.Trigger>…</DateTimePicker.Trigger>
</DateTimePicker>
  • DateTimePicker.Trigger — Wrap your own trigger in it to replace the default button. It is Popover.Trigger — the same part DatePicker and TimePicker re-export, so a field row written for one works for all three.

Examples

One value, filled in from either end

onValueChange fires on every change to either half, not on Done — Done closes the panel, it does not decide anything the caller has not already been told.

The halves can be filled in either order. A day picked first takes the top of the current hour until the time is touched; a time picked first means today, because a time is not a Date without a day and the day the reader is looking at is the only defensible guess. The alternative — emitting nothing until both halves have been touched — is a form that silently does nothing when you use it in the order it did not expect.

const [when, setWhen] = useState<Date>();

<DateTimePicker
  value={when}
  onValueChange={setWhen}
  placeholder="Pick a date and time"
/>

Where the panel goes

popover anchors it to the trigger and is the default. bottom-sheet is usually the better answer on a phone: a calendar and a scale stacked is a tall panel, and a sheet has the height to give it. dialog is for when the appointment is the decision on the screen. inline renders the panel bare, for composing into a Frame or a form.

<DateTimePicker presentation="bottom-sheet" />

<DateTimePicker presentation="dialog" />

{/* Bare, inside something of your own. */}
<Frame>
  <Frame.Header><Frame.Title>Book a fitting</Frame.Title></Frame.Header>
  <Frame.Panel>
    <View className="p-3">
      <DateTimePicker presentation="inline" value={when} onValueChange={setWhen} />
    </View>
  </Frame.Panel>
</Frame>

Which face the time is picked on

ruler by default rather than the wheel. Under a month grid the panel is already tall, and the wheel is five rows of it; the ruler is one readout over a scale, reads at arm's length, and is the one face that fits under a calendar without the whole thing needing to scroll.

Where the exact minute matters more than the height, the other two faces are a prop away and produce the same value.

{/* The default. */}
<DateTimePicker layout="ruler" />

{/* Any minute in the day, two flicks away. */}
<DateTimePicker layout="wheel" minuteStep={5} hourCycle={24} />

{/* A face beside a list of slots. */}
<DateTimePicker layout="clock" minuteStep={30} />

Bounding it on both axes

minDate and maxDate bound the calendar; minTime and maxTime bound the time of day, and minuteStep decides how finely it can be landed on. A picker that offers a slot nobody can be given is a picker that has to reject it afterwards, which is the same rejection arriving later and less usefully.

The bounds are independent, so this reads as "any day in the next three weeks, between nine and half past five" — which is what opening hours are.

<DateTimePicker
  value={when}
  onValueChange={setWhen}
  minDate={today}
  maxDate={threeWeeksOut}
  minTime={{ hour: 9, minute: 0 }}
  maxTime={{ hour: 17, minute: 30 }}
  minuteStep={30}
/>

A trigger of your own

Pass a child and it is cloned with an onPress that opens the panel, so a field row, a list item or an icon button can stand in for the default button without this component knowing what any of them look like.

<DateTimePicker value={when} onValueChange={setWhen}>
  <Item onPress={() => {}}>
    <Item.Content>
      <Item.Title>Starts</Item.Title>
      <Item.Description>{when ? format(when) : 'Not set'}</Item.Description>
    </Item.Content>
    <ChevronRightIcon size={16} />
  </Item>
</DateTimePicker>

API Reference

DateTimePicker

PropTypeDefaultDescription
valueDateControlled value. One Date carrying both halves.
defaultValueDateStarting value when uncontrolled.
onValueChange(value: Date) => voidFires on every change to either half, not on Done. Done closes the panel; it does not decide anything the caller has not already been told.
layoutTimePickerLayout'ruler'Which face the time is picked on. ruler is the one that fits here.
presentationDateTimePickerPresentation'popover'Anchored panel, a sheet, a dialog, or the panel with nothing around it.
openbooleanControlled open state of the panel.
onOpenChange(open: boolean) => void
hourCycleHourCycle1212 shows a meridiem, 24 does not. The value is 24-hour either way.
minuteStepnumberMinutes between one selectable time and the next.
minTimeTimeValueEarliest selectable time of day, inclusive.
maxTimeTimeValueLatest selectable time of day, inclusive.
placeholderstringDEFAULT_PLACEHOLDERWhat the trigger reads when nothing has been chosen.
format(value: Date) => stringOverride how the chosen value is written on the trigger.
disabledbooleanfalseStop the trigger opening it, and the panel from being used.
disabledDatesCalendarDisabledDays that cannot be picked: a list, a span, or a rule.
minDateDateEarliest selectable day.
maxDateDateLatest selectable day.
captionLayoutCalendarCaptionLayout'label'dropdown swaps the month caption for month and year pickers.
weekStartsOnnumber00 is Sunday.
localestringBCP 47 tag for the month names, the time and the trigger's own text.
calendarCalendarSystem'gregory'Which calendar the months and day numbers are counted in.
doneLabelstring'Done'Label on the button that closes the panel.
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

It does not close on the date

DatePicker closes as soon as a single day is tapped, because at that point there is nothing left to say. Here there is: the day is half the value, and closing on it would hide the other half at the moment it became relevant.

So the panel stays until Done — in every presentation, including the popover, which is the one place DatePicker has no Done button at all. Tapping outside still dismisses it, but that is how a popover is abandoned, and something has to say when both halves are settled.

The panel is a fixed width

That is what lets the two halves line up. A month grid and a time scale that each measured themselves would be two boxes of slightly different widths stacked on each other, and the seam shows at any size. The hairline between them is a hairline rather than a gap for the same reason: space alone reads as two controls that happen to be near each other.

The value is a Date, the time is not

The component's value is one Date carrying both halves, because that is what a caller stores and sends. Inside, and in minTime / maxTime, a time of day is { hour, minute } on a 24-hour clock — a Date cannot hold a time without also holding a day, and the two conversions that forces on every caller are where the daylight-saving bugs live. hourCycle decides whether a meridiem is shown; it does not change what is stored.

When two fields are still the right answer

If the day and the time are genuinely separate decisions — a recurring event's day-of-week and its time, a filter with an optional time — keep them apart. This component is for the case where one moment is being chosen and half of it is not an answer.

On this page