Skip to content

Duration picker

A number and unit pair, with presets, for timeouts, delays and windows.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { DurationPicker } from "@oration/canon/components/duration-picker";
packages/canon/src/components/duration-picker.tsx
End call after
Total silence before the agent says goodbye and hangs up.

Hangs up after 1 minute of silence

Remind the approver after
If a payment run is still waiting, Priya Raman gets an email.

Reminds after 1 day

import { DurationPicker } from "@oration/canon/components/duration-picker";import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import * as React from "react";export function Hero() {    const [silence, setSilence] = React.useState(60_000);    const [reminder, setReminder] = React.useState(86_400_000);    return (        <SettingsGroup className="w-full max-w-2xl text-left">            <SettingsRow                label="End call after"                description="Total silence before the agent says goodbye and hangs up."                align="start"            >                <DurationPicker                    aria-label="End call after"                    value={silence}                    onChange={setSilence}                    units={["s", "min"]}                    presets={[30_000, 60_000, 120_000]}                    min={5_000}                    readout={(human) => `Hangs up after ${human} of silence`}                />            </SettingsRow>            <SettingsRow                label="Remind the approver after"                description="If a payment run is still waiting, Priya Raman gets an email."                align="start"            >                <DurationPicker                    aria-label="Remind the approver after"                    value={reminder}                    onChange={setReminder}                    units={["h", "d"]}                    presets={[14_400_000, 86_400_000, 259_200_000]}                    min={3_600_000}                    max={604_800_000}                    readout={(human) => `Reminds after ${human}`}                />            </SettingsRow>        </SettingsGroup>    );}

Usage#

Duration picker edits one length of time, stored in milliseconds, as a number beside a unit select, with optional preset chips and a plain-language readout such as Hangs up after 1 minute of silence. Oration uses it for silence timeouts, retention windows, shift and break lengths and retry delays. Switching the unit converts the number, so the stored value never changes by accident. The mistake people make is offering every unit: pass only the units that fit the setting, ["s", "min"] for a silence timeout or ["d"] for retention, and always store and pass milliseconds.

When to use

  • For timeouts and delays: End call after, Retry after, Remind the approver after.
  • For retention and expiry windows in days: Delete recordings after.
  • For shift, break and schedule lengths in minutes or hours.
  • With presets when a few values cover most cases, such as 30 seconds, 1 minute and 2 minutes.

When not to use

  • For a point in time rather than a length, such as a payment run date. Use Calendar
  • For opening hours across the week. Use Business hours picker
  • For a relative scale with no exact value, such as speaking speed. Use Slider field
  • For a plain count that isn't time, such as maximum reminders. Use Input

The Tabular Figures Rule

The number is right-aligned in tabular figures, and preset chips and the readout are tabular too, so values line up down a settings page.

Say it in words

Keep the readout on unless space is tight. Deleted after 90 days is what people check; the number and unit are how they set it.

Anatomy#

1 minute

  1. Number. A 96px Input, right-aligned in tabular figures, with a decimal keypad on phones.
  2. Unit. A 128px Select of the allowed units, spelled out: seconds, minutes, days.
  3. Readout. 13px Slate Meta text from formatDuration, wrapped by readout, in a polite live region.
  4. Presets. 28px chips labelled with formatDurationShort. The selected chip is a 6% indigo tint with a 22% inset indigo ring.

Examples#

Units and readouts

Pass only the units that fit: days for retention, milliseconds and seconds for a retry. readout receives the spelled-out value and the milliseconds.

Delete call recordings after

Deleted after 90 days

Retry the ERP after

Waits 1 second 500 milliseconds (1,500 ms) before retrying

import { DurationPicker } from "@oration/canon/components/duration-picker";import * as React from "react";export function Units() {    const [retention, setRetention] = React.useState(90 * 86_400_000);    const [retry, setRetry] = React.useState(1_500);    return (        <div className="flex w-full max-w-lg flex-col gap-8">            <div className="flex flex-col gap-2">                <span className="text-sm font-medium text-foreground">                    Delete call recordings after                </span>                <DurationPicker                    aria-label="Delete call recordings after"                    value={retention}                    onChange={setRetention}                    units={["d"]}                    presets={[                        30 * 86_400_000,                        90 * 86_400_000,                        365 * 86_400_000,                    ]}                    min={86_400_000}                    readout={(human) => `Deleted after ${human}`}                />            </div>            <div className="flex flex-col gap-2">                <span className="text-sm font-medium text-foreground">                    Retry the ERP after                </span>                <DurationPicker                    aria-label="Retry the ERP after"                    value={retry}                    onChange={setRetry}                    units={["ms", "s"]}                    max={10_000}                    readout={(human, ms) =>                        `Waits ${human} (${ms.toLocaleString("en-US")} ms) before retrying`                    }                />            </div>        </div>    );}

