caliper uidocs

Duration field

Free

A 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.

Installs components/ui/duration-field.tsxDepends on lucide-reactMotion shared motion tokens
pnpm dlx shadcn@latest add https://caliperui.site/r/duration-field.json

Type 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 #

terminal
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.

example.tsx
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
PropTypeDefaultDescription
value *number | null—Minutes, or null when empty.
onValueChange *(minutes: number | null) => void—
stepnumber15Minutes added or taken by the buttons and arrow keys. Shift or Page keys move an hour.
minnumber0
maxnumber24 * 60
presetsreadonly number[][15, 30, 60, 120]Common lengths offered under the field, in minutes; [] hides them.
placeholderstring"e.g. 1h 30m"
disabledboolean—
idstring—
aria-describedbystring—
aria-invalidtrue—
aria-labelstring—
classNamestring—

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.