Duration ​

A text input for a length of time. The value is a number of seconds, and people can type it as 1h 30m, 1:30:00 or 5400.

label
description
format
size
variant
<Duration
  label="Time spent"
  v-model="value"
/>

Examples ​

Logging time ​

Each entry is typed in whatever notation is quickest, and the saved seconds add up into a total. formatDuration renders a value outside the input.

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>

Service targets ​

format="long" writes the saved value out in words, which reads better in a settings form.

First responseReply to a new ticket within
ResolutionClose the ticket within
vue
<script setup lang="ts">
import { ref } from 'vue'
import { Duration } from 'frappe-ui'

// Service targets for a support team. `format="long"` spells the saved value
// out in words, which reads better in a settings form than `4h`.
const firstResponse = ref<number | null>(4 * 3600)
const resolution = ref<number | null>(24 * 3600)
</script>

<template>
  <div class="flex w-full max-w-md flex-col divide-y divide-outline-gray-1">
    <div class="flex items-center justify-between gap-6 py-3">
      <div class="flex flex-col gap-0.5">
        <span class="text-base font-medium text-ink-gray-8"
          >First response</span
        >
        <span class="text-p-sm text-ink-gray-5"
          >Reply to a new ticket within</span
        >
      </div>
      <Duration v-model="firstResponse" format="long" class="w-44" />
    </div>
    <div class="flex items-center justify-between gap-6 py-3">
      <div class="flex flex-col gap-0.5">
        <span class="text-base font-medium text-ink-gray-8">Resolution</span>
        <span class="text-p-sm text-ink-gray-5">Close the ticket within</span>
      </div>
      <Duration v-model="resolution" format="long" class="w-44" />
    </div>
  </div>
</template>

Behavior ​

What people can type ​

The input accepts several notations. Case does not matter, and units can come in any order.

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

The typed text is read when the input loses focus or on Enter. Text that cannot be read keeps the input open with an error, and the saved value stays as it was. Escape drops the edit. An empty input saves null.

Display format ​

When the input is not focused, it shows the saved value in the format you pass. format is a preset name or a template.

The presets leave out parts that are zero, and long adds plurals:

format544590
short (default)1h 30m 45s1m 30s
long1 hour 30 minutes 45 seconds1 minute 30 seconds
colon1:30:451:30

Any other value is a template, and every part in it is shown, zero or not. h, m and s stand for hours, minutes and seconds. Double a letter (hh) to pad it to two digits. Put text in single quotes so its letters are not read as units.

format7323
h'h' m'm' s's'2h 2m 3s
hh:mm:ss02:02:03
h':'mm2:02

m is the minutes left over after the hours (2), not the total minutes (122). A template without h drops the hours from the output.

While the input has focus, it always shows the short notation (2h 2m 3s), so the text can be edited and read back the same way whatever the format.

Label, description and error ​

label renders above the field and description below it. error renders below the field and hides description. It takes a string, an array of strings (one line each), or an Error, the same values as ErrorMessage. An empty string or an empty array means no error. required adds a red asterisk to the label and sets required on the <input>.

The #label slot replaces the label text and the required marker, and receives { required }. A #description slot is not hidden by error. It renders above the error.

vue
<Duration v-model="seconds">
  <template #label="{ required }">
    Time spent <Badge v-if="required" label="Required" />
  </template>
  <template #description>Type 1h 30m, or 01:30:00.</template>
</Duration>

Accessibility ​

KeysAction
EnterSave the typed value
Tab / blurSave the typed value
EscapeDrop the edit and show the saved value

API Reference ​

Show types
typescript
import type {
  InputExposed,
  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 extends InputExposed {}
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. A `#description` slot is not: it renders beside the error, and is referenced alongside it.

error
ErrorMessageValue

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. Takes a string, an array of strings, or an `Error` whose `messages` are rendered as stacked lines (with `Error.message` as the fallback). This is the same value `ErrorMessage.message` takes. An empty array and an empty string both mean no error.

required
boolean

Marks the field as required. Renders an asterisk next to the label, with `sr-only` text that announces it, and forwards `required` / `aria-required` to the underlying control where the control's role allows it. `data-required` is set either way.

id
string

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

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description
—

Overrides the rendered description content.

update:modelValue
[value: number | null]

Fired when the model value changes.

focus
(options?: FocusOptions) => void

Moves focus to the input.