Minimum and maximum

min and max are in milliseconds. Out-of-range typing shows a built-in message on blur and leaves the stored value alone; the 45-day preset is out of range, so it is disabled.

Early-pay window

Between 1 day and 30 days. Type 45 or 0 and leave the field to see the errors.

Discount applies for 3 days

import { DurationPicker } from "@oration/canon/components/duration-picker";import * as React from "react";export function Bounds() {    const [grace, setGrace] = React.useState(3 * 86_400_000);    return (        <div className="flex w-full max-w-md flex-col gap-2">            <span className="text-sm font-medium text-foreground">                Early-pay window            </span>            <p className="text-13 text-muted-foreground">                Between 1 day and 30 days. Type 45 or 0 and leave the field to                see the errors.            </p>            <DurationPicker                aria-label="Early-pay window"                value={grace}                onChange={setGrace}                units={["d"]}                min={86_400_000}                max={30 * 86_400_000}                presets={[3 * 86_400_000, 10 * 86_400_000, 45 * 86_400_000]}                readout={(human) => `Discount applies for ${human}`}            />        </div>    );}

Without a readout

In a dense list where the unit is fixed and the row label already says what it is, readout={false} keeps just the number and unit.

  • Morning break
  • Lunch
import { DurationPicker } from "@oration/canon/components/duration-picker";import * as React from "react";export function Compact() {    const [breaks, setBreaks] = React.useState([        { name: "Morning break", ms: 900_000 },        { name: "Lunch", ms: 1_800_000 },    ]);    return (        <ul className="flex w-full max-w-md flex-col divide-y divide-border rounded-xl bg-card shadow-border">            {breaks.map((row, index) => (                <li                    key={row.name}                    className="flex items-center justify-between gap-4 px-4 py-2.5"                >                    <span className="text-13 text-foreground">{row.name}</span>                    <DurationPicker                        aria-label={`${row.name} length`}                        value={row.ms}                        onChange={(ms) =>                            setBreaks((current) =>                                current.map((b, i) =>                                    i === index ? { ...b, ms } : b,                                ),                            )                        }                        units={["min"]}                        min={300_000}                        readout={false}                    />                </li>            ))}        </ul>    );}

formatDuration and formatDurationShort

Use the same formatters outside the picker, in timelines, tags and summaries, so durations read the same everywhere.

MillisecondsformatDurationformatDurationShort
45,00045 seconds45 s
90,0001 minute 30 seconds90 s
1,800,00030 minutes30 min
5,400,0001 hour 30 minutes90 min
86,400,0001 day1 day
93,600,0001 day 2 hours26 h
7,776,000,00090 days90 days
import { formatDuration, formatDurationShort } from "@oration/canon/components/duration-picker";export function Formatters() {    const samples = [        45_000, 90_000, 1_800_000, 5_400_000, 86_400_000, 93_600_000,        7_776_000_000,    ];    return (        <table className="w-full max-w-lg text-13">            <thead>                <tr className="border-b border-border text-left text-muted-foreground">                    <th className="py-2 pr-4 font-medium">Milliseconds</th>                    <th className="py-2 pr-4 font-medium">formatDuration</th>                    <th className="py-2 font-medium">formatDurationShort</th>                </tr>            </thead>            <tbody className="divide-y divide-border">                {samples.map((ms) => (                    <tr key={ms}>                        <td className="py-2 pr-4 font-mono text-xs text-muted-foreground">                            {ms.toLocaleString("en-US")}                        </td>                        <td className="py-2 pr-4 text-foreground tabular-nums">                            {formatDuration(ms)}                        </td>                        <td className="py-2 text-foreground tabular-nums">                            {formatDurationShort(ms)}                        </td>                    </tr>                ))}            </tbody>        </table>    );}

States#

States
StateTreatment
RestThe value in its best unit, the readout beside it.
Focus visibleThe input or select takes its indigo border and 3px ring.
InvalidOut of range or not a number: red border and ring on the input and a message under the row. The stored value doesn't change.
Preset selectedThe chip matching the value is pressed: indigo tint and inset ring.
Preset hoverUnselected chips move from Well Gray at 70% to full Well Gray and Graphite Ink.
Preset out of rangeChips outside min and max dim to 50% and can't be pressed.
DisabledInput, select and chips are all disabled.

