Time Range

Start/end time range (HH:mm) integrated with TanStack Form via field context. Selecting the start sets the end one hour later and enforces start < end.

npx shadcn@latest add @shuip/tsf-time-range
pnpm dlx shadcn@latest add @shuip/tsf-time-range
bun x shadcn@latest add @shuip/tsf-time-range
'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 {
  addOneHour,
  HOUR_OPTIONS,
  isHourDisabled,
  isMinuteDisabled,
  joinTime,
  MINUTE_OPTIONS,
  nextSlot,
  splitTime,
  type TimeRangeValue,
} 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)),
  };
}

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 { onHourChange, onMinuteChange } = timePartHandlers(value, onChange);
  const bounds = { min };

  return (
    <div className='flex items-center gap-2'>
      <Select value={hour} onValueChange={onHourChange} 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={onMinuteChange} 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 {
  label?: string;
  description?: string;
  startLabel?: string;
  endLabel?: string;
  disabled?: boolean;
  fieldProps?: React.ComponentProps<typeof Field>;
}

export function TimeRangeField({
  label,
  description,
  startLabel = 'Start',
  endLabel = 'End',
  disabled,
  fieldProps,
}: TimeRangeFieldProps) {
  const field = useFieldContext<TimeRangeValue>();
  const { isValid, errors } = field.state.meta;
  const value: TimeRangeValue = field.state.value ?? { start: '', end: '' };

  return (
    <Field className='gap-2' data-invalid={!isValid} {...fieldProps}>
      {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>
          <TimePicker
            id={`${field.name}-start`}
            value={value.start}
            onChange={(start) => field.handleChange({ start, end: addOneHour(start) })}
            disabled={disabled}
            invalid={!isValid}
          />
        </div>
        <div className='flex flex-1 flex-col gap-1'>
          <FieldLabel htmlFor={`${field.name}-end`} className='text-xs'>
            {endLabel}
          </FieldLabel>
          <TimePicker
            id={`${field.name}-end`}
            value={value.end}
            onChange={(end) => field.handleChange({ ...value, end })}
            min={value.start ? nextSlot(value.start) : undefined}
            disabled={disabled}
            invalid={!isValid}
          />
        </div>
      </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...

TimeRangeField is two coupled time pickers — a start and an end — reading a single { start, end } object from TanStack Form's field context. Both values are HH:mm strings.

Built-in features

  • Coupled pickers: choosing a start time sets the end to one hour later automatically
  • start < end enforced: end options at or before the start are disabled in the dropdowns
  • Single object binding: bind the field to a { start, end } value
  • Validation: surfaces TanStack Form validator errors below the pickers

Setup

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

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

// defaultValues: { slot: { start: '', end: '' } }
<form.AppField
  name='slot'
  children={(field) => <field.TimeRangeField label='Meeting slot' />}
/>

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 { 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 { TimeRangeField } from '@/components/ui/shuip/tanstack-form/time-range';

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

export default function TsfTimeRangeExample() {
  const form = useAppForm({
    defaultValues: {
      slot: { start: '', end: '' },
    },
    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='slot'
        validators={{
          onChange: ({ value }) =>
            value.start && value.end && value.start < value.end
              ? undefined
              : 'Select a start and end time (end after start)',
        }}
        children={(field) => <field.TimeRangeField label='Meeting slot' description='Pick a start and end time' />}
      />

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

Props

Prop

Type

On this page