Time Field

Time picker (HH:mm) integrated with TanStack Form via field context. Two shadcn Select inputs for hours and minutes.

npx shadcn@latest add @shuip/tsf-time-field
pnpm dlx shadcn@latest add @shuip/tsf-time-field
bun x shadcn@latest add @shuip/tsf-time-field
'use client';

import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';
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 {
  label?: string;
  description?: string;
  min?: string;
  max?: string;
  disabled?: boolean;
  fieldProps?: React.ComponentProps<typeof Field>;
}

export function TimeField({ label, description, min, max, disabled, fieldProps }: TimeFieldProps) {
  const field = useFieldContext<string>();
  const { isValid, errors } = field.state.meta;
  const time = field.state.value ?? '';
  const { hour, minute } = splitTime(time);
  const { onHourChange, onMinuteChange } = timePartHandlers(time, field.handleChange);
  const bounds = { min, max };

  return (
    <Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
      {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={!isValid} 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={!isValid} 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>
      {!isValid && (
        <FieldError
          className='text-xs text-left'
          errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}
        />
      )}
      {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 and reads its state from TanStack Form's field context.

Built-in features

  • Two-select picker: hours and minutes as shadcn Select inputs — consistent styling, full keyboard support
  • Field-context binding: reads value and validation state via useFieldContext
  • Range constraints: pass min / max (as HH:mm strings) to disable out-of-range options
  • Validation: surfaces TanStack Form validator errors below the picker

Setup

Register the field component on your form hook, then render it inside form.AppField:

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { TimeField } from '@/components/ui/shuip/tanstack-form/time-field';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { TimeField },
  formComponents: {},
});

<form.AppField
  name='meetingTime'
  children={(field) => <field.TimeField label='Meeting time' />}
/>

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:

<field.TimeField label='Appointment' min='09:00' max='18:00' />

Examples

Default

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
import { TimeField } from '@/components/ui/shuip/tanstack-form/time-field';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { TimeField },
  formComponents: { SubmitButton },
});

export default function TsfTimeFieldExample() {
  const form = useAppForm({
    defaultValues: {
      meetingTime: '',
    },
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='meetingTime'
        validators={{
          onChange: ({ value }) => (!value ? 'Time is required' : undefined),
        }}
        children={(field) => <field.TimeField label='Meeting time' description='Pick a time' />}
      />

      <form.AppForm>
        <form.SubmitButton>Save</form.SubmitButton>
      </form.AppForm>
    </form>
  );
}

Validation

Loading...
'use client';

import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';
import { TimeField } from '@/components/ui/shuip/tanstack-form/time-field';

const BUSINESS_OPEN = '09:00';
const BUSINESS_CLOSE = '18:00';

const { useAppForm } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: { TimeField },
  formComponents: { SubmitButton },
});

export default function TsfTimeFieldValidationExample() {
  const form = useAppForm({
    defaultValues: {
      appointment: '',
    },
    onSubmit: async ({ value }) => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      alert(JSON.stringify(value, null, 2));
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
      className='space-y-4'
    >
      <form.AppField
        name='appointment'
        validators={{
          onChange: ({ value }) => {
            if (!value) return 'Time is required';
            if (value < BUSINESS_OPEN || value > BUSINESS_CLOSE) {
              return `Appointment must be between ${BUSINESS_OPEN} and ${BUSINESS_CLOSE}`;
            }
            return undefined;
          },
        }}
        children={(field) => (
          <field.TimeField
            label='Appointment'
            description={`Office hours: ${BUSINESS_OPEN} – ${BUSINESS_CLOSE}`}
            min={BUSINESS_OPEN}
            max={BUSINESS_CLOSE}
          />
        )}
      />

      <form.AppForm>
        <form.SubmitButton>Book</form.SubmitButton>
      </form.AppForm>
    </form>
  );
}

Props

Prop

Type

On this page