Behavior#

  • value is milliseconds and onChange receives milliseconds. The initial unit is the largest allowed unit that shows the value as a whole number: 90,000 ms shows as 90 seconds, 120,000 ms as 2 minutes.
  • If no allowed unit fits evenly, the smallest one is used with a decimal, such as 1.5 with ["min"].
  • Valid typing commits on every keystroke. Problems are reported on blur, then update live while the error shows.
  • Changing the unit converts the number: 90 seconds becomes 1.5 minutes. The stored value stays the same.
  • On blur the number is tidied, so 05 becomes 5.
  • Pressing a preset sets the value, switches to that preset's best unit and clears any error. Presses scale to 0.96 when motion is allowed.
  • A new value from outside resyncs the number and unit.

Do and don't#

Do. Offer only the units that make sense for the setting, and presets for the common values.
Don't. Offer milliseconds to days for a silence timeout, so people can type 0.02 minutes.

Hangs up after 1 minute 30 seconds of silence

Do. Wrap the readout in the setting's words: Hangs up after 1 minute of silence.
Don't. Hide the readout and leave people to convert 90 seconds in their heads.

Content#

  • The readout is a fragment that states the effect: Deleted after 90 days, Hangs up after 1 minute of silence.
  • Errors come built in and give the fix: Enter a number, like 30., Enter at least 5 seconds., Enter 10 minutes or less.
  • Pass an aria-label that matches the row's label, such as End call after.
  • Keep presets to three or four, in ascending order.

Accessibility#

  • The number and unit sit in a role="group" named by aria-label. Without an id, the input takes the same name; with an id, label it through <label htmlFor>.
  • The readout is aria-live="polite" and linked to the input, so the plain-language value is read on change and on focus.
  • Errors are linked to the input with aria-describedby and mark it aria-invalid.
  • Presets are toggle buttons with aria-pressed in a group named Presets.
  • Out-of-range presets are disabled rather than hidden, so the set stays stable.
Keyboard interactions
KeysAction
TabMoves from the number to the unit select, then through the presets.
SpaceOpens the unit select, or presses a preset.
↑↓Moves through units in the open select.
EnterChooses a unit, or presses a preset.

Design tokens#

Design tokens
TokenUsed for
--inputInput and select strokes
--ringFocus rings
--destructiveInvalid ring and error text
--mutedPreset chips at 70%, full on hover
--primarySelected preset: 6% tint and 22% inset ring
--muted-foregroundReadout and unselected preset text
--radius-lg10px corners on every part

API reference#

DurationPicker

A number and unit that hold one value in milliseconds.

Props of DurationPicker
PropTypeDefaultDescription
valueRequirednumberNo defaultThe duration in milliseconds.
onChangeRequired(value: number) => voidNo defaultCalled with milliseconds when a valid value is typed or a preset is pressed.
unitsDurationUnit[] ("ms" | "s" | "min" | "h" | "d")["s", "min", "h", "d"]The units offered, in any order.
presetsnumber[]No defaultPreset values in milliseconds, shown as chips.
minnumberNo defaultLower bound in milliseconds.
maxnumberNo defaultUpper bound in milliseconds.
readout((human: string, ms: number) => ReactNode) | false(human) => humanWraps the plain-language value. false hides it.
idstringNo defaultThe number input's id.
aria-labelstring"Duration"Names the group, and the input when there's no id.
aria-describedbystringNo defaultExtra ids for the input's description.
disabledbooleanNo defaultDisables every part.
classNamestringNo defaultOn the root.

formatDuration

Spells out up to the two largest parts: 1 hour 30 minutes, 7 days. Zero and below is 0 seconds.

Props of formatDuration
PropTypeDefaultDescription
msRequirednumberNo defaultMilliseconds.

formatDurationShort

The compact chip label in the largest unit that fits: 30 s, 2 min, 1 h, 90 days.

Props of formatDurationShort
PropTypeDefaultDescription
msRequirednumberNo defaultMilliseconds.

DurationUnit

Type: "ms" | "s" | "min" | "h" | "d".

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The unit select is always named Unit and the chip group Presets, so a settings page with several pickers has many controls with the same name.

formatDuration keeps only the two largest parts, so 1 day 2 hours 30 minutes reads 1 day 2 hours. It groups counts with toLocaleString() and no locale, so a count over 999 can render differently on the server and in the browser.

formatDurationShort abbreviates every unit except days (30 s, 2 min, 1 h, 7 days), which mixes styles in one row of chips.

Preset chips are hand-built buttons that copy the active filter chip's look rather than reusing Filter chip or Toggle group.

Widths are fixed at 96px and 128px; there is no size prop for a 28px toolbar or a table cell.