Time date pickers
Five date pickers that add a time layer next to the calendar: a half-hour list, hour and minute columns, a clock grid, booking slots and a start plus duration. Times are fixed values, so the render is deterministic. Each is self-contained (no date library), sized, token-driven, and closes on day select, outside click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/date-picker-timeDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/date-picker-time.tsx"use client"
import * as React from "react"
import { CalendarClock, ChevronLeft, ChevronRight, Clock } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion, type Transition } from "motion/react"
import { cn } from "@/lib/utils"
/* Time date picker family: 5 self-contained date + time pickers. The difference is
the time layer beside the calendar: an hour list, separate hour/minute columns, an
hour grid, appointment slots, duration selection. NO radix, NO date library, NO
portal, NO Intl. Each export is a complete picker: a relative wrapper + a trigger
(aria-haspopup="dialog", aria-expanded) + a panel absolutely positioned BELOW the
trigger (a role="grid" month grid + a time column). Picking a day updates the
value and closes the panel; picking a time does not close it (day first, then time).
It closes on an outside click or Escape (focus returns to the trigger).
CRITICAL: all dates and times derive from FIXED values (FIXED_MONTH = 2026-01, a
default time of 09:00) - Date.now/new Date() are NEVER called. Reading the real
clock breaks prerender/hydration determinism.
State trap: the selection and time state is held OUTSIDE THE POPOVER TREE - the
panel unmounts on close, and the selection lives on. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const triggerHeight: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-base",
xl: "h-12 text-base",
}
/* SSR-guvenli sabitler. today ASLA kullanilmaz. */
const FIXED_MONTH = new Date(2026, 0, 1)
const DEFAULT_HOUR = 9
const DEFAULT_MINUTE = 0
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`
}
function formatDate(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
function formatLong(d: Date): string {
return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`
}
function formatTime(h: number, m: number): string {
return `${pad2(h)}:${pad2(m)}`
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
function sameDay(a: Date | undefined, b: Date | undefined): boolean {
return (
!!a &&
!!b &&
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function useDismiss(
open: boolean,
onClose: (refocus: boolean) => void,
ref: React.RefObject<HTMLDivElement | null>
) {
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(true)
}
const onPointer = (e: PointerEvent) => {
const node = ref.current
if (node && e.target instanceof Node && !node.contains(e.target)) {
onClose(false)
}
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onPointer)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onPointer)
}
}, [open, onClose, ref])
}
const springTransition: Transition = { type: "spring" as const, stiffness: 340, damping: 26 }
const panelMotion = {
initial: { opacity: 0, scale: 0.96, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.96, y: -6 },
}
const triggerBase =
"inline-flex w-full select-none items-center gap-2 whitespace-nowrap rounded-lg px-3 font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const navBtn =
"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const dayBase =
"relative inline-flex size-8 items-center justify-center rounded-md text-sm text-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50"
const daySelected =
"bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--color-primary)_90%,transparent)]"
const timeBtnBase =
"w-full shrink-0 cursor-default select-none rounded-md px-2 py-1 text-center text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
const timeBtnActive =
"bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--color-primary)_90%,transparent)] hover:text-primary-foreground"
const columnBase =
"flex max-h-56 flex-col gap-0.5 overflow-y-auto scroll-smooth pr-1"
function CalendarBody({
month,
onMonthChange,
selected,
onPick,
labelId,
}: {
month: Date
onMonthChange: (d: Date) => void
selected: Date | undefined
onPick: (d: Date) => void
labelId: string
}) {
const year = month.getFullYear()
const m = month.getMonth()
const leading = new Date(year, m, 1).getDay()
const total = daysInMonth(year, m)
const cells: (Date | null)[] = []
for (let i = 0; i < leading; i++) cells.push(null)
for (let d = 1; d <= total; d++) cells.push(new Date(year, m, d))
return (
<div data-slot="styled-date-picker-calendar" className="flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<button
type="button"
aria-label="Previous month"
className={navBtn}
onClick={() => onMonthChange(new Date(year, m - 1, 1))}
>
<ChevronLeft />
</button>
<div id={labelId} className="text-sm font-semibold text-foreground">
{MONTH_NAMES[m]} {year}
</div>
<button
type="button"
aria-label="Next month"
className={navBtn}
onClick={() => onMonthChange(new Date(year, m + 1, 1))}
>
<ChevronRight />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5">
{WEEKDAY_LABELS.map((w) => (
<div
key={w}
className="flex size-8 items-center justify-center text-xs font-medium text-muted-foreground"
>
{w}
</div>
))}
</div>
<div role="grid" aria-labelledby={labelId} className="grid grid-cols-7 gap-0.5">
{cells.map((day, i) =>
day === null ? (
<span key={`empty-${i}`} className="size-8" aria-hidden="true" />
) : (
<button
key={formatDate(day)}
type="button"
role="gridcell"
aria-label={formatLong(day)}
aria-selected={sameDay(day, selected)}
className={cn(dayBase, sameDay(day, selected) && daySelected)}
onClick={() => onPick(day)}
>
{day.getDate()}
</button>
)
)}
</div>
</div>
)
}
export interface DatePickerProps {
className?: string
size?: StyledSize
placeholder?: string
value?: Date
defaultValue?: Date
onChange?: (d: Date) => void
}
/* Shared body. `timeSlot` draws the panel's hour layer; the selection state lives OUTSIDE the popover. A day click closes the panel, an hour click does not. */
function TimePickerShell({
className,
size = "md",
placeholder = "Pick a date and time",
value,
defaultValue,
onChange,
panelWidth = "w-[22rem]",
renderTime,
timeLabel,
triggerIcon,
}: DatePickerProps & {
panelWidth?: string
renderTime: (ctx: {
hour: number
minute: number
setHour: (h: number) => void
setMinute: (m: number) => void
}) => React.ReactNode
timeLabel: (hour: number, minute: number) => string
triggerIcon?: React.ReactNode
}) {
const isControlled = value !== undefined
const [internal, setInternal] = React.useState<Date | undefined>(defaultValue)
const selected = isControlled ? value : internal
const [open, setOpen] = React.useState(false)
const [month, setMonth] = React.useState(() => startOfMonth(selected ?? FIXED_MONTH))
const [hour, setHour] = React.useState(DEFAULT_HOUR)
const [minute, setMinute] = React.useState(DEFAULT_MINUTE)
const reduce = useReducedMotion()
const wrapperRef = React.useRef<HTMLDivElement>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const panelId = React.useId()
const labelId = React.useId()
const close = React.useCallback((refocus: boolean) => {
setOpen(false)
if (refocus) triggerRef.current?.focus()
}, [])
useDismiss(open, close, wrapperRef)
const pick = (day: Date) => {
const next = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute)
if (!isControlled) setInternal(next)
onChange?.(next)
setMonth(startOfMonth(next))
setOpen(false)
triggerRef.current?.focus()
}
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const mp = reduce ? fade : panelMotion
return (
<div
ref={wrapperRef}
data-slot="styled-date-picker"
className={cn("relative inline-flex w-64 flex-col", className)}
>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
className={cn(
triggerBase,
triggerHeight[size],
"justify-between border border-field-border bg-transparent text-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_4%,transparent)]"
)}
onClick={() => setOpen(!open)}
>
<span className={cn(!selected && "text-muted-foreground")}>
{selected ? `${formatDate(selected)} ${timeLabel(hour, minute)}` : placeholder}
</span>
{triggerIcon ?? <CalendarClock className="text-muted-foreground" />}
</button>
<AnimatePresence>
{open ? (
<motion.div
id={panelId}
data-slot="styled-date-picker-popover"
className={cn(
"absolute left-0 top-full z-50 mt-2 flex origin-top gap-3 rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-lg outline-none",
panelWidth
)}
initial={mp.initial}
animate={mp.animate}
exit={mp.exit}
transition={reduce ? ({ duration: 0.12 } as Transition) : springTransition}
>
<div className="min-w-0 flex-1">
<CalendarBody
month={month}
onMonthChange={setMonth}
selected={selected}
onPick={pick}
labelId={labelId}
/>
</div>
<div
data-slot="styled-date-picker-time"
className="flex shrink-0 flex-col gap-1 border-l border-border pl-3"
>
{renderTime({ hour, minute, setHour, setMinute })}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
const HOURS = Array.from({ length: 24 }, (_, i) => i)
const HALF_HOURS: [number, number][] = HOURS.flatMap((h) => [
[h, 0] as [number, number],
[h, 30] as [number, number],
])
const MINUTES = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
const BUSINESS_SLOTS: [number, number][] = [
[9, 0],
[9, 30],
[10, 0],
[10, 30],
[11, 0],
[13, 0],
[13, 30],
[14, 0],
[15, 0],
[16, 30],
]
const DURATIONS = [15, 30, 45, 60, 90, 120]
function ColumnHeading({ children }: { children: React.ReactNode }) {
return (
<div className="px-1 pb-1 text-[0.6875rem] font-semibold uppercase tracking-wide text-muted-foreground">
{children}
</div>
)
}
/* TimeDatePicker: tek sutunda yarim saatlik zaman listesi. */
export function TimeDatePicker(props: DatePickerProps) {
return (
<TimePickerShell
{...props}
timeLabel={(h, m) => formatTime(h, m)}
renderTime={({ hour, minute, setHour, setMinute }) => (
<>
<ColumnHeading>Time</ColumnHeading>
<div className={cn(columnBase, "w-16")}>
{HALF_HOURS.map(([h, m]) => (
<button
key={formatTime(h, m)}
type="button"
aria-pressed={hour === h && minute === m}
aria-label={`Set time to ${formatTime(h, m)}`}
className={cn(timeBtnBase, hour === h && minute === m && timeBtnActive)}
onClick={() => {
setHour(h)
setMinute(m)
}}
>
{formatTime(h, m)}
</button>
))}
</div>
</>
)}
/>
)
}
/* DateTimeDatePicker: ayri saat ve dakika sutunlari. */
export function DateTimeDatePicker(props: DatePickerProps) {
return (
<TimePickerShell
{...props}
panelWidth="w-[24rem]"
timeLabel={(h, m) => formatTime(h, m)}
renderTime={({ hour, minute, setHour, setMinute }) => (
<div className="flex gap-2">
<div className="flex flex-col">
<ColumnHeading>Hr</ColumnHeading>
<div className={cn(columnBase, "w-11")}>
{HOURS.map((h) => (
<button
key={h}
type="button"
aria-pressed={hour === h}
aria-label={`Set hour to ${pad2(h)}`}
className={cn(timeBtnBase, hour === h && timeBtnActive)}
onClick={() => setHour(h)}
>
{pad2(h)}
</button>
))}
</div>
</div>
<div className="flex flex-col">
<ColumnHeading>Min</ColumnHeading>
<div className={cn(columnBase, "w-11")}>
{MINUTES.map((m) => (
<button
key={m}
type="button"
aria-pressed={minute === m}
aria-label={`Set minute to ${pad2(m)}`}
className={cn(timeBtnBase, minute === m && timeBtnActive)}
onClick={() => setMinute(m)}
>
{pad2(m)}
</button>
))}
</div>
</div>
</div>
)}
/>
)
}
/* ClockDatePicker: 12'li saat izgarasi + saat ikonlu trigger. */
export function ClockDatePicker(props: DatePickerProps) {
return (
<TimePickerShell
{...props}
panelWidth="w-[24rem]"
triggerIcon={<Clock className="text-muted-foreground" />}
timeLabel={(h, m) => formatTime(h, m)}
renderTime={({ hour, minute, setHour, setMinute }) => (
<>
<ColumnHeading>Clock</ColumnHeading>
<div className="grid w-24 grid-cols-3 gap-0.5">
{HOURS.map((h) => (
<button
key={h}
type="button"
aria-pressed={hour === h}
aria-label={`Set hour to ${pad2(h)}`}
className={cn(timeBtnBase, "px-0", hour === h && timeBtnActive)}
onClick={() => setHour(h)}
>
{pad2(h)}
</button>
))}
</div>
<ColumnHeading>Minute</ColumnHeading>
<div className="grid w-24 grid-cols-3 gap-0.5">
{[0, 15, 30, 45].map((m) => (
<button
key={m}
type="button"
aria-pressed={minute === m}
aria-label={`Set minute to ${pad2(m)}`}
className={cn(timeBtnBase, "px-0", minute === m && timeBtnActive)}
onClick={() => setMinute(m)}
>
{pad2(m)}
</button>
))}
</div>
</>
)}
/>
)
}
/* SlotsDatePicker: is saatlerine gore randevu slotlari. */
export function SlotsDatePicker(props: DatePickerProps) {
return (
<TimePickerShell
{...props}
placeholder={props.placeholder ?? "Pick a slot"}
timeLabel={(h, m) => formatTime(h, m)}
renderTime={({ hour, minute, setHour, setMinute }) => (
<>
<ColumnHeading>Slots</ColumnHeading>
<div className={cn(columnBase, "w-16")}>
{BUSINESS_SLOTS.map(([h, m]) => (
<button
key={formatTime(h, m)}
type="button"
aria-pressed={hour === h && minute === m}
aria-label={`Book the ${formatTime(h, m)} slot`}
className={cn(timeBtnBase, hour === h && minute === m && timeBtnActive)}
onClick={() => {
setHour(h)
setMinute(m)
}}
>
{formatTime(h, m)}
</button>
))}
</div>
</>
)}
/>
)
}
/* DurationDatePicker: a start time + a duration; the trigger shows the start and the
end. */
export function DurationDatePicker(props: DatePickerProps) {
const [duration, setDuration] = React.useState(30)
const endLabel = React.useCallback(
(h: number, m: number) => {
const total = h * 60 + m + duration
return formatTime(Math.floor(total / 60) % 24, total % 60)
},
[duration]
)
return (
<TimePickerShell
{...props}
panelWidth="w-[24rem]"
className={cn("w-72", props.className)}
placeholder={props.placeholder ?? "Pick a date and duration"}
timeLabel={(h, m) => `${formatTime(h, m)} - ${endLabel(h, m)}`}
renderTime={({ hour, minute, setHour, setMinute }) => (
<div className="flex gap-2">
<div className="flex flex-col">
<ColumnHeading>Start</ColumnHeading>
<div className={cn(columnBase, "w-16")}>
{HALF_HOURS.map(([h, m]) => (
<button
key={formatTime(h, m)}
type="button"
aria-pressed={hour === h && minute === m}
aria-label={`Start at ${formatTime(h, m)}`}
className={cn(timeBtnBase, hour === h && minute === m && timeBtnActive)}
onClick={() => {
setHour(h)
setMinute(m)
}}
>
{formatTime(h, m)}
</button>
))}
</div>
</div>
<div className="flex flex-col">
<ColumnHeading>Length</ColumnHeading>
<div className={cn(columnBase, "w-14")}>
{DURATIONS.map((d) => (
<button
key={d}
type="button"
aria-pressed={duration === d}
aria-label={`Set duration to ${d} minutes`}
className={cn(timeBtnBase, duration === d && timeBtnActive)}
onClick={() => setDuration(d)}
>
{d}m
</button>
))}
</div>
</div>
</div>
)}
/>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Time
A single column of half-hour times.
import { TimeDatePicker } from "@/components/ui/date-picker-time"
<TimeDatePicker />Date time
Separate hour and minute columns.
import { DateTimeDatePicker } from "@/components/ui/date-picker-time"
<DateTimeDatePicker />Clock
An hour grid with quarter-hour minutes.
import { ClockDatePicker } from "@/components/ui/date-picker-time"
<ClockDatePicker />Slots
Bookable slots within business hours.
import { SlotsDatePicker } from "@/components/ui/date-picker-time"
<SlotsDatePicker />Duration
A start time plus a length, showing the end.
import { DurationDatePicker } from "@/components/ui/date-picker-time"
<DurationDatePicker />ai2 Time date pickers: 5 styled variations on the token system
The ai2 Time date pickers are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around date pickers that select a date and a time together. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion springs the calendar popover in from the trigger. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the popover fades in with no transform.
What is in the ai2 Time date pickers?
5 exports in one file: Time, Date time, Clock, Slots and Duration. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion springs the calendar popover in from the trigger.
- Reduced-motion aware: Under prefers-reduced-motion, the popover fades in with no transform.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Time date pickers sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.