Manifest UI
blockdate-time-pickersource: ok · hit
Date & Time Picker
A Calendly-style date and time picker with calendar, available time slots, and timezone display.
npx shadcn add "https://ui.manifest.build/r/date-time-picker.json"free
Where it goes
calendar
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
buttonpopover
Files2 · source cached 10/10/2026, 12:19:38 AM
registry/form/date-time-picker.tsx
'use client'
import { useState, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover'
import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { demoDateTimePickerData } from './demo/form'
/** Timezone configuration using IANA timezone identifiers for correct DST handling */
const timezones = [
{ id: 'pacific', name: 'Pacific Time - US & Canada', iana: 'America/Los_Angeles' },
{ id: 'mountain', name: 'Mountain Time - US & Canada', iana: 'America/Denver' },
{ id: 'central', name: 'Central Time - US & Canada', iana: 'America/Chicago' },
{ id: 'eastern', name: 'Eastern Time - US & Canada', iana: 'America/New_York' },
{ id: 'alaska', name: 'Alaska Time', iana: 'America/Anchorage' },
{ id: 'arizona', name: 'Arizona, Yukon Time', iana: 'America/Phoenix' },
{ id: 'newfoundland', name: 'Newfoundland Time', iana: 'America/St_Johns' },
{ id: 'atlantic', name: 'Atlantic Time - Canada', iana: 'America/Halifax' },
{ id: 'london', name: 'London, Dublin, Edinburgh', iana: 'Europe/London' },
{ id: 'paris', name: 'Paris, Berlin, Amsterdam', iana: 'Europe/Paris' },
{ id: 'athens', name: 'Athens, Helsinki, Istanbul', iana: 'Europe/Athens' },
{ id: 'moscow', name: 'Moscow, St. Petersburg', iana: 'Europe/Moscow' },
{ id: 'dubai', name: 'Dubai, Abu Dhabi', iana: 'Asia/Dubai' },
{ id: 'karachi', name: 'Karachi, Islamabad', iana: 'Asia/Karachi' },
{ id: 'dhaka', name: 'Dhaka, Almaty', iana: 'Asia/Dhaka' },
{ id: 'bangkok', name: 'Bangkok, Hanoi, Jakarta', iana: 'Asia/Bangkok' },
{ id: 'singapore', name: 'Singapore, Hong Kong, Perth', iana: 'Asia/Singapore' },
{ id: 'tokyo', name: 'Tokyo, Seoul, Osaka', iana: 'Asia/Tokyo' },
{ id: 'sydney', name: 'Sydney, Melbourne, Brisbane', iana: 'Australia/Sydney' },
{ id: 'auckland', name: 'Auckland, Wellington', iana: 'Pacific/Auckland' }
]
const getTimeForTimezone = (iana: string) => {
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: iana,
hour: 'numeric',
minute: '2-digit',
hour12: true,
}).format(new Date()).toLowerCase()
} catch {
return ''
}
}
/**
* ═══════════════════════════════════════════════════════════════════════════
* DateTimePickerProps
* ═══════════════════════════════════════════════════════════════════════════
*
* Props for the DateTimePicker component with calendar view, available time
* slots, and timezone selection.
*/
export interface DateTimePickerProps {
data?: {
/** Title displayed at the top of the picker. */
title?: string
/** Array of dates that can be selected. */
availableDates?: Date[]
/** Array of time slot strings (e.g., '11:30am'). */
availableTimeSlots?: string[]
/** Default timezone name to display. */
timezone?: string
}
actions?: {
/** Called when the user clicks the Next button. */
onNext?: (date: Date, time: string) => void
}
appearance?: {
/**
* Whether to display the title.
* @default true
*/
showTitle?: boolean
/**
* Whether to show timezone selector.
* @default true
*/
showTimezone?: boolean
/**
* First day of the week.
* - `'sunday'` — US, Canada, Japan (default)
* - `'monday'` — ISO 8601, Europe, most of the world
* - `'saturday'` — Middle East
* @default 'sunday'
*/
weekStartsOn?: 'sunday' | 'monday' | 'saturday'
}
control?: {
/** Controlled selected date value. */
selectedDate?: Date | null
/** Controlled selected time value. */
selectedTime?: string | null
}
}
const ALL_DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const WEEK_START_OFFSETS: Record<'sunday' | 'monday' | 'saturday', number> = {
sunday: 0,
monday: 1,
saturday: 6,
}
const getOrderedDays = (weekStartsOn: 'sunday' | 'monday' | 'saturday') => {
const offset = WEEK_START_OFFSETS[weekStartsOn]
return [...ALL_DAYS.slice(offset), ...ALL_DAYS.slice(0, offset)]
}
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
const formatDateHeader = (date: Date) => {
const dayName = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()]
const monthName = MONTHS[date.getMonth()]
return `${dayName}, ${monthName} ${date.getDate()}`
}
const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
/**
* A Calendly-style date and time picker with calendar view, available time slots,
* and timezone selection. Supports both desktop and mobile layouts.
*
* Features:
* - Monthly calendar navigation with available date highlighting
* - Time slot selection with animated transitions
* - Searchable timezone dropdown with current time display
* - Responsive design with mobile-first time view
* - Today indicator and selected date highlighting
* - Controlled and uncontrolled modes
*
* @component
* @example
* ```tsx
* <DateTimePicker
* data={{
* title: "Schedule a Meeting",
* availableDates: [new Date('2024-01-15'), new Date('2024-01-16')],
* availableTimeSlots: ['9:00am', '10:00am', '2:00pm'],
* timezone: "Pacific Time - US & Canada"
* }}
* actions={{
* onSelect: (date, time) => console.log("Selected:", date, time),
* onNext: (date, time) => console.log("Proceeding with:", date, time)
* }}
* appearance={{ showTitle: true, showTimezone: true }}
* />
* ```
*/
export function DateTimePicker({ data, actions, appearance, control }: DateTimePickerProps) {
const resolved: NonNullable<DateTimePickerProps['data']> = data ?? demoDateTimePickerData
const title = resolved.title
const availableDates = resolved.availableDates ?? []
const availableTimeSlots = resolved.availableTimeSlots ?? []
const timezone = resolved.timezone
const { onNext } = actions ?? {}
const { showTitle = true, showTimezone = true, weekStartsOn = 'sunday' } = appearance ?? {}
const orderedDays = getOrderedDays(weekStartsOn)
const weekStartOffset = WEEK_START_OFFSETS[weekStartsOn]
const {
selectedDate: controlledDate,
selectedTime: controlledTime
} = control ?? {}
const [currentMonth, setCurrentMonth] = useState(() => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1)
})
const [selectedDate, setSelectedDate] = useState<Date | null>(controlledDate ?? null)
const [selectedTime, setSelectedTime] = useState<string | null>(controlledTime ?? null)
const [selectedTimezone, setSelectedTimezone] = useState(timezones.find(tz => tz.name === timezone) || timezones[3])
const [timezoneSearch, setTimezoneSearch] = useState('')
const [timezoneDropdownOpen, setTimezoneDropdownOpen] = useState(false)
const timezoneSearchRef = useRef<HTMLInputElement>(null)
// Mobile view mode: 'calendar' or 'time'
const [mobileView, setMobileView] = useState<'calendar' | 'time'>('calendar')
const filteredTimezones = timezones.filter(tz =>
tz.name.toLowerCase().includes(timezoneSearch.toLowerCase())
)
useEffect(() => {
if (timezoneDropdownOpen && timezoneSearchRef.current) {
timezoneSearchRef.current.focus()
}
}, [timezoneDropdownOpen])
const handleTimezoneSelect = (tz: typeof timezones[0]) => {
setSelectedTimezone(tz)
setTimezoneDropdownOpen(false)
setTimezoneSearch('')
}
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
// Calculate calendar grid
const firstDayOfMonth = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const daysInPrevMonth = new Date(year, month, 0).getDate()
const calendarDays: { day: number; isCurrentMonth: boolean; date: Date }[] = []
// Previous month days (adjusted for week start)
const leadingDays = (firstDayOfMonth - weekStartOffset + 7) % 7
for (let i = leadingDays - 1; i >= 0; i--) {
const day = daysInPrevMonth - i
calendarDays.push({
day,
isCurrentMonth: false,
date: new Date(year, month - 1, day)
})
}
// Current month days
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push({
day,
isCurrentMonth: true,
date: new Date(year, month, day)
})
}
// Next month days to fill the grid (6 rows max)
const totalCells = Math.ceil(calendarDays.length / 7) * 7
const remainingDays = totalCells - calendarDays.length
for (let day = 1; day <= remainingDays; day++) {
calendarDays.push({
day,
isCurrentMonth: false,
date: new Date(year, month + 1, day)
})
}
const isDateAvailable = (date: Date) => {
return availableDates.some(d => isSameDay(d, date))
}
const handlePrevMonth = () => {
setCurrentMonth(new Date(year, month - 1, 1))
}
const handleNextMonth = () => {
setCurrentMonth(new Date(year, month + 1, 1))
}
const handleDateSelect = (date: Date) => {
if (!isDateAvailable(date)) return
setSelectedDate(date)
setSelectedTime(null)
// On mobile, switch to time view when date is selected
setMobileView('time')
}
const handleBackToCalendar = () => {
setMobileView('calendar')
}
const handleTimeSelect = (time: string) => {
setSelectedTime(time)
}
const handleNext = () => {
if (selectedDate && selectedTime) {
onNext?.(selectedDate, selectedTime)
}
}
const now = new Date()
return (
<div className="w-full bg-card rounded-xl p-6">
{showTitle && title && (
<h2 className="text-xl font-semibold text-foreground mb-6">{title}</h2>
)}
<div className="flex justify-center">
{/* Calendar Section - Hidden on mobile when viewing time slots */}
<div className={cn(
"w-[304px] flex-shrink-0",
mobileView === 'time' ? 'hidden md:block' : 'block'
)}>
{/* Month Navigation */}
<div className="flex items-center justify-center gap-4 mb-4">
<button
onClick={handlePrevMonth}
aria-label="Previous month"
className="p-1 hover:bg-muted rounded transition-colors"
>
<ChevronLeft className="h-5 w-5 text-muted-foreground" />
</button>
<span className="text-base font-medium text-foreground min-w-[140px] text-center">
{MONTHS[month]} {year}
</span>
<button
onClick={handleNextMonth}
aria-label="Next month"
className="p-1 hover:bg-muted rounded transition-colors"
>
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</button>
</div>
{/* Day Headers */}
<div className="grid grid-cols-7 mb-2">
{orderedDays.map(day => (
<div
key={day}
className="text-center text-xs font-medium text-muted-foreground py-2"
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className="grid grid-cols-7 gap-y-1">
{calendarDays.map((item, index) => {
const isAvailable = item.isCurrentMonth && isDateAvailable(item.date)
const isSelected = selectedDate && isSameDay(item.date, selectedDate)
const isToday = isSameDay(item.date, now)
return (
<button
key={index}
onClick={() => item.isCurrentMonth && handleDateSelect(item.date)}
disabled={!item.isCurrentMonth || !isAvailable}
className={cn(
'relative h-10 w-10 rounded-full text-sm transition-all duration-200 flex items-center justify-center mx-auto',
!item.isCurrentMonth && 'text-muted-foreground/30',
item.isCurrentMonth && !isAvailable && 'text-muted-foreground cursor-default',
item.isCurrentMonth && isAvailable && !isSelected && 'text-primary font-medium hover:bg-primary/10 cursor-pointer',
isSelected && 'bg-primary text-primary-foreground font-medium',
isAvailable && !isSelected && 'bg-primary/10'
)}
>
{item.day}
{isToday && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-foreground" />
)}
</button>
)
})}
</div>
{/* Timezone */}
{showTimezone && (
<div className="mt-6">
<p className="text-sm font-medium text-foreground mb-2">Time zone</p>
<Popover open={timezoneDropdownOpen} onOpenChange={setTimezoneDropdownOpen}>
<PopoverTrigger asChild>
<button
aria-label="Select timezone"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<Globe className="h-4 w-4" />
<span>{selectedTimezone.name} ({getTimeForTimezone(selectedTimezone.iana)})</span>
<ChevronRight className={cn("h-3 w-3 transition-transform", timezoneDropdownOpen ? "rotate-90" : "rotate-0")} />
</button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
ref={timezoneSearchRef}
type="text"
placeholder="Search timezone..."
value={timezoneSearch}
onChange={(e) => setTimezoneSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border rounded-md focus:outline-none focus:border-primary bg-background"
/>
</div>
</div>
<div className="max-h-[280px] overflow-y-auto">
{filteredTimezones.map((tz) => (
<button
key={tz.id}
onClick={() => handleTimezoneSelect(tz)}
className={cn(
"w-full px-3 py-2.5 text-left text-sm hover:bg-muted transition-colors flex items-center justify-between",
selectedTimezone.id === tz.id && "bg-muted"
)}
>
<span className="text-foreground">{tz.name}</span>
<span className="text-muted-foreground text-xs">{getTimeForTimezone(tz.iana)}</span>
</button>
))}
{filteredTimezones.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No timezone found
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* Time Slots Section - Visible on mobile when viewing times, animated on desktop */}
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-out',
// Mobile: show/hide based on mobileView, full width
mobileView === 'time' ? 'block w-full md:w-[200px]' : 'hidden md:block',
// Desktop: animate width based on selectedDate
selectedDate ? 'md:w-[200px] md:opacity-100 md:ml-8' : 'md:w-0 md:opacity-0 md:ml-0'
)}
>
<div className="w-full md:w-[200px]">
{/* Back button - Mobile only */}
<button
onClick={handleBackToCalendar}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4 md:hidden"
>
<ArrowLeft className="h-4 w-4" />
<span>Back to calendar</span>
</button>
<p className="text-base font-medium text-foreground mb-4 whitespace-nowrap">
{selectedDate ? formatDateHeader(selectedDate) : ''}
</p>
<div className="space-y-2 max-h-[320px] overflow-y-auto">
{availableTimeSlots.map((time) => {
const isTimeSelected = selectedTime === time
return (
<div key={time} className="grid grid-cols-2 gap-2">
<button
onClick={() => handleTimeSelect(time)}
className={cn(
'h-[52px] rounded-lg border text-sm font-semibold transition-all duration-200',
isTimeSelected
? 'bg-muted-foreground text-background border-muted-foreground'
: 'col-span-2 border-primary text-primary hover:bg-primary/5'
)}
>
{time}
</button>
{isTimeSelected && (
<Button
onClick={handleNext}
className="h-[52px] animate-in fade-in slide-in-from-left-2 duration-200"
>
Next
</Button>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
)
}
registry/form/demo/form.ts
// Demo data for Form category components
// This file contains sample data used for component previews and documentation
/**
* Generate available dates dynamically so the DateTimePicker preview
* always has clickable dates regardless of when it is viewed.
*/
function generateAvailableDates(): Date[] {
const dates: Date[] = [];
const now = new Date();
// Walk through current and next month, only keeping weekdays (Mon-Fri)
for (let m = 0; m <= 1; m++) {
for (let d = 1; d <= 28; d += 2) {
const date = new Date(now.getFullYear(), now.getMonth() + m, d);
const day = date.getDay();
if (day !== 0 && day !== 6) {
dates.push(date);
}
}
}
return dates;
}
export const demoContactFormData = {
title: 'Get in Touch',
subtitle: "We'd love to hear from you. Fill out the form below.",
submitLabel: 'Send Message',
}
export const demoIssueReportFormData = {
title: 'Report an Issue',
teams: ['Engineering', 'Product', 'Design', 'Marketing', 'Operations'],
locations: ['New York - HQ', 'San Francisco', 'London', 'Remote'],
categories: {
Software: ['Business App', 'Email', 'VPN', 'Browser', 'OS'],
Hardware: ['Computer', 'Monitor', 'Keyboard', 'Mouse', 'Printer'],
Network: ['Wi-Fi', 'Ethernet', 'VPN Access'],
Access: ['Account', 'Permissions', 'Password Reset'],
} as Record<string, string[]>,
impacts: [
{ value: 'critical', label: 'Critical - Work stopped' },
{ value: 'high', label: 'High - Major feature broken' },
{ value: 'medium', label: 'Medium - Workaround available' },
{ value: 'low', label: 'Low - Minor inconvenience' },
],
urgencies: [
{ value: 'immediate', label: 'Immediate' },
{ value: 'today', label: 'Today' },
{ value: 'this-week', label: 'This week' },
{ value: 'no-rush', label: 'No rush' },
],
frequencies: [
{ value: 'constant', label: 'Constant' },
{ value: 'frequent', label: 'Frequent' },
{ value: 'occasional', label: 'Occasional' },
{ value: 'once', label: 'Happened once' },
],
attemptedActions: [
'Restarted the application',
'Cleared browser cache',
'Restarted computer',
'Checked internet connection',
'Contacted a colleague',
],
}
export const demoDateTimePickerData = {
title: 'Select a Date & Time',
availableDates: generateAvailableDates(),
availableTimeSlots: [
'9:00am',
'10:00am',
'11:30am',
'1:00pm',
'2:30pm',
'4:00pm',
],
timezone: 'Eastern Time - US & Canada',
};