Inputs

Datetime Picker

A calendar grid beside an hour/minute/second ladder that emits one Date — min and max bound the combined instant, not the day, so the max day narrows the hours.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CalendarClock, ChevronLeft, ChevronRight, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Panel entrance + month swap. React 19 hoisted <style> — no Tailwind config edits. */
const KEYFRAMES = `@keyframes dtp-panel-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
@keyframes dtp-month-in{from{opacity:0}to{opacity:1}}`

/** Always six rows so the panel keeps one height across months. */
const WEEK_ROWS = 6
const DAYS_PER_WEEK = 7
/** How far arrow keys keep looking for a selectable day before giving up. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/datetime-picker.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "DatetimePicker" component: a trigger button
that opens one panel holding a calendar grid and, beside it, a column of time
ladders. lucide-react for icons, Intl.DateTimeFormat for every string, no date
library and no popover library.

Contract
- forwardRef<HTMLButtonElement> — the ref and every native prop that is not listed
  below land on the trigger button, because that is the focusable thing a form
  library or a "jump to the first error" routine will call focus() on. className
  styles the field's outer box instead.
- Props extend Omit<React.ButtonHTMLAttributes<HTMLButtonElement>,
  "value" | "defaultValue" | "onChange" | "type" | "disabled" | "name"> and add:
  value?: Date | null (controlled) / defaultValue?: Date | null (uncontrolled),
  onChange?: (value: Date | null) => void,
  min?: Date, max?: Date — inclusive bounds on the whole INSTANT, not on the day,
  disabledDates?: (date: Date) => boolean (receives local midnight),
  minuteStep?: number (default 5), secondStep?: number (default 15),
  showSeconds?: boolean (default false), hour12?: boolean (default: the locale's),
  weekStartsOn?: 0..6 (default 1), locale?: string (default "en-US"),
  defaultTime?: string ("HH:mm" | "HH:mm:ss", 24h, default "09:00"),
  now?: Date, clearable?: boolean (default true),
  align?: "start" | "end", disabled?: boolean, name?: string, placeholder?: string.
- One Date goes out, never a {date, time} pair: every commit is
  new Date(y, m, d, 0, 0, secondsIntoDay, 0), so milliseconds are always zero and
  comparisons are exact.
- name renders a hidden input carrying local "YYYY-MM-DDTHH:mm[:ss]" — the wire
  format of <input type="datetime-local">. Build it from getFullYear/getMonth/
  getDate/getHours/…, never toISOString(), which shifts the day for anyone east or
  west of UTC. Every helper in the file compares local parts for the same reason.
- now is the injected instant that "today" and the Now button are measured
  against. Left out, fall back to useSyncExternalStore with a server snapshot of
  null and a client snapshot floored to the current minute (a snapshot that
  changes on every read makes React loop): SSR renders with no notion of now and
  React fills it in after hydration — no mismatch, no mounted flag.

Behavior — one rule the whole component obeys
- min/max bound the combined instant. Express everything as seconds-into-day:
  for a day, dayWindow() returns {lo, hi} — 0..86399 narrowed to
  secondsIntoDay(min) on min's own day and secondsIntoDay(max) on max's own day,
  or null when the day sits entirely outside. Snap min up and max down to whole
  seconds once, so comparisons against ms-zeroed commits are exact.
- The reachable times on a day are a ladder: hours 0..23 crossed with the minute
  ladder (0, minuteStep, …) and the second ladder (0, secondStep, … when
  showSeconds; otherwise the single second the live value carries, so a 13:59:30
  is never told 13:59:30 is unreachable). Whatever minute/second the live value
  holds is spliced into its ladder even when it is off-step — a 09:07 from a
  server must stay reachable instead of being rounded the first time an unrelated
  column is touched.
- Two primitives do all the maths, both O(60) arithmetic rather than a scan over
  the day's 86 400 possible points, because they run for all 42 visible cells on
  every render: firstStepAtOrAfter(seconds) and lastStepAtOrBefore(seconds).
  From them: hasSlot(window, from, to) = "is any ladder point inside this span
  also inside the day's window", and resolveInUnit(window, desired, from, to) =
  "the nearest reachable second-of-day to `desired` inside this span" — desired
  unchanged when it is already legal, otherwise pulled to the closest ladder point
  on the legal side.
- A day cell is unavailable when disabledDates says so or when hasSlot over the
  whole day is false. An hour row is unavailable when no ladder point inside that
  hour is legal; a minute row, when no legal point sits inside that minute; a
  second row, when that exact instant is out; AM/PM, when no legal point sits in
  that half of the day.
- Picking a day carries the current time-of-day over: legal, and it commits
  unchanged; illegal, and it is clamped to the nearest legal rung on that day and
  announced ("3:00 PM is outside the allowed range on that day — moved to 2:00
  PM"). Picking a time unit works the same way, with the unit's own span as the
  bracket: an hour that has no legal point at all is REFUSED with a reason, an
  hour that is legal but whose composed instant is not gets pulled to the nearest
  rung. That is the difference between "you cannot go there" and "you can, but not
  at that minute", and the user is told which one happened.
- The time half is a function of the date half. With no day picked, the ladders
  are inert: 15:30 is not an instant, and min/max cannot even be evaluated against
  it. Say that in the panel instead of silently doing nothing, and let a keystroke
  in an inert ladder raise the same sentence rather than being swallowed. Picking
  the first day applies defaultTime, clamped like any other pick.
- A value handed in through props can break the rules too, so it is flagged rather
  than accepted: aria-invalid on the trigger plus a message naming the earliest or
  latest allowed instant. This is the headline case — max is today 14:00 and the
  value is today 15:00: the same calendar day as the max, one hour past it.
- Now commits the injected instant truncated to the resolution the user can see
  (to the minute while seconds are hidden). Its inert state and its handler read
  ONE verdict, so "looks available" and "refused on click" can never disagree.
- Keyboard, trigger: Enter/Space (native) and ArrowDown open the panel and move
  focus onto a day cell.
- Keyboard, grid: Arrow keys ±1 day / ±7 days, Home/End to the bounds of the
  focused week, PageUp/PageDown ±1 month, Shift + PageUp/PageDown ±1 year (clamp
  the day: Jan 31 → Feb 28/29), Enter/Space select. Every move hops over
  unavailable days by walking up to ~62 days in the travel direction.
- Keyboard, each ladder: ArrowUp/ArrowDown to the previous/next AVAILABLE row,
  Home/End to the first/last available row, PageUp/PageDown four available rows at
  a time, Enter/Space re-commits the active row. Selection follows focus — moving
  is choosing — which is what makes the two halves feel like one control.
- Escape closes the panel, stopPropagation() so a surrounding dialog does not also
  close, and returns focus to the trigger. Picking a day does NOT close the panel:
  the time is still unset, and closing would strand the user with a half-answer.
  Done closes deliberately.
- ARIA: the panel is role="dialog" with its own aria-label (non-modal, no focus
  trap); the trigger carries aria-haspopup="dialog", aria-expanded and
  aria-controls only while open (a dangling id is an ARIA error); the month is
  role="grid" labelled by its caption, weekday names are role="columnheader" with
  a full-name aria-label, days are role="gridcell" wrappers with aria-selected
  around a button carrying aria-current="date" for today.
- Each ladder is a role="listbox" that keeps DOM focus on ITSELF and points at the
  current row with aria-activedescendant. That is not decoration: rows are
  re-created whenever the ladder changes (the off-step row appears and
  disappears), and focus must not travel with them. Rows are role="option" with
  aria-selected and an aria-label spelling the value out ("3 PM", "45 minutes"),
  suffixed ", unavailable" when they are.
- Unavailable days and rows stay in the tree, struck through, with aria-disabled —
  a row that vanishes cannot be told apart from a row that never existed, and the
  ladder would silently renumber itself under the user.
- Never the native disabled attribute on anything the user may be standing on: the
  ✕ unmounts itself the moment it clears (focus the trigger FIRST), the panel's
  Clear dies the instant it succeeds, the month arrows die at the min/max month,
  Now dies when now falls out of range, whole ladders go inert when the value is
  cleared. All of them use aria-disabled plus an early return in the handler.
- disabled makes the trigger aria-disabled — focusable and readable, not ripped
  out of the tab order — and the panel's open state is derived as
  `open && !disabled`, so going inert can never leave a live panel over a dead
  field.
- A polite live region (role="status", visually hidden, ALWAYS mounted — a region
  that appears together with its text is not announced) speaks the last verdict,
  then the focused day while arrowing, then the caption when the month moves.
- Pointer: inside a ladder, preventDefault the pointerdown and focus the listbox —
  rows are not natively focusable, so the press would otherwise blur the panel to
  <body>. On the control's own surface (the field's padding, the panel's padding,
  the gap between two cells) do the same, and on the field's surface focus the
  trigger. Without this an open panel loses its keyboard owner on a stray press:
  Escape is bound to the root and the arrows to the grid, so neither would see a
  key again.
- Closing: a pointerdown outside the whole control closes it (listener added only
  while open, removed on close and on unmount); focusout closes on a real Tab-out
  only (relatedTarget outside the root) and does not steal focus back.
- Focus never lands on <body>. Cells are re-created by a month swap, so raise a
  ref flag inside the handler and move DOM focus in an effect after the commit
  (when the target cell is already on screen, focus it synchronously and skip the
  flag). And whenever the panel goes away — Done, Escape, a press outside,
  `disabled` flipping true mid-interaction — check after the commit whether focus
  fell to document.body and, only then, hand it to the trigger.

Rendering & styling
- Semantic tokens only: field border + bg-background, bg-muted when inert,
  border-destructive + text-destructive for the invalid state, panel bg-popover /
  text-popover-foreground with a border and shadow, selected day and selected row
  bg-primary / text-primary-foreground, today ring-1 ring-primary, hover bg-accent
  / text-accent-foreground, muted-foreground for outside days, unavailable cells
  and the panel note.
- cn() merges every className; focus-within ring on the field shell and
  focus-visible:ring-2 ring-ring on every button, select, day cell and listbox.
- Layout: six week rows always, so the panel keeps one height across months; the
  ladders are fixed-height scroll containers that move their OWN scrollTop to keep
  the active row visible (scrollIntoView would walk every scrollable ancestor and
  drag the page when the panel opens near the fold).
- Motion is decoration: a 140ms panel fade-in and a 180ms month cross-fade, both
  behind motion-reduce:[animation:none], plus motion-reduce:transition-none on
  colour transitions. With motion off the panel simply appears and every ladder
  still scrolls.

Customization levers
- Resolution: minuteStep / secondStep / showSeconds reshape the ladders without
  touching the maths — the availability rules read the ladders, not the other way
  round. minuteStep={1} gives a 60-row minute column; showSeconds adds a fourth
  column, which is the widest configuration a 12-hour locale can produce.
- Density: day cells are size-8 with gap-1 (7 × 32 + 6 × 4 = one 248px grid) and
  the ladders are w-12 / h-56 — change all four and the panel follows, because
  nothing is measured in JS.
- Sub-blocks are independent: delete the footer (Now / Clear / Done), swap the
  month + year selects for a static caption plus arrows, or drop the panel note if
  the live region alone is enough. To close the panel as soon as a day is picked,
  call closePanel(true) at the end of selectDay — only do it when the time half is
  presentational.
- Placement: align="start" | "end" flips the anchor edge; to escape a clipping
  ancestor, mount the same panel in a portal — nothing in the logic assumes it is
  a sibling.
- Locale: pass one locale string and the printed value, month names, weekday
  letters, AM/PM words and the 12- vs 24-hour default all follow; hour12 overrides
  just the last of those.
- Rules: keep min/max for the hard window and put "no weekends", "not during the
  freeze" in disabledDates. Both feed the same refusal path, so the message, the
  struck-through cell and the clamp come for free.
- Tokens: recolour the selected row to var(--chart-1) to match a schedule chart,
  or switch today's marker from a ring to a dot under the number.

Concepts

  • The bound is on the instant — every rule is evaluated on the composed date-and-time, so the day that carries max stays selectable while the hours past it go struck through; a picker that checked the date alone would happily emit 15:00 under a 14:00 deadline.
  • Refuse versus clamp — an hour with no legal minute at all is refused and the value is left alone; an hour that is legal but whose composed instant is not gets pulled to the nearest rung and announced. Two different sentences, because they are two different answers.
  • The ladder is the reachable set — hours crossed with the minute and second steps define exactly which instants exist in this control, and the live value's own off-step minute is spliced in so a 09:07 from the server survives being edited elsewhere.
  • Selection follows focus in the ladders — arrowing a column commits as it moves, which is what makes two halves read as one control, and aria-activedescendant keeps DOM focus on the listbox so rows can appear and disappear underneath without dropping it.
  • Inert, not silent — the time columns before a day is chosen, Now outside the window, Clear with nothing to clear: all stay mounted with aria-disabled and answer when pressed, because the native disabled attribute blurs whatever the user was standing on.
  • Injected instant — "today" and Now come from a now prop, or from a minute-granular external store whose server snapshot is null; a render-time clock read makes the server and the browser disagree about what day it is.

On This Page