Time Field
Time picker (HH:mm) integrated with React Hook Form via typed lens binding from @hookform/lenses. Two shadcn Select inputs for hours and minutes.
npx shadcn@latest add https://shuip.plvo.dev/r/rhf-time-field.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/rhf-time-field.json
bun x shadcn@latest add https://shuip.plvo.dev/r/rhf-time-field.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 { HOUR_OPTIONS, isHourDisabled, isMinuteDisabled, joinTime, MINUTE_OPTIONS, splitTime } from '@/lib/time';export interface TimeFieldProps {lens: Lens<string>;label?: string;description?: string;min?: string;max?: string;disabled?: boolean;}export function TimeField({ lens, label, description, min, max, disabled }: TimeFieldProps) {const { field, fieldState } = useController(lens.interop());const { hour, minute } = splitTime(field.value ?? '');const bounds = { min, max };return (<Field className='gap-2' data-invalid={fieldState.invalid}>{label && <FieldLabel htmlFor={field.name}>{label}</FieldLabel>}<div className='flex items-center gap-2'><Select value={hour} onValueChange={(h) => field.onChange(joinTime(h, minute))} disabled={disabled}><SelectTrigger id={field.name} aria-invalid={fieldState.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) => field.onChange(joinTime(hour, m))} disabled={disabled}><SelectTrigger aria-invalid={fieldState.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>{fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
TimeField is a time-only picker built from two shadcn/ui Select inputs — hours 00–23 and minutes in 5-minute steps. It binds to a string in the HH:mm 24-hour format, encapsulating React Hook Form's field management.
The field binds to the form via a typed lens from @hookform/lenses — no call-site generic, just lens.focus('fieldName') with full autocomplete from your form's value type.
Built-in features
- Two-select picker: hours and minutes as shadcn
Selectinputs — consistent styling, full keyboard support, no native-input inconsistencies across browsers - Typed lens binding:
lens.focus('meetingTime')autocompletes from your form's value type - Range constraints: pass
min/max(asHH:mmstrings) to disable out-of-range hour and minute options - Zod validation: native integration with react-hook-form and Zod via resolver
Setup
Field components bind via @hookform/lenses. Create a lens once per form:
import { useLens } from '@hookform/lenses';
import { useForm } from 'react-hook-form';
import { Form } from '@/components/ui/form';
import { TimeField } from '@/components/ui/shuip/react-hook-form/time-field';
const form = useForm<MyForm>({ defaultValues: { meetingTime: '' } });
const lens = useLens({ control: form.control });
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<TimeField lens={lens.focus('meetingTime')} label='Meeting time' />
</form>
</Form>The value is a string in HH:mm format (or empty string when unset). Picking only the hour or only the minute defaults the other part to 00.
Constraining the range
min and max accept HH:mm strings. Out-of-range hour and minute options are disabled in the dropdowns. Always validate via Zod (and server-side) for safety:
const schema = z.object({
appointment: z
.string()
.min(1, 'Required')
.refine((v) => v >= '09:00' && v <= '18:00', 'Outside office hours'),
});
<TimeField lens={lens.focus('appointment')} label='Appointment' min='09:00' max='18:00' />Examples
Default
'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 { TimeField } from '@/components/ui/shuip/react-hook-form/time-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const zodSchema = z.object({meetingTime: z.string().min(1, 'Time is required'),});type Values = z.infer<typeof zodSchema>;export default function RhfTimeFieldExample() {const form = useForm<Values>({defaultValues: { meetingTime: '' },resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {try {alert(`Meeting at ${values.meetingTime}`);} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><TimeField lens={lens.focus('meetingTime')} label='Meeting time' description='Pick a time' /><SubmitButton>Save</SubmitButton></form></Form>);}
Validation
'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 { TimeField } from '@/components/ui/shuip/react-hook-form/time-field';import { SubmitButton } from '@/components/ui/shuip/submit-button';const BUSINESS_OPEN = '09:00';const BUSINESS_CLOSE = '18:00';const zodSchema = z.object({appointment: z.string().min(1, 'Time is required').refine((value) => value >= BUSINESS_OPEN && value <= BUSINESS_CLOSE, {message: `Appointment must be between ${BUSINESS_OPEN} and ${BUSINESS_CLOSE}`,}),});type Values = z.infer<typeof zodSchema>;export default function RhfTimeFieldValidationExample() {const form = useForm<Values>({defaultValues: { appointment: '' },resolver: zodResolver(zodSchema),});const lens = useLens({ control: form.control });async function onSubmit(values: Values) {try {alert(`Appointment at ${values.appointment}`);} catch (error) {console.error(error);}}return (<Form {...form}><form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'><TimeFieldlens={lens.focus('appointment')}label='Appointment'description={`Office hours: ${BUSINESS_OPEN} – ${BUSINESS_CLOSE}`}min={BUSINESS_OPEN}max={BUSINESS_CLOSE}/><SubmitButton>Book</SubmitButton></form></Form>);}
Props
Prop
Type