Blogs

Building a Production-Ready Date Picker with Zag.js

Building a Production-Ready Date Picker with Zag.js

Demo

Repo GitHub Repository

I love using the shadcn/ui library for building React components - it provides beautiful, accessible components that work perfectly with Tailwind CSS. However, after searching through their component collection, I noticed they don't provide a date picker component with the level of customization I needed.

After lots of research, I discovered Zag.js - a headless UI library that provides unstyled, accessible primitives built as state machines. Their date picker component offers a simple API that we can easily integrate into our design system while maintaining full control over styling and behavior.

I picked up their API, added some custom input masking, and enhanced it to work seamlessly with React Hook Form and Zod validation.

What We Built

Here's what our enhanced date picker includes:

  • Smart input masking with automatic MM/DD/YYYY formatting
  • Dual API design (single date and array-based for flexibility)
  • React Hook Form + Zod integration for form validation
  • Enhanced UX with improved focus behavior

Installation

bash

npm install @zag-js/date-picker @zag-js/react lucide-react

Component Features

1. Dual API Design

tsx

// Simple single date API <DatePicker singleValue={date} onSingleValueChange={setDate} /> // Array-based API (for multiple dates) <DatePicker value={dates} onValueChange={details => setDates(details.value)} />

2. Smart Input Masking

Automatically formats user input to MM/DD/YYYY as they type, with intelligent cursor positioning and backspace handling.

3. Enhanced UX

  • Date picker opens only when calendar icon is clicked (not on input focus)
  • Proper keyboard navigation and accessibility
  • Beautiful styling that matches shadcn/ui design patterns

The Complete Component

tsx

