Duration field
FreeA length of time, not a time of day: type it the way you would say it (90, 1h 30m, 1:30), step it with the buttons or arrow keys, or pick a common length.
pnpm dlx shadcn@latest add https://caliperui.site/r/duration-field.jsonType 45m, 1h 30m or 90; arrow keys step 15 minutes, Shift steps an hour.
Logged 2 h 15 min, 45 min over the estimate.
When to use #
Use it for
- Estimates, timers, meeting lengths, time logged against a task.
- Anywhere people think in hours and minutes rather than a clock.
Skip it when
- A time of day; use the time picker.
- Spans between two dates; use the date range picker.
Decisions #
Why it is built this way, so you know what to keep when you change your copy.
Behaviour
- A bare number is minutes
- “90” is how people type a length; reading it as hours would turn a meeting into a quarter of a week. “1.5h” and “1:30” say hours explicitly.
- Say it cannot read it instead of guessing
- Unreadable text stays in the field with a message and an example, so a typo never becomes a wrong value silently.
- Steps land on the grid
- With a 15 minute step, 50 minutes plus one step is 60, not 65, so stepping always reaches round numbers.
Usage
- Store minutes
- The value is a number of minutes or null; format it for display with
formatDuration.
Installation #
pnpm dlx shadcn@latest add https://caliperui.site/r/duration-field.json
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
Usage #
This is the code behind the preview above. It is built from DurationField, formatDuration and parseDuration.
import { useState } from "react" import { DurationField, formatDuration } from "@/components/ui/duration-field" import { Field } from "@/components/ui/field" export function DurationFieldDemo() { const [estimate, setEstimate] = useState<number | null>(90); const logged = 135; return ( <div className="grid w-full max-w-xs gap-3"> <Field label="Estimate" description="Type 45m, 1h 30m or 90; arrow keys step 15 minutes, Shift steps an hour."> {(control) => <DurationField {...control} value={estimate} onValueChange={setEstimate} />} </Field> <p className="text-xs text-muted-foreground"> {estimate === null ? "No estimate yet." : logged === estimate ? `Logged ${formatDuration(logged)}, exactly the estimate.` : logged > estimate ? `Logged ${formatDuration(logged)}, ${formatDuration(logged - estimate)} over the estimate.` : `Logged ${formatDuration(logged)}, ${formatDuration(estimate - logged)} left.`} </p> </div> ); }
API reference #
DurationField
component
formatDuration
component
No props of its own; it accepts the native props of its root element.
parseDuration
component
No props of its own; it accepts the native props of its root element.
Accessibility #
It exposes spinbutton, alert and group semantics, with aria-label, aria-hidden, aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext kept in step with state. Changes that matter are announced through a live region.
No custom key handling; Tab and the native activation keys do the work.
Motion #
Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.