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 @shuip/rhf-time-field
pnpm dlx shadcn@latest add @shuip/rhf-time-field
bun x shadcn@latest add @shuip/rhf-time-field
'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 function timePartHandlers(time: string, onChange: (time: string) => void) {
  const { hour, minute } = splitTime(time);
  return {
    onHourChange: (next: string | null) => onChange(next === null ? '' : joinTime(next, minute)),
    onMinuteChange: (next: string | null) => onChange(next === null ? '' : joinTime(hour, next)),
  };
}

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 time = field.value ?? '';
  const { hour, minute } = splitTime(time);
  const { onHourChange, onMinuteChange } = timePartHandlers(time, field.onChange);
  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={onHourChange} 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={onMinuteChange} 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>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

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 Select inputs — 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 (as HH:mm strings) 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 { TimeField } from '@/components/ui/shuip/react-hook-form/time-field';

const form = useForm<MyForm>({ defaultValues: { meetingTime: '' } });
const lens = useLens({ control: form.control });

<form onSubmit={form.handleSubmit(onSubmit)}>
  <TimeField lens={lens.focus('meetingTime')} label='Meeting time' />
</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

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 { 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 onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <TimeField lens={lens.focus('meetingTime')} label='Meeting time' description='Pick a time' />
      <SubmitButton>Save</SubmitButton>
    </form>
  );
}

Validation

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 { 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 onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
      <TimeField
        lens={lens.focus('appointment')}
        label='Appointment'
        description={`Office hours: ${BUSINESS_OPEN} – ${BUSINESS_CLOSE}`}
        min={BUSINESS_OPEN}
        max={BUSINESS_CLOSE}
      />
      <SubmitButton>Book</SubmitButton>
    </form>
  );
}

Props

Prop

Type

On this page