Duration
A text input for entering a length of time. The caller works in seconds (via v-model); the user types a human-readable duration in any common notation and the component parses it on commit.
Playground
<script setup lang="ts">
import { ref } from 'vue'
import { Duration } from 'frappe-ui'
const value = ref<number | null>(5445)
</script>
<template>
<Duration v-model="value" label="Time spent" />
</template>Example
Logging effort against a task: the user enters a duration in any notation and the saved seconds roll up into a total.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, Duration, formatDuration } from 'frappe-ui'
// Logging effort against a CRM task: the user types a duration in any
// notation, "Log time" appends it, and the entries roll up into a total.
const entries = ref<{ label: string; seconds: number }[]>([
{ label: 'Discovery call', seconds: 2700 },
{ label: 'Proposal draft', seconds: 5445 },
])
const draft = ref<number | null>(null)
const total = computed(() =>
entries.value.reduce((sum, entry) => sum + entry.seconds, 0),
)
function logTime() {
if (!draft.value) return
entries.value.push({ label: 'Follow-up', seconds: draft.value })
draft.value = null
}
</script>
<template>
<div class="flex w-80 flex-col gap-4">
<div class="flex flex-col gap-2">
<div
v-for="(entry, index) in entries"
:key="index"
class="flex items-center justify-between text-base text-ink-gray-7"
>
<span>{{ entry.label }}</span>
<span class="text-ink-gray-9">{{ formatDuration(entry.seconds) }}</span>
</div>
</div>
<div class="flex items-end gap-2">
<Duration v-model="draft" label="Log time" placeholder="e.g. 45m" />
<Button label="Log time" @click="logTime" />
</div>
<div
class="flex items-center justify-between border-t border-outline-gray-1 pt-3 text-base font-medium text-ink-gray-9"
>
<span>Total logged</span>
<span>{{ formatDuration(total, 'long') }}</span>
</div>
</div>
</template>Input notation
The field accepts several notations, case-insensitive, with units in any order. Parsing happens when the field is committed (blur or Enter):
| Notation | Example | Seconds |
|---|---|---|
| Short units | 1h 30m 45s, 1h30m45s, 45s 1h 30m | 5445 |
| Long units | 1 hour 30 minutes, 2hrs 15min | 5400, 8100 |
| Colon | 1:30:45, 1:30, :45 | 5445, 90, 45 |
| Bare integer | 90 | 90 |
Invalid input keeps the field open with the typed text and an inline error so it can be corrected; the saved value is left untouched until a valid commit. Escape abandons the edit, and clearing the field commits null.
Display format
When the field is not focused, the saved value is rendered using format. This is either a named preset or a token template string. The default is the short preset, which renders 2h 2m 3s and omits zero parts.
Presets
Presets are smart — they omit zero components (and long pluralizes):
format | 5445 | 90 |
|---|---|---|
short (default) | 1h 30m 45s | 1m 30s |
long | 1 hour 30 minutes 45 seconds | 1 minute 30 seconds |
colon | 1:30:45 | 1:30 |
Token templates
Any other format value is a template, rendered literally (no zero-omission). h/m/s are unit tokens; double them (hh/mm/ss) to zero-pad to two digits. Wrap label text in single quotes so the unit letters render as text.
format | 7323 |
|---|---|
h'h' m'm' s's' | 2h 2m 3s |
hh:mm:ss | 02:02:03 |
h':'mm | 2:02 |
Each token renders only its own component — m is the minutes-within-the-hour (2), not the total minutes (122). Include every unit you want to keep, or a higher unit's value is dropped from the output.
Editing always switches to the canonical
2h 2m 3snotation so the typed value round-trips reliably through the parser, whatever the display format.
<script setup lang="ts">
import { ref } from 'vue'
import { Duration } from 'frappe-ui'
const value = ref<number | null>(7323)
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<Duration v-model="value" format="short" label="short (default)" />
<Duration v-model="value" format="long" label="long" />
<Duration v-model="value" format="colon" label="colon" />
<Duration v-model="value" format="h'h' m'm' s's'" label="template: h'h' m'm' s's'" />
<Duration v-model="value" format="hh:mm:ss" label="template: hh:mm:ss" />
</div>
</template>Sizes
<script setup lang="ts">
import { ref } from 'vue'
import { Duration } from 'frappe-ui'
const value = ref<number | null>(5445)
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<Duration v-model="value" size="sm" />
<Duration v-model="value" size="md" />
<Duration v-model="value" size="lg" />
<Duration v-model="value" size="xl" />
</div>
</template>Labeling
Duration implements the shared input labeling contract (label, description, error, required), forwarded to the underlying TextInput.
<script setup lang="ts">
import { Duration } from 'frappe-ui'
</script>
<template>
<div class="flex flex-col gap-4 items-start">
<Duration label="Default" :model-value="5445" />
<Duration
label="With description"
description="Time spent on this task."
:model-value="5445"
/>
<Duration label="Required" required :model-value="5445" />
<Duration label="Disabled" disabled :model-value="5445" />
<Duration
label="With error"
error="Please enter a duration."
:model-value="5445"
/>
</div>
</template>Keyboard
| Keys | Action |
|---|---|
Enter | Commit the typed value |
Escape | Cancel the edit and revert to the saved value |
Tab / blur | Commit the typed value |
Customization
Duration renders a TextInput, so the same data-attribute styling hooks apply — data-slot="control", data-size, data-disabled, and data-state="invalid" on error. See TextInput for the full taxonomy.
API Reference
Show types
import type { InputSize, InputVariant } from '../../composables/inputTypes'
import type { InputLabelingProps } from '../../composables/useInputLabeling'
/**
* Named display presets (smart zero-omission; `long` also pluralizes):
* short — "1h 30m 45s"
* long — "1 hour 30 minutes 45 seconds"
* colon — "1:30:45"
*/
export type DurationFormatPreset = 'short' | 'long' | 'colon'
/**
* How a duration is rendered: a named preset, or a token template string
* rendered literally — `h`/`hh`, `m`/`mm`, `s`/`ss`, with single-quoted text
* taken as a literal (e.g. `h'h' m'm' s's'` → "2h 2m 3s", `hh:mm:ss` → "02:02:03").
*/
// `string & {}` keeps preset autocomplete while still accepting any template.
export type DurationFormat = DurationFormatPreset | (string & {})
export interface DurationProps extends InputLabelingProps {
/** The duration value in seconds (two-way via `v-model`). */
modelValue?: number | null
/** Placeholder shown when the input is empty. Defaults to `1h 30m 45s`. */
placeholder?: string
/**
* How the saved value is rendered when not focused: a named preset
* (`short` | `long` | `colon`) or a token template (e.g. `h'h' m'm' s's'`,
* `hh:mm:ss`). Defaults to `short`.
*/
format?: DurationFormat
/** Visual size of the input. Forwarded to the underlying `TextInput`. */
size?: InputSize
/** Style variant of the input. Forwarded to the underlying `TextInput`. */
variant?: InputVariant
/** Disables the input when true. */
disabled?: boolean
}
export interface DurationEmits {
/** Fired when the model value changes. */
'update:modelValue': [value: number | null]
}
export interface DurationExposed {
/** Focuses the underlying text input. */
focus: () => void
}The duration value in seconds (two-way via `v-model`).
Placeholder shown when the input is empty. Defaults to `1h 30m 45s`.
How the saved value is rendered when not focused: a named preset (`short` | `long` | `colon`) or a token template (e.g. `h'h' m'm' s's'`, `hh:mm:ss`). Defaults to `short`.
Visual size of the input. Forwarded to the underlying `TextInput`.
Style variant of the input. Forwarded to the underlying `TextInput`.
Disables the input when true.
Label rendered above (or beside, for binary controls) the input.
Helper text rendered below the input. Hidden when `error` is set.
Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).
Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.
HTML id of the underlying control. Auto-generated via `useId()` if omitted.
| Event | Payload |
|---|---|
update:modelValue | [value: number | null] Fired when the model value changes. |
Fired when the model value changes.