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

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

Discovery call45m
Proposal draft1h 30m 45s
Total logged2 hours 15 minutes 45 seconds
vue
<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):

NotationExampleSeconds
Short units1h 30m 45s, 1h30m45s, 45s 1h 30m5445
Long units1 hour 30 minutes, 2hrs 15min5400, 8100
Colon1:30:45, 1:30, :455445, 90, 45
Bare integer9090

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):

format544590
short (default)1h 30m 45s1m 30s
long1 hour 30 minutes 45 seconds1 minute 30 seconds
colon1:30:451: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.

format7323
h'h' m'm' s's'2h 2m 3s
hh:mm:ss02:02:03
h':'mm2: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 3s notation so the typed value round-trips reliably through the parser, whatever the display format.

vue
<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

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

Time spent on this task.

vue
<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

KeysAction
EnterCommit the typed value
EscapeCancel the edit and revert to the saved value
Tab / blurCommit 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
typescript
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
}
modelValue
= null
number | null

The duration value in seconds (two-way via `v-model`).

placeholder
= "1h 30m 45s"
string

Placeholder shown when the input is empty. Defaults to `1h 30m 45s`.

format
= "short"
DurationFormat

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

size
InputSize

Visual size of the input. Forwarded to the underlying `TextInput`.

variant
InputVariant

Style variant of the input. Forwarded to the underlying `TextInput`.

disabled
boolean

Disables the input when true.

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set.

error
string | FrappeUIError

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

required
boolean

Marks the field as required. Renders an asterisk next to the label and forwards `required` / `aria-required` to the underlying control.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

update:modelValue
[value: number | null]

Fired when the model value changes.