Skip to content

Vue 3 Recipes

TypeScript components and composables for using moment-less in Vue 3 applications.

RelativeTime.vue Component

A reactive component that displays a live-updating relative time string. Uses onUnmounted to clean up the interval.

vue
<!-- components/RelativeTime.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { fromNow, fromDate } from 'moment-less'

interface Props {
  /** ISO 8601 string, JS Date, or Temporal.Instant */
  date: string | Date | Temporal.Instant
  locale?: string
  updateInterval?: number
}

const props = withDefaults(defineProps<Props>(), {
  locale: undefined,
  updateInterval: 30_000,
})

function toInstant(date: string | Date | Temporal.Instant): Temporal.Instant {
  if (date instanceof Date) return fromDate(date)
  if (typeof date === 'string') return Temporal.Instant.from(date)
  return date
}

const instant = computed(() => toInstant(props.date))
const label   = ref('')

function updateLabel() {
  label.value = fromNow(instant.value, Temporal.Now.instant(), props.locale)
}

let intervalId: ReturnType<typeof setInterval> | undefined

onMounted(() => {
  updateLabel()
  intervalId = setInterval(updateLabel, props.updateInterval)
})

onUnmounted(() => {
  if (intervalId !== undefined) clearInterval(intervalId)
})

// Re-render if date prop changes
watch(() => props.date, updateLabel)

const isoString = computed(() => instant.value.toString())
</script>

<template>
  <time :datetime="isoString" :title="isoString">{{ label }}</time>
</template>

Usage:

vue
<script setup lang="ts">
import RelativeTime from '@/components/RelativeTime.vue'

const comment = {
  body: 'This is a great post!',
  createdAt: '2026-04-09T10:00:00Z',
}
</script>

<template>
  <article>
    <p>{{ comment.body }}</p>
    <RelativeTime :date="comment.createdAt" locale="en" />
  </article>
</template>

useCalendar Composable

A composable that returns a reactive calendar label for a date, updating whenever a reactive reference point changes.

ts
// composables/useCalendar.ts
import { computed, type Ref, type MaybeRef, toValue } from 'vue'
import { calendar, fromDate } from 'moment-less'
import type { CalendarOptions } from 'moment-less'

type CalendarInput =
  | Date
  | string
  | Temporal.PlainDate
  | Temporal.PlainDateTime
  | Temporal.Instant

function toCalendarTemporal(input: CalendarInput) {
  if (input instanceof Date) return fromDate(input)
  if (typeof input === 'string') {
    try { return Temporal.Instant.from(input) } catch {}
    try { return Temporal.PlainDateTime.from(input) } catch {}
    return Temporal.PlainDate.from(input)
  }
  return input
}

export function useCalendar(
  date: MaybeRef<CalendarInput>,
  options?: MaybeRef<CalendarOptions>
) {
  const label = computed(() => {
    const input = toCalendarTemporal(toValue(date))
    const ref   = Temporal.Now.instant()
    const opts  = toValue(options)
    return calendar(input, ref, opts)
  })

  return { label }
}

Usage in a component:

vue
<script setup lang="ts">
import { ref } from 'vue'
import { useCalendar } from '@/composables/useCalendar'

const sentAt = ref('2026-04-09T09:15:00Z')
const { label } = useCalendar(sentAt, { timeFormat: 'h12' })
</script>

<template>
  <span class="text-sm text-gray-500">{{ label }}</span>
</template>

useHumanizedDuration Composable

ts
// composables/useHumanizedDuration.ts
import { computed, type MaybeRef, toValue } from 'vue'
import { humanizeDuration } from 'moment-less'

export function useHumanizedDuration(
  duration: MaybeRef<Temporal.Duration | string>,
  locale?: MaybeRef<string>
) {
  const label = computed(() => {
    const raw = toValue(duration)
    const dur = typeof raw === 'string' ? Temporal.Duration.from(raw) : raw
    return humanizeDuration(dur, toValue(locale))
  })

  return { label }
}

Usage:

vue
<script setup lang="ts">
import { useHumanizedDuration } from '@/composables/useHumanizedDuration'

const props = defineProps<{ duration: string }>()
const { label } = useHumanizedDuration(() => props.duration)
</script>

<template>
  <span class="badge">{{ label }}</span>
</template>

v-date-format Custom Directive

A custom directive that formats an element's text content with a format string. This is most useful when you want declarative formatting directly in templates without creating wrapper components.

ts
// directives/vDateFormat.ts
import type { DirectiveBinding } from 'vue'
import { format, fromDate } from 'moment-less'

interface DateFormatBinding {
  /** Format string, e.g. 'MMMM Do, YYYY' */
  format: string
  /** Optional BCP 47 locale tag */
  locale?: string
  /** The date value: ISO string, Date, or Temporal object */
  value?: string | Date | Temporal.PlainDate | Temporal.PlainDateTime | Temporal.Instant
}

function applyFormat(el: HTMLElement, binding: DirectiveBinding<DateFormatBinding>) {
  const { format: fmt, locale, value } = binding.value

  let temporal: Parameters<typeof format>[0]
  const raw = value ?? el.textContent?.trim() ?? ''

  if (raw instanceof Date) {
    temporal = fromDate(raw)
  } else if (typeof raw === 'string') {
    try { temporal = Temporal.Instant.from(raw) }
    catch { temporal = Temporal.PlainDate.from(raw) }
  } else {
    temporal = raw
  }

  el.textContent = format(temporal, fmt, { locale })
}

export const vDateFormat = {
  mounted: applyFormat,
  updated: applyFormat,
}

Registration (global):

ts
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { vDateFormat } from './directives/vDateFormat'

const app = createApp(App)
app.directive('date-format', vDateFormat)
app.mount('#app')

Usage in templates:

vue
<template>
  <!-- Format the ISO string inside the element -->
  <time
    v-date-format="{
      format: 'MMMM Do, YYYY',
      locale: 'en',
      value: post.publishedAt,
    }"
    :datetime="post.publishedAt"
  />

  <!-- French month name -->
  <span
    v-date-format="{
      format: 'MMMM YYYY',
      locale: 'fr',
      value: '2026-04-09',
    }"
  />
  <!-- → "avril 2026" -->
</template>

Pinia Store Integration

If you use Pinia for state management, you can keep formatting logic close to your store data using getters:

ts
// stores/notifications.ts
import { defineStore } from 'pinia'
import { calendar, fromDate } from 'moment-less'

interface Notification {
  id: string
  message: string
  createdAt: Date
}

export const useNotificationsStore = defineStore('notifications', {
  state: () => ({
    items: [] as Notification[],
  }),

  getters: {
    formattedItems: (state) =>
      state.items.map((n) => ({
        ...n,
        timeLabel: calendar(
          fromDate(n.createdAt),
          Temporal.Now.instant(),
          { timeFormat: 'h12' }
        ),
      })),
  },

  actions: {
    add(message: string) {
      this.items.unshift({
        id: crypto.randomUUID(),
        message,
        createdAt: new Date(),
      })
    },
  },
})

Released under the MIT License.