'use client'; import * as datepicker from '@zag-js/date-picker'; import { normalizeProps, useMachine } from '@zag-js/react'; import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; import { useId } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; type DatePickerProps = { value?: Date[]; defaultValue?: Date[]; onValueChange?: (details: { value: Date[]; valueAsString: string[] }) => void; // Single date mode props singleValue?: Date | null; onSingleValueChange?: (date: Date | null) => void; open?: boolean; onOpenChange?: (details: { open: boolean }) => void; disabled?: boolean; readOnly?: boolean; placeholder?: string; name?: string; min?: Date; max?: Date; closeOnSelect?: boolean; className?: string; }; export default function DatePicker({ value, defaultValue, onValueChange, open, onOpenChange, disabled = false, readOnly = false, placeholder = 'MM/DD/YYYY', name, min, max, closeOnSelect = true, className, singleValue, onSingleValueChange, }: DatePickerProps) { // Handle single date mode conversion const actualValue = singleValue ? [singleValue] : value; const actualDefaultValue = singleValue ? singleValue ? [singleValue] : undefined : defaultValue; const service = useMachine(datepicker.machine, { id: useId(), value: actualValue?.map(date => datepicker.parse(date.toISOString().split('T')[0]) ), defaultValue: actualDefaultValue?.map(date => datepicker.parse(date.toISOString().split('T')[0]) ), onValueChange: details => { const dates = details.value.map(dateValue => new Date(dateValue.toString())); // Handle single date mode callback if (onSingleValueChange) { onSingleValueChange(dates.length > 0 ? dates[0] : null); } // Handle array mode callback if (onValueChange) { onValueChange({ value: dates, valueAsString: details.valueAsString }); } }, open, onOpenChange, disabled, readOnly, placeholder, name, min: min ? datepicker.parse(min.toISOString().split('T')[0]) : undefined, max: max ? datepicker.parse(max.toISOString().split('T')[0]) : undefined, closeOnSelect, }); const api = datepicker.connect(service, normalizeProps); // Handle input with proper masking const handleInputEvent = (e: React.FormEvent<HTMLInputElement>) => { const input = e.currentTarget; const value = input.value; const cursorPosition = input.selectionStart || 0; // Remove all non-digits and apply mask const digits = value.replace(/\D/g, ''); let formattedValue = ''; if (digits.length > 0) { formattedValue += digits.substring(0, 2); if (digits.length >= 3) { formattedValue += `/${digits.substring(2, 4)}`; if (digits.length >= 5) { formattedValue += `/${digits.substring(4, 8)}`; } } } // Only update if the formatted value is different if (formattedValue !== value) { input.value = formattedValue; // Adjust cursor position let newCursorPosition = cursorPosition; // If we just added a slash, move cursor past it if ( formattedValue.length > value.length && (formattedValue[cursorPosition] === '/' || formattedValue[cursorPosition - 1] === '/') ) { newCursorPosition = cursorPosition + 1; } // Set cursor position setTimeout(() => { input.setSelectionRange(newCursorPosition, newCursorPosition); }, 0); } }; // Handle key down for backspace behavior const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const input = e.currentTarget; const cursorPosition = input.selectionStart || 0; // Handle backspace on slash characters if (e.key === 'Backspace' && cursorPosition > 0) { const prevChar = input.value[cursorPosition - 1]; if (prevChar === '/') { e.preventDefault(); const newValue = `${input.value.slice(0, cursorPosition - 2)}${input.value.slice(cursorPosition)}`; input.value = newValue; input.setSelectionRange(cursorPosition - 1, cursorPosition - 1); return; } } // Call original keydown handler const inputProps = api.getInputProps(); if (inputProps.onKeyDown) { inputProps.onKeyDown(e); } }; // Handle input focus (removed auto-open functionality) const handleInputFocus = (e: React.FocusEvent<HTMLInputElement>) => { // Call the original focus handler if it exists const inputProps = api.getInputProps(); if (inputProps.onFocus) { inputProps.onFocus(e); } }; const inputProps = api.getInputProps(); return ( <div className={cn('relative', className)}> <div {...api.getControlProps()} className="relative"> <Input {...inputProps} onInput={handleInputEvent} onKeyDown={handleKeyDown} onFocus={handleInputFocus} className={cn( 'pr-10', disabled && 'cursor-not-allowed opacity-50', readOnly && 'cursor-default' )} disabled={disabled} readOnly={readOnly} placeholder={placeholder} maxLength={10} // MM/DD/YYYY format /> <Button {...api.getTriggerProps()} variant="ghost" size="icon" disabled={disabled} className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" > <Calendar className="h-4 w-4" /> </Button> </div> {api.open && ( <div {...api.getPositionerProps()} className="absolute z-50 mt-1"> <div {...api.getContentProps()} className="rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95" > {/* Day View */} <div hidden={api.view !== 'day'}> <div {...api.getViewControlProps({ view: 'year' })} className="mb-4 flex items-center justify-between" > <Button {...api.getPrevTriggerProps()} variant="ghost" size="icon"> <ChevronLeft className="h-4 w-4" /> </Button> <Button {...api.getViewTriggerProps()} variant="ghost" className="font-medium text-primary hover:text-primary/80" > {api.visibleRangeText.start} </Button> <Button {...api.getNextTriggerProps()} variant="ghost" size="icon"> <ChevronRight className="h-4 w-4" /> </Button> </div> <table {...api.getTableProps({ view: 'day' })} className="w-full border-collapse" > <thead {...api.getTableHeaderProps({ view: 'day' })}> <tr {...api.getTableRowProps({ view: 'day' })}> {api.weekDays.map(day => ( <th key={`weekday-${day.long}`} scope="col" aria-label={day.long} className="p-2 text-center text-sm font-medium text-muted-foreground" > {day.narrow} </th> ))} </tr> </thead> <tbody {...api.getTableBodyProps({ view: 'day' })}> {api.weeks.map(week => ( <tr key={`week-${week.map(d => `${d.day}-${d.month}-${d.year}`).join('-')}`} {...api.getTableRowProps({ view: 'day' })} > {week.map(value => ( <td key={`day-${value.day}-${value.month}-${value.year}`} {...api.getDayTableCellProps({ value })} className="p-0 text-center" > <Button {...api.getDayTableCellTriggerProps({ value })} variant="ghost" size="icon" className={cn( 'h-9 w-9 p-0 font-normal text-primary', '[&[data-selected]]:bg-primary [&[data-selected]]:text-primary-foreground', '[&[data-today]]:bg-accent [&[data-today]]:text-accent-foreground', '[&[data-outside-range]]:text-muted-foreground [&[data-outside-range]]:opacity-50', '[&[data-disabled]]:text-muted-foreground [&[data-disabled]]:opacity-50', '[&[data-unavailable]]:text-muted-foreground [&[data-unavailable]]:opacity-50' )} > {value.day} </Button> </td> ))} </tr> ))} </tbody> </table> </div> {/* Month View */} <div hidden={api.view !== 'month'}> <div {...api.getViewControlProps({ view: 'month' })} className="mb-4 flex items-center justify-between" > <Button {...api.getPrevTriggerProps({ view: 'month' })} variant="ghost" size="icon" > <ChevronLeft className="h-4 w-4" /> </Button> <Button {...api.getViewTriggerProps({ view: 'month' })} variant="ghost" className="font-medium text-primary hover:text-primary/80" > {api.visibleRange.start.year} </Button> <Button {...api.getNextTriggerProps({ view: 'month' })} variant="ghost" size="icon" > <ChevronRight className="h-4 w-4" /> </Button> </div> <table {...api.getTableProps({ view: 'month', columns: 4 })} className="w-full border-collapse" > <tbody {...api.getTableBodyProps({ view: 'month' })}> {api.getMonthsGrid({ columns: 4, format: 'short' }).map(months => ( <tr key={`month-row-${months.map(m => m.value).join('-')}`} {...api.getTableRowProps()} > {months.map(month => ( <td key={`month-${month.value}`} {...api.getMonthTableCellProps({ ...month, columns: 4, })} className="p-1 text-center" > <Button {...api.getMonthTableCellTriggerProps({ ...month, columns: 4, })} variant="ghost" className={cn( 'h-9 w-16 p-0 font-normal text-primary', '[&[data-selected]]:bg-primary [&[data-selected]]:text-primary-foreground' )} > {month.label} </Button> </td> ))} </tr> ))} </tbody> </table> </div> {/* Year View */} <div hidden={api.view !== 'year'}> <div {...api.getViewControlProps({ view: 'year' })} className="mb-4 flex items-center justify-between" > <Button {...api.getPrevTriggerProps({ view: 'year' })} variant="ghost" size="icon" > <ChevronLeft className="h-4 w-4" /> </Button> <span className="font-medium text-primary"> {api.getDecade().start} -{api.getDecade().end} </span> <Button {...api.getNextTriggerProps({ view: 'year' })} variant="ghost" size="icon" > <ChevronRight className="h-4 w-4" /> </Button> </div> <table {...api.getTableProps({ view: 'year', columns: 4 })} className="w-full border-collapse" > <tbody {...api.getTableBodyProps()}> {api.getYearsGrid({ columns: 4 }).map(years => ( <tr key={`year-row-${years.map(y => y.value).join('-')}`} {...api.getTableRowProps({ view: 'year' })} > {years.map(year => ( <td key={`year-${year.value}`} {...api.getYearTableCellProps({ ...year, columns: 4, })} className="p-1 text-center" > <Button {...api.getYearTableCellTriggerProps({ ...year, columns: 4, })} variant="ghost" className={cn( 'h-9 w-16 p-0 font-normal text-primary', '[&[data-selected]]:bg-primary [&[data-selected]]:text-primary-foreground' )} > {year.label} </Button> </td> ))} </tr> ))} </tbody> </table> </div> </div> </div> )} </div> ); }

React Hook Form Integration

Here's how to use the date picker with React Hook Form and Zod for form validation:

tsx

'use client'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import DatePicker from '@/components/date-picker'; // Zod schema for validation const formSchema = z.object({ birthDate: z.array(z.date()).min(1, 'Birth date is required'), startDate: z.array(z.date()).min(1, 'Start date is required'), endDate: z.array(z.date()).min(1, 'End date is required'), }).refine((data) => { // Cross-field validation: end date must be after start date if (data.startDate.length > 0 && data.endDate.length > 0) { return data.endDate[0] > data.startDate[0]; } return true; }, { message: "End date must be after start date", path: ["endDate"], }); type FormData = z.infer<typeof formSchema>; export default function DatePickerForm() { const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const { setValue, watch, handleSubmit, formState: { errors, isSubmitting }, reset, } = useForm<FormData>({ resolver: zodResolver(formSchema), defaultValues: { birthDate: [], startDate: [], endDate: [], }, }); const watchedValues = watch(); const onSubmit = async (data: FormData) => { try { await new Promise(resolve => setTimeout(resolve, 1000)); alert('Form submitted successfully!'); reset(); } catch (error) { console.error('Submission error:', error); } }; return ( <Card className="max-w-2xl mx-auto"> <CardHeader> <CardTitle>Date Picker Form Example</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> {/* Birth Date Field */} <div className="space-y-2"> <Label>Birth Date *</Label> <DatePicker value={watchedValues.birthDate} onValueChange={(details) => setValue('birthDate', details.value, { shouldValidate: true })} placeholder="MM/DD/YYYY" max={today} className="w-full" /> {errors.birthDate && ( <p className="text-sm text-red-600">{errors.birthDate.message}</p> )} </div> {/* Start Date Field */} <div className="space-y-2"> <Label>Project Start Date *</Label> <DatePicker value={watchedValues.startDate} onValueChange={(details) => setValue('startDate', details.value, { shouldValidate: true })} placeholder="MM/DD/YYYY" min={tomorrow} className="w-full" /> {errors.startDate && ( <p className="text-sm text-red-600">{errors.startDate.message}</p> )} </div> {/* End Date Field */} <div className="space-y-2"> <Label>Project End Date *</Label> <DatePicker value={watchedValues.endDate} onValueChange={(details) => setValue('endDate', details.value, { shouldValidate: true })} placeholder="MM/DD/YYYY" min={watchedValues.startDate.length > 0 ? watchedValues.startDate[0] : tomorrow} className="w-full" /> {errors.endDate && ( <p className="text-sm text-red-600">{errors.endDate.message}</p> )} </div> {/* Form Actions */} <div className="flex gap-4 pt-4"> <Button type="submit" disabled={isSubmitting} className="flex-1" > {isSubmitting ? 'Submitting...' : 'Submit Form'} </Button> <Button type="button" variant="outline" onClick={() => reset()} className="flex-1" > Reset Form </Button> </div> </form> </CardContent> </Card> ); }

Key Features

  • Input Masking: Automatically formats input to MM/DD/YYYY as users type
  • Dual API: Choose between simple single date or array-based APIs
  • Form Integration: Works seamlessly with React Hook Form and Zod
  • Date Constraints: Support for min/max dates and custom validation
  • Accessibility: Built-in keyboard navigation and screen reader support
  • Customizable: Full control over styling using Tailwind CSS

Conclusion

By combining Zag.js's headless approach with custom enhancements, we created a production-ready date picker that's both flexible and easy to use. The component provides the functionality missing from shadcn/ui while maintaining the same design principles and user experience.

The key benefits:

  • No need to compromise on design flexibility
  • Built-in accessibility and state management
  • Simple integration with existing form libraries
  • TypeScript support throughout