Duration picker
A number and unit pair, with presets, for timeouts, delays and windows.
Hangs up after 1 minute of silence
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
Say it in words
Anatomy#
1 minute
- Number. A 96px Input, right-aligned in tabular figures, with a decimal keypad on phones.
- Unit. A 128px Select of the allowed units, spelled out: seconds, minutes, days.
- Readout. 13px Slate Meta text from
formatDuration, wrapped byreadout, in a polite live region. - 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.
Deleted after 90 days
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.
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.
| Milliseconds | formatDuration | formatDurationShort |
|---|---|---|
| 45,000 | 45 seconds | 45 s |
| 90,000 | 1 minute 30 seconds | 90 s |
| 1,800,000 | 30 minutes | 30 min |
| 5,400,000 | 1 hour 30 minutes | 90 min |
| 86,400,000 | 1 day | 1 day |
| 93,600,000 | 1 day 2 hours | 26 h |
| 7,776,000,000 | 90 days | 90 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#
| State | Treatment |
|---|---|
| Rest | The value in its best unit, the readout beside it. |
| Focus visible | The input or select takes its indigo border and 3px ring. |
| Invalid | Out 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 selected | The chip matching the value is pressed: indigo tint and inset ring. |
| Preset hover | Unselected chips move from Well Gray at 70% to full Well Gray and Graphite Ink. |
| Preset out of range | Chips outside min and max dim to 50% and can't be pressed. |
| Disabled | Input, select and chips are all disabled. |
Behavior#
valueis milliseconds andonChangereceives 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
05becomes5. - 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
valuefrom outside resyncs the number and unit.
Do and don't#
Hangs up after 1 minute 30 seconds of silence
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-labelthat 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 byaria-label. Without anid, the input takes the same name; with anid, 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-describedbyand mark itaria-invalid. - Presets are toggle buttons with
aria-pressedin a group named Presets. - Out-of-range presets are disabled rather than hidden, so the set stays stable.
| Keys | Action |
|---|---|
| Tab | Moves from the number to the unit select, then through the presets. |
| Space | Opens the unit select, or presses a preset. |
| ↑↓ | Moves through units in the open select. |
| Enter | Chooses a unit, or presses a preset. |
Design tokens#
| Token | Used for |
|---|---|
--input | Input and select strokes |
--ring | Focus rings |
--destructive | Invalid ring and error text |
--muted | Preset chips at 70%, full on hover |
--primary | Selected preset: 6% tint and 22% inset ring |
--muted-foreground | Readout and unselected preset text |
--radius-lg | 10px corners on every part |
API reference#
DurationPicker
A number and unit that hold one value in milliseconds.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | number | No default | The duration in milliseconds. |
onChangeRequired | (value: number) => void | No default | Called with milliseconds when a valid value is typed or a preset is pressed. |
units | DurationUnit[] ("ms" | "s" | "min" | "h" | "d") | ["s", "min", "h", "d"] | The units offered, in any order. |
presets | number[] | No default | Preset values in milliseconds, shown as chips. |
min | number | No default | Lower bound in milliseconds. |
max | number | No default | Upper bound in milliseconds. |
readout | ((human: string, ms: number) => ReactNode) | false | (human) => human | Wraps the plain-language value. false hides it. |
id | string | No default | The number input's id. |
aria-label | string | "Duration" | Names the group, and the input when there's no id. |
aria-describedby | string | No default | Extra ids for the input's description. |
disabled | boolean | No default | Disables every part. |
className | string | No default | On the root. |
formatDuration
Spells out up to the two largest parts: 1 hour 30 minutes, 7 days. Zero and below is 0 seconds.
| Prop | Type | Default | Description |
|---|---|---|---|
msRequired | number | No default | Milliseconds. |
formatDurationShort
The compact chip label in the largest unit that fits: 30 s, 2 min, 1 h, 90 days.
| Prop | Type | Default | Description |
|---|---|---|---|
msRequired | number | No default | Milliseconds. |
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.