Time Range
Start/end time range (HH:mm) integrated with React Hook Form via typed lens binding. Selecting the start sets the end one hour later and enforces start < end.
npx shadcn@latest add https://shuip.plvo.dev/r/rhf-time-range.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-time-range.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-time-range.json
'use client';import type { Lens } from '@hookform/lenses';import { useController } from 'react-hook-form';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';import {addOneHour,HOUR_OPTIONS,isHourDisabled,isMinuteDisabled,joinTime,MINUTE_OPTIONS,nextSlot,splitTime,type TimeRangeValue,} from '@/lib/time';interface TimePickerProps {id?: string;value: string;onChange: (value: string) => void;min?: string;disabled?: boolean;invalid?: boolean;}function TimePicker({ id, value, onChange, min, disabled, invalid }: TimePickerProps) {const { hour, minute } = splitTime(value);const bounds = { min };return (<div className='flex items-center gap-2'><Select value={hour} onValueChange={(h) => onChange(joinTime(h, minute))} disabled={disabled}><SelectTrigger id={id} aria-invalid={invalid} className='w-full'><SelectValue placeholder='HH' /></SelectTrigger><SelectContent>{HOUR_OPTIONS.map((h) => (<SelectItem key={h} value={h} disabled={isHourDisabled(h, bounds)}>{h}</SelectItem>))}</SelectContent></Select><span className='text-muted-foreground'>:</span><Select value={minute} onValueChange={(m) => onChange(joinTime(hour, m))} disabled={disabled}><SelectTrigger aria-invalid={invalid} className='w-full'><SelectValue placeholder='mm' /></SelectTrigger><SelectContent>{MINUTE_OPTIONS.map((m) => (<SelectItem key={m} value={m} disabled={isMinuteDisabled(m, hour, bounds)}>{m}</SelectItem>))}</SelectContent></Select></div>);}export interface TimeRangeFieldProps {lens: Lens<TimeRangeValue>;label?: string;description?: string;startLabel?: string;endLabel?: string;disabled?: boolean;}export function TimeRangeField({lens,label,description,startLabel = 'Start',endLabel = 'End',disabled,}: TimeRangeFieldProps) {const { field, fieldState } = useController(lens.interop());const value: TimeRangeValue = field.value ?? { start: '', end: '' };return (<Field className='gap-2' data-invalid={fieldState.invalid}>{label && <FieldLabel>{label}</FieldLabel>}<div className='flex flex-col gap-3 sm:flex-row sm:items-end'><div className='flex flex-1 flex-col gap-1'><FieldLabel htmlFor={`${field.name}-start`} className='text-xs'>{startLabel}</FieldLabel><TimePickerid={`${field.name}-start`}value={value.start}onChange={(start) => field.onChange({ start, end: addOneHour(start) })}disabled={disabled}invalid={fieldState.invalid}/></div><div className='flex flex-1 flex-col gap-1'><FieldLabel htmlFor={`${field.name}-end`} className='text-xs'>{endLabel}</FieldLabel><TimePickerid={`${field.name}-end`}value={value.end}onChange={(end) => field.onChange({ ...value, end })}min={value.start ? nextSlot(value.start) : undefined}disabled={disabled}invalid={fieldState.invalid}/></div></div>{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
Loading...
TimeRangeField is two coupled time pickers — a start and an end — bound to a single { start, end } object via a typed lens from @hookform/lenses. Both values are HH:mm strings.
Built-in features
- Coupled pickers: choosing a start time sets the end to one hour later automatically
start < endenforced: end options at or before the start are disabled in the dropdowns- Single object binding:
lens.focus('slot')binds the whole{ start, end }value - Zod validation: refine the object to require a valid ordered range
Setup
import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { Form } from '@/components/ui/form';
import { TimeRangeField } from '@/components/ui/shuip/react-hook-form/time-range';
type MyForm = { slot: { start: string; end: string } };
const form = useForm<MyForm>({ defaultValues: { slot: { start: '', end: '' } } });
const lens = useLens({ control: form.control });
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<TimeRangeField lens={lens.focus('slot')} label='Meeting slot' />
</form>
</Form>The value is { start: string; end: string }, each a HH:mm string (empty when unset). Changing the start always resets the end to one hour after the new start.
Examples
Default
Loading...
'use client';import { useLens } from '@hookform/lenses';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { z } from 'zod';import { Form } from '@/components/ui/form';import { TimeRangeField } from '@/components/ui/shuip/react-hook-form/time-range';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({slot: z.object({start: z.string(),end: z.string(),}),}).refine((values) => Boolean(values.slot.start && values.slot.end && values.slot.start < values.slot.end), {message: 'Select a start and end time (end after start)',path: ['slot'],});type Values = z.infer<typeof zodSchema>;export default function RhfTimeRangeExample() {const form = useForm<Values>({defaultValues: { slot: { start: '', end: '' } },resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {try {alert(`From ${values.slot.start} to ${values.slot.end}`);} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><TimeRangeField lens={lens.focus('slot')} label='Meeting slot' description='Pick a start and end time' /><SubmitButton>Save</SubmitButton></form></Form>);}
Props
Prop
Type