Time Range Field
Dates & Timeimport { TimeRangeField } from "bits-ui" Bound working hours
Start and end Time values move together while a derived duration stays in sync
<script lang="ts">
import { TimeRangeField, type TimeRange } from 'bits-ui';
import { Time } from '@internationalized/date';
let value = $state<TimeRange<Time>>({
start: new Time(9, 0),
end: new Time(17, 30)
});
const minutes = $derived(
value.start && value.end ? Math.max(0, value.end.compare(value.start) / 60_000) : 0
);
const duration = $derived(`${Math.floor(minutes / 60)}h ${minutes % 60}m`);
function shift(hours: number) {
if (!value.start || !value.end) return;
value = {
start: value.start.add({ hours }),
end: value.end.add({ hours })
};
}
</script>
<div class="mx-auto grid w-full max-w-lg gap-4">
<TimeRangeField.Root
bind:value
granularity="minute"
hourCycle={24}
locale="en-GB"
class="group grid gap-2"
>
<TimeRangeField.Label class="text-sm font-medium text-ink-200">Support coverage</TimeRangeField.Label>
<div
class="input flex min-h-11 items-center gap-1 group-data-invalid:border-accent-500 focus-within:border-brand-600"
>
{#each ['start', 'end'] as const as type (type)}
<TimeRangeField.Input {type} name={`coverage-${type}`} class="flex min-w-0 items-center">
{#snippet children({ segments })}
{#each segments as { part, value: segmentValue }, i (part + i)}
{#if part === 'literal'}
<TimeRangeField.Segment {part} class="px-0.5 text-ink-600">
{segmentValue}
</TimeRangeField.Segment>
{:else}
<TimeRangeField.Segment
{part}
class="rounded-md px-1.5 py-1 font-mono text-sm tabular-nums outline-none hover:bg-ink-850 focus:bg-brand-500 focus:text-ink-950 data-invalid:text-accent-500 aria-[valuetext=Empty]:text-ink-600"
>
{segmentValue}
</TimeRangeField.Segment>
{/if}
{/each}
{/snippet}
</TimeRangeField.Input>
{#if type === 'start'}<span aria-hidden="true" class="px-1 text-xs text-ink-600">to</span>{/if}
{/each}
</div>
</TimeRangeField.Root>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-sm" onclick={() => shift(-1)}>Earlier</button>
<button type="button" class="btn btn-sm" onclick={() => shift(1)}>Later</button>
<span class="chip ml-auto">{duration} coverage</span>
</div>
</div>
Cross-field validation
The complete range is rejected when its end is earlier than its start
End time must not be earlier than start time.
<script lang="ts">
import { TimeRangeField, type TimeRange } from 'bits-ui';
import { Time } from '@internationalized/date';
const errorMessage = 'End time must not be earlier than start time.';
let value = $state<TimeRange<Time>>({
start: new Time(16, 0),
end: new Time(15, 30)
});
const invalid = $derived(
Boolean(value.start && value.end && value.end.compare(value.start) < 0)
);
function validateRange(range: { start: Time; end: Time }) {
return range.end.compare(range.start) < 0 ? errorMessage : undefined;
}
function setValid() {
value = { start: new Time(16, 0), end: new Time(17, 30) };
}
function setInvalid() {
value = { start: new Time(16, 0), end: new Time(15, 30) };
}
</script>
<div class="mx-auto grid w-full max-w-lg gap-4">
<TimeRangeField.Root
bind:value
validate={validateRange}
errorMessageId="time-range-order-error"
granularity="minute"
hourCycle={12}
locale="en-US"
class="group grid gap-2"
>
<TimeRangeField.Label class="text-sm font-medium text-ink-200">Review session</TimeRangeField.Label>
<div
class="input flex min-h-11 items-center gap-1 group-data-invalid:border-accent-500 focus-within:border-brand-600"
>
{#each ['start', 'end'] as const as type (type)}
<TimeRangeField.Input {type} class="flex min-w-0 items-center">
{#snippet children({ segments })}
{#each segments as { part, value: segmentValue }, i (part + i)}
{#if part === 'literal'}
<TimeRangeField.Segment {part} class="px-0.5 text-ink-600">
{segmentValue}
</TimeRangeField.Segment>
{:else}
<TimeRangeField.Segment
{part}
class="rounded-md px-1.5 py-1 font-mono text-sm tabular-nums outline-none hover:bg-ink-850 focus:bg-brand-500 focus:text-ink-950 data-invalid:text-accent-500 aria-[valuetext=Empty]:text-ink-600"
>
{segmentValue}
</TimeRangeField.Segment>
{/if}
{/each}
{/snippet}
</TimeRangeField.Input>
{#if type === 'start'}<span aria-hidden="true" class="px-1 text-xs text-ink-600">to</span>{/if}
{/each}
</div>
</TimeRangeField.Root>
<p
id="time-range-order-error"
aria-live="polite"
class={invalid ? 'text-xs font-medium text-accent-500' : 'text-xs text-ink-400'}
>
{invalid ? errorMessage : 'Range is in chronological order.'}
</p>
<div class="flex flex-wrap gap-2">
<button type="button" class="btn btn-sm" onclick={setInvalid}>Set invalid range</button>
<button type="button" class="btn btn-primary btn-sm" onclick={setValid}>Fix end time</button>
</div>
</div>
The bound value is { start, end }, where either side can be undefined during partial entry. Each TimeRangeField.Input therefore needs a required type so Bits UI can route its segments to the correct side; an Input name also produces that side's hidden form input.
Custom validation receives the completed range and may return a string or string array; errorMessageId associates explanatory text with the invalid field. Granularity defaults to minute, locale supplies segment order and clock preference, and hourCycle can force 12- or 24-hour output for both sides.