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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/datetime-picker.jsonPrompt
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
maxstays 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:07from 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-activedescendantkeeps 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,
Nowoutside the window,Clearwith nothing to clear: all stay mounted witharia-disabledand answer when pressed, because the nativedisabledattribute blurs whatever the user was standing on. - Injected instant — "today" and
Nowcome from anowprop, 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.
Business Hours
A weekly opening-hours editor — per-day open/closed switches, split shifts, blocks that cross midnight, overlap detection that follows the spill into the next day, and a plain-language summary.
Period Picker
A whole-period picker — week, month, quarter or year — whose grid changes shape per granularity and whose value carries the resolved start and end